Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions mcp-server/tests/format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { describe, it, expect } from 'vitest';
import { formatScanResult, CHARACTER_LIMIT, type ResponseFormat } from '../src/format.js';
import type { ScanResult } from '../src/types.js';

function makeResult(overrides: Partial<ScanResult> = {}): ScanResult {
return {
url: 'https://example.com/',
score: 72,
totalViolations: 5,
passedRules: 30,
totalRules: 36,
cms: 'wordpress',
violations: [
{
id: 'image-alt',
impact: 'critical',
description: 'Images must have alternate text',
helpUrl: 'https://dequeuniversity.com/rules/axe/4.11/image-alt',
nodeCount: 3,
wcagTags: ['wcag2a', 'wcag111'],
sampleNodes: [
{
target: ['img.logo'],
html: '<img class="logo" src="/logo.png">',
failureSummary: 'Element does not have an alt attribute',
},
],
},
{
id: 'color-contrast',
impact: 'serious',
description: 'Elements must meet minimum contrast ratio thresholds',
helpUrl: 'https://dequeuniversity.com/rules/axe/4.11/color-contrast',
nodeCount: 2,
wcagTags: ['wcag2aa', 'wcag143'],
sampleNodes: [],
},
],
...overrides,
};
}

describe('formatScanResult — markdown', () => {
it('includes score, band, violation count, and CMS', () => {
const { text } = formatScanResult(makeResult(), 'markdown', 20);
expect(text).toContain('72/100');
expect(text).toContain('Needs attention');
expect(text).toContain('wordpress');
expect(text).toContain('image-alt');
});

it('shows "Good" band for score >= 80', () => {
const { text } = formatScanResult(makeResult({ score: 90 }), 'markdown', 20);
expect(text).toContain('Good');
});

it('shows "Urgent" band for score < 50', () => {
const { text } = formatScanResult(makeResult({ score: 20 }), 'markdown', 20);
expect(text).toContain('Urgent');
});

it('shows no-violations message when violations array is empty', () => {
const { text } = formatScanResult(makeResult({ violations: [] }), 'markdown', 20);
expect(text).toContain('No automatically-detectable violations found');
});

it('includes sample selector and failure summary', () => {
const { text } = formatScanResult(makeResult(), 'markdown', 20);
expect(text).toContain('img.logo');
expect(text).toContain('Element does not have an alt attribute');
});

it('includes WCAG tags', () => {
const { text } = formatScanResult(makeResult(), 'markdown', 20);
expect(text).toContain('wcag2a');
});

it('includes help URL reference', () => {
const { text } = formatScanResult(makeResult(), 'markdown', 20);
expect(text).toContain('dequeuniversity.com');
});
});

describe('formatScanResult — json', () => {
it('returns valid JSON with the scan data', () => {
const { text, structured } = formatScanResult(makeResult(), 'json', 20);
const parsed = JSON.parse(text);
expect(parsed.score).toBe(72);
expect(parsed.url).toBe('https://example.com/');
expect(structured.score).toBe(72);
});
});

describe('formatScanResult — max_violations trimming', () => {
it('trims violations to max_violations', () => {
const { structured, text } = formatScanResult(makeResult(), 'markdown', 1);
expect(structured.violations).toHaveLength(1);
expect(structured.violations[0]?.id).toBe('image-alt');
expect(text).toContain('and 1 more rule');
});

it('does not trim when max >= total violations', () => {
const { structured, text } = formatScanResult(makeResult(), 'markdown', 100);
expect(structured.violations).toHaveLength(2);
expect(text).not.toContain('more rule');
});
});

describe('formatScanResult — character limit', () => {
it('exports a sensible CHARACTER_LIMIT constant', () => {
expect(CHARACTER_LIMIT).toBe(25_000);
});

it('reduces violation count when markdown exceeds limit', () => {
const longViolations = Array.from({ length: 100 }, (_, i) => ({
id: `rule-${i}`,
impact: 'moderate',
description: 'A'.repeat(500),
helpUrl: `https://example.com/rule-${i}`,
nodeCount: 1,
wcagTags: ['wcag2a'],
sampleNodes: [
{
target: [`.el-${i}`],
html: '<div>' + 'B'.repeat(200) + '</div>',
failureSummary: 'C'.repeat(300),
},
],
}));
const { text } = formatScanResult(
makeResult({ violations: longViolations }),
'markdown',
100,
);
expect(text).toContain('Output truncated');
});

it('strips sample HTML from oversized JSON output', () => {
const longViolations = Array.from({ length: 100 }, (_, i) => ({
id: `rule-${i}`,
impact: 'serious',
description: 'D'.repeat(500),
helpUrl: `https://example.com/rule-${i}`,
nodeCount: 1,
wcagTags: ['wcag2a'],
sampleNodes: [
{
target: [`.el-${i}`],
html: '<div>' + 'E'.repeat(200) + '</div>',
failureSummary: 'F'.repeat(300),
},
],
}));
const { text } = formatScanResult(
makeResult({ violations: longViolations }),
'json',
100,
);
const parsed = JSON.parse(text);
const allHtmlEmpty = parsed.violations.every(
(v: { sampleNodes: { html: string }[] }) =>
v.sampleNodes.every((n: { html: string }) => n.html === ''),
);
expect(allHtmlEmpty).toBe(true);
});
});

describe('formatScanResult — structured output consistency', () => {
it('structured result matches trimmed violations', () => {
const result = makeResult();
const { structured } = formatScanResult(result, 'json', 1);
expect(structured.violations).toHaveLength(1);
expect(structured.url).toBe(result.url);
expect(structured.score).toBe(result.score);
});
});
95 changes: 95 additions & 0 deletions scan-service/tests/emailer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

const sendMock = vi.fn();

vi.mock('resend', () => ({
Resend: class {
emails = { send: sendMock };
},
}));

describe('sendReport', () => {
const originalEnv = process.env['RESEND_API_KEY'];

beforeEach(() => {
vi.resetModules();
sendMock.mockReset();
});

afterEach(() => {
if (originalEnv !== undefined) {
process.env['RESEND_API_KEY'] = originalEnv;
} else {
delete process.env['RESEND_API_KEY'];
}
});

it('throws when RESEND_API_KEY is missing', async () => {
delete process.env['RESEND_API_KEY'];
const { sendReport } = await import('../src/emailer.js');
await expect(
sendReport('user@example.com', Buffer.from('pdf'), 'https://example.com', 72),
).rejects.toThrow('RESEND_API_KEY');
});

it('sends email with correct fields and PDF attachment', async () => {
process.env['RESEND_API_KEY'] = 'test-key';
sendMock.mockResolvedValueOnce({ data: { id: 'msg-1' }, error: null });
const { sendReport } = await import('../src/emailer.js');
const pdf = Buffer.from('fake-pdf-content');

await sendReport('owner@example.com', pdf, 'https://shop.example.com', 85);

expect(sendMock).toHaveBeenCalledOnce();
const call = sendMock.mock.calls[0]![0];
expect(call.to).toBe('owner@example.com');
expect(call.subject).toContain('85/100');
expect(call.html).toContain('shop.example.com');
expect(call.attachments).toHaveLength(1);
expect(call.attachments[0].filename).toContain('.pdf');
expect(call.attachments[0].content).toBe(pdf);
});

it('includes "Good" label for score >= 80', async () => {
process.env['RESEND_API_KEY'] = 'test-key';
sendMock.mockResolvedValueOnce({ data: { id: 'msg-2' }, error: null });
const { sendReport } = await import('../src/emailer.js');

await sendReport('a@b.com', Buffer.from(''), 'https://a.com', 90);
const html = sendMock.mock.calls[0]![0].html;
expect(html).toContain('Good');
});

it('includes "Needs Attention" label for score 50-79', async () => {
process.env['RESEND_API_KEY'] = 'test-key';
sendMock.mockResolvedValueOnce({ data: { id: 'msg-3' }, error: null });
const { sendReport } = await import('../src/emailer.js');

await sendReport('a@b.com', Buffer.from(''), 'https://a.com', 60);
const html = sendMock.mock.calls[0]![0].html;
expect(html).toContain('Needs Attention');
});

it('includes "Urgent Action Needed" label for score < 50', async () => {
process.env['RESEND_API_KEY'] = 'test-key';
sendMock.mockResolvedValueOnce({ data: { id: 'msg-4' }, error: null });
const { sendReport } = await import('../src/emailer.js');

await sendReport('a@b.com', Buffer.from(''), 'https://a.com', 30);
const html = sendMock.mock.calls[0]![0].html;
expect(html).toContain('Urgent Action Needed');
});

it('throws when Resend returns an error', async () => {
process.env['RESEND_API_KEY'] = 'test-key';
sendMock.mockResolvedValueOnce({
data: null,
error: { message: 'Invalid recipient', name: 'validation_error' },
});
const { sendReport } = await import('../src/emailer.js');

await expect(
sendReport('bad@example.com', Buffer.from(''), 'https://a.com', 50),
).rejects.toThrow('Failed to send email');
});
});
54 changes: 54 additions & 0 deletions scan-service/tests/notify.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

const sendMock = vi.fn();

vi.mock('resend', () => ({
Resend: class {
emails = { send: sendMock };
},
}));

describe('notifyFailure', () => {
const originalEnv = process.env['RESEND_API_KEY'];

beforeEach(() => {
vi.resetModules();
sendMock.mockReset();
});

afterEach(() => {
if (originalEnv !== undefined) {
process.env['RESEND_API_KEY'] = originalEnv;
} else {
delete process.env['RESEND_API_KEY'];
}
});

it('does nothing when RESEND_API_KEY is not set', async () => {
delete process.env['RESEND_API_KEY'];
const { notifyFailure } = await import('../src/notify.js');
await notifyFailure('abc-123', 'Something broke');
expect(sendMock).not.toHaveBeenCalled();
});

it('sends an alert email when RESEND_API_KEY is set', async () => {
process.env['RESEND_API_KEY'] = 'test-key';
sendMock.mockResolvedValueOnce({ id: 'msg-1' });
const { notifyFailure } = await import('../src/notify.js');
await notifyFailure('report-id-full', 'Scan timed out');
expect(sendMock).toHaveBeenCalledOnce();

const call = sendMock.mock.calls[0]![0];
expect(call.to).toBe('ved@neuroedge.co.uk');
expect(call.subject).toContain('report-i');
expect(call.text).toContain('report-id-full');
expect(call.text).toContain('Scan timed out');
});

it('swallows email send errors silently', async () => {
process.env['RESEND_API_KEY'] = 'test-key';
sendMock.mockRejectedValueOnce(new Error('Network error'));
const { notifyFailure } = await import('../src/notify.js');
await expect(notifyFailure('abc', 'fail')).resolves.toBeUndefined();
});
});
Loading
Loading