docs: enterprise refactor plan with ralph specs

This commit is contained in:
debian
2026-03-04 16:17:03 -05:00
parent 4c92712d20
commit f8191133c8
204 changed files with 32722 additions and 422 deletions

View File

@@ -0,0 +1,187 @@
/**
* Tests for AI providers with mocked HTTP calls.
*/
import { ClaudeProvider } from '../../src/server/enrichment/ClaudeProvider';
import { OpenAIProvider } from '../../src/server/enrichment/OpenAIProvider';
import { OllamaProvider } from '../../src/server/enrichment/OllamaProvider';
import type { IAnomaly, IEnrichmentContext } from '../../src/core/interfaces';
function makeAnomaly(): IAnomaly {
return {
id: 'anom_test',
type: 'http_error',
severity: 'high',
observationId: 'obs_1',
actionTrace: [],
description: 'HTTP 500 error on /api/users',
evidence: { rawErrors: ['GET /api/users → 500'] },
timestamp: Date.now(),
};
}
function makeContext(): IEnrichmentContext {
return {
domSnapshot: '<body>Error</body>',
httpLog: [{ url: '/api/users', status: 500, method: 'GET', durationMs: 120 }],
consoleErrors: ['Uncaught TypeError: Cannot read properties of undefined'],
actionTrace: [],
pageTitle: 'Dashboard',
url: 'http://localhost:3000/dashboard',
};
}
const VALID_ENRICHMENT_JSON = JSON.stringify({
rootCause: 'Null pointer in user data fetch',
userImpact: 'Users cannot load the dashboard',
suggestedFix: 'Add null check before accessing user.name',
confidence: 'high',
});
// ─── ClaudeProvider ───────────────────────────────────────────────────────────
describe('ClaudeProvider', () => {
const origFetch = global.fetch;
afterEach(() => {
global.fetch = origFetch;
});
it('enriches anomaly with valid response', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
content: [{ type: 'text', text: VALID_ENRICHMENT_JSON }],
}),
}) as unknown as typeof fetch;
const provider = new ClaudeProvider('test-api-key');
const result = await provider.enrich(makeAnomaly(), makeContext());
expect(result.rootCause).toBe('Null pointer in user data fetch');
expect(result.userImpact).toBe('Users cannot load the dashboard');
expect(result.confidence).toBe('high');
expect(result.provider).toBe('claude');
expect(result.generatedAt).toBeLessThanOrEqual(Date.now());
});
it('throws on non-OK API response', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 401,
text: () => Promise.resolve('Unauthorized'),
}) as unknown as typeof fetch;
const provider = new ClaudeProvider('bad-key');
await expect(provider.enrich(makeAnomaly(), makeContext())).rejects.toThrow('Anthropic API error: 401');
});
it('falls back gracefully on non-JSON response', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
content: [{ type: 'text', text: 'Sorry, I cannot help with that.' }],
}),
}) as unknown as typeof fetch;
const provider = new ClaudeProvider('test-key');
const result = await provider.enrich(makeAnomaly(), makeContext());
expect(result.rootCause).toBeTruthy();
expect(result.provider).toBe('claude');
expect(result.confidence).toBe('low');
});
});
// ─── OpenAIProvider ───────────────────────────────────────────────────────────
describe('OpenAIProvider', () => {
const origFetch = global.fetch;
afterEach(() => {
global.fetch = origFetch;
});
it('enriches anomaly with valid response', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
choices: [{ message: { content: VALID_ENRICHMENT_JSON } }],
}),
}) as unknown as typeof fetch;
const provider = new OpenAIProvider('test-openai-key');
const result = await provider.enrich(makeAnomaly(), makeContext());
expect(result.rootCause).toBe('Null pointer in user data fetch');
expect(result.confidence).toBe('high');
expect(result.provider).toBe('openai');
});
it('throws on non-OK API response', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 429,
text: () => Promise.resolve('Rate limit exceeded'),
}) as unknown as typeof fetch;
const provider = new OpenAIProvider('test-key');
await expect(provider.enrich(makeAnomaly(), makeContext())).rejects.toThrow('OpenAI API error: 429');
});
it('falls back on missing choices', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ choices: [] }),
}) as unknown as typeof fetch;
const provider = new OpenAIProvider('test-key');
const result = await provider.enrich(makeAnomaly(), makeContext());
expect(result.provider).toBe('openai');
});
});
// ─── OllamaProvider ───────────────────────────────────────────────────────────
describe('OllamaProvider', () => {
const origFetch = global.fetch;
afterEach(() => {
global.fetch = origFetch;
});
it('enriches anomaly with valid response', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ response: VALID_ENRICHMENT_JSON }),
}) as unknown as typeof fetch;
const provider = new OllamaProvider('http://localhost:11434');
const result = await provider.enrich(makeAnomaly(), makeContext());
expect(result.rootCause).toBe('Null pointer in user data fetch');
expect(result.provider).toBe('ollama');
});
it('throws on non-OK Ollama response', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 503,
}) as unknown as typeof fetch;
const provider = new OllamaProvider();
await expect(provider.enrich(makeAnomaly(), makeContext())).rejects.toThrow('Ollama API error: 503');
});
it('falls back on non-JSON response text', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ response: 'No valid JSON here' }),
}) as unknown as typeof fetch;
const provider = new OllamaProvider();
const result = await provider.enrich(makeAnomaly(), makeContext());
expect(result.provider).toBe('ollama');
expect(result.rootCause).toBeTruthy();
});
});

75
tests/server/auth.test.ts Normal file
View File

@@ -0,0 +1,75 @@
/**
* Tests for API key authentication middleware and security features.
*/
import request from 'supertest';
import { createApp } from '../../src/server/index';
import { SessionStore } from '../../src/server/SessionStore';
function makeApp(apiKey?: string) {
if (apiKey !== undefined) {
process.env['ABE_API_KEY'] = apiKey;
} else {
delete process.env['ABE_API_KEY'];
}
const store = new SessionStore('./reports');
return createApp(store);
}
afterEach(() => {
delete process.env['ABE_API_KEY'];
});
describe('Auth middleware', () => {
it('allows all requests in dev mode (no ABE_API_KEY set)', async () => {
const app = makeApp(undefined);
const res = await request(app).get('/api/sessions');
expect(res.status).not.toBe(401);
});
it('returns 401 when API key is missing', async () => {
const app = makeApp('my-secret-key');
const res = await request(app).get('/api/sessions');
expect(res.status).toBe(401);
});
it('returns 401 when wrong API key provided', async () => {
const app = makeApp('my-secret-key');
const res = await request(app).get('/api/sessions').set('x-abe-api-key', 'wrong-key');
expect(res.status).toBe(401);
});
it('allows request with correct API key', async () => {
const app = makeApp('my-secret-key');
const res = await request(app).get('/api/sessions').set('x-abe-api-key', 'my-secret-key');
expect(res.status).toBe(200);
});
});
describe('Health endpoints (no auth)', () => {
it('GET /health requires no auth', async () => {
const app = makeApp('super-secret');
const res = await request(app).get('/health');
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
});
it('GET /ready requires no auth', async () => {
const app = makeApp('super-secret');
const res = await request(app).get('/ready');
expect(res.status).toBe(200);
});
});
describe('Concurrent session limit', () => {
it('returns 429 when limit exceeded', async () => {
delete process.env['ABE_API_KEY'];
const store = new SessionStore('./reports', undefined, undefined, 0);
const app = createApp(store);
const res = await request(app)
.post('/api/sessions')
.send({ url: 'http://localhost:3000' });
expect(res.status).toBe(429);
expect(res.body.error).toMatch(/Max concurrent/i);
});
});

91
tests/server/cli.test.ts Normal file
View File

@@ -0,0 +1,91 @@
/**
* Tests for CLI flag parsing and exit code logic.
* These test the logic functions extracted from the CLI, not the CLI process itself.
*/
describe('CLI exit code logic', () => {
const severityRank: Record<string, number> = { low: 0, medium: 1, high: 2, critical: 3 };
function shouldFailOnSeverity(
anomalies: Array<{ severity: string }>,
threshold: string
): boolean {
const thresholdRank = severityRank[threshold] ?? 0;
return anomalies.some((a) => (severityRank[a.severity] ?? 0) >= thresholdRank);
}
it('exit 0 when no anomalies', () => {
expect(shouldFailOnSeverity([], 'high')).toBe(false);
});
it('exit 1 when anomaly at threshold', () => {
expect(shouldFailOnSeverity([{ severity: 'high' }], 'high')).toBe(true);
});
it('exit 1 when anomaly above threshold', () => {
expect(shouldFailOnSeverity([{ severity: 'critical' }], 'high')).toBe(true);
});
it('exit 0 when anomaly below threshold', () => {
expect(shouldFailOnSeverity([{ severity: 'low' }], 'high')).toBe(false);
});
it('exit 0 when medium anomaly and threshold is high', () => {
expect(shouldFailOnSeverity([{ severity: 'medium' }], 'high')).toBe(false);
});
});
describe('JUnit XML generation', () => {
function buildJunit(
anomalies: Array<{ id: string; type: string; severity: string; description: string }>,
url: string
): string {
function escapeXml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
const cases = anomalies
.map(
(a) =>
` <testcase name="${escapeXml(a.description)}" classname="abe.anomaly.${escapeXml(a.type)}">\n` +
` <failure message="${escapeXml(a.description)}" type="${escapeXml(a.severity)}">${escapeXml(a.id)}</failure>\n` +
` </testcase>`
)
.join('\n');
return (
`<?xml version="1.0" encoding="UTF-8"?>\n` +
`<testsuite name="ABE Exploration: ${escapeXml(url)}" tests="${anomalies.length}" failures="${anomalies.length}">\n` +
cases + '\n' +
`</testsuite>\n`
);
}
it('generates valid XML with one anomaly', () => {
const xml = buildJunit(
[{ id: 'a1', type: 'http_error', severity: 'high', description: 'Server error on form' }],
'http://localhost:3000'
);
expect(xml).toContain('<testsuite');
expect(xml).toContain('<failure');
expect(xml).toContain('http_error');
expect(xml).toContain('Server error on form');
});
it('generates empty testsuite for no anomalies', () => {
const xml = buildJunit([], 'http://localhost:3000');
expect(xml).toContain('tests="0"');
expect(xml).not.toContain('<failure');
});
it('escapes XML special characters', () => {
const xml = buildJunit(
[{ id: 'a1', type: 'http_error', severity: 'high', description: '<script>alert(1)</script>' }],
'http://app.com'
);
expect(xml).not.toContain('<script>');
expect(xml).toContain('&lt;script&gt;');
});
});

View File

@@ -0,0 +1,131 @@
/**
* Unit tests for SlackNotifier, WebhookNotifier, and NotificationService.
* HTTP calls are mocked via jest.spyOn on global fetch.
*/
import { SlackNotifier } from '../../src/server/notifications/SlackNotifier';
import { WebhookNotifier } from '../../src/server/notifications/WebhookNotifier';
import { NotificationService } from '../../src/server/notifications/NotificationService';
import { IAnomaly } from '../../src/core/interfaces';
function makeAnomaly(overrides: Partial<IAnomaly> = {}): IAnomaly {
return {
id: 'anom_1',
type: 'http_error',
severity: 'high',
observationId: 'obs_1',
actionTrace: [],
description: 'Test anomaly',
evidence: {},
timestamp: 1000000,
...overrides,
};
}
function mockFetch(ok: boolean, text = 'ok') {
return jest.spyOn(global, 'fetch').mockResolvedValue({
ok,
status: ok ? 200 : 500,
text: () => Promise.resolve(text),
} as Response);
}
afterEach(() => {
jest.restoreAllMocks();
});
// ─── SlackNotifier ────────────────────────────────────────────────────────────
describe('SlackNotifier', () => {
it('sends a POST to the Slack webhook URL', async () => {
const spy = mockFetch(true);
const notifier = new SlackNotifier('https://hooks.slack.com/test');
await notifier.send(makeAnomaly(), 'sess_1', 'http://app.com');
expect(spy).toHaveBeenCalledTimes(1);
const [url, opts] = spy.mock.calls[0]!;
expect(url).toBe('https://hooks.slack.com/test');
expect((opts as RequestInit).method).toBe('POST');
const body = JSON.parse((opts as RequestInit).body as string);
expect(body.blocks).toBeDefined();
});
it('throws when Slack returns non-200', async () => {
mockFetch(false, 'channel_not_found');
const notifier = new SlackNotifier('https://hooks.slack.com/test');
await expect(notifier.send(makeAnomaly(), 'sess_1', 'http://app.com')).rejects.toThrow('500');
});
});
// ─── WebhookNotifier ─────────────────────────────────────────────────────────
describe('WebhookNotifier', () => {
it('sends a POST with anomaly JSON to webhook URL', async () => {
const spy = mockFetch(true);
const notifier = new WebhookNotifier('https://myapp.com/webhooks/abe');
await notifier.send(makeAnomaly());
expect(spy).toHaveBeenCalledTimes(1);
const [url, opts] = spy.mock.calls[0]!;
expect(url).toBe('https://myapp.com/webhooks/abe');
expect((opts as RequestInit).method).toBe('POST');
const body = JSON.parse((opts as RequestInit).body as string);
expect(body.id).toBe('anom_1');
});
it('throws when webhook returns non-200', async () => {
mockFetch(false, 'bad gateway');
const notifier = new WebhookNotifier('https://myapp.com/webhooks/abe');
await expect(notifier.send(makeAnomaly())).rejects.toThrow('500');
});
});
// ─── NotificationService ─────────────────────────────────────────────────────
describe('NotificationService', () => {
it('skips notifications below minSeverity', async () => {
const spy = mockFetch(true);
const service = new NotificationService({
webhookUrl: 'https://myapp.com/hooks',
minSeverity: 'high',
});
await service.notify(makeAnomaly({ severity: 'low' }), 'sess_1', 'http://app.com');
expect(spy).not.toHaveBeenCalled();
});
it('sends notification at or above minSeverity', async () => {
const spy = mockFetch(true);
const service = new NotificationService({
webhookUrl: 'https://myapp.com/hooks',
minSeverity: 'high',
});
await service.notify(makeAnomaly({ severity: 'high' }), 'sess_1', 'http://app.com');
expect(spy).toHaveBeenCalledTimes(1);
});
it('calls persister with success record on successful send', async () => {
mockFetch(true);
const persisted: unknown[] = [];
const service = new NotificationService({
webhookUrl: 'https://myapp.com/hooks',
minSeverity: 'low',
persister: (r) => persisted.push(r),
});
await service.notify(makeAnomaly({ severity: 'medium' }), 'sess_1', 'http://app.com');
expect(persisted).toHaveLength(1);
expect((persisted[0] as { status: string }).status).toBe('success');
});
it('calls persister with failed record on send failure', async () => {
mockFetch(false, 'error');
jest.useFakeTimers();
const persisted: unknown[] = [];
const service = new NotificationService({
webhookUrl: 'https://myapp.com/hooks',
minSeverity: 'low',
persister: (r) => persisted.push(r),
});
await service.notify(makeAnomaly({ severity: 'medium' }), 'sess_1', 'http://app.com');
expect(persisted).toHaveLength(1);
expect((persisted[0] as { status: string }).status).toBe('failed');
jest.useRealTimers();
});
});

View File

@@ -0,0 +1,151 @@
/**
* Tests for SchedulerService: cron parsing, schedule registration, skip when session active.
*/
import Database from 'better-sqlite3';
import { runMigrations } from '../../src/db/migrations';
import { ScheduleRepository } from '../../src/db/ScheduleRepository';
import { SchedulerService } from '../../src/server/scheduler/SchedulerService';
import { SessionStore } from '../../src/server/SessionStore';
function makeDb(): Database.Database {
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
runMigrations(db);
return db;
}
describe('SchedulerService', () => {
let db: Database.Database;
let repo: ScheduleRepository;
let store: SessionStore;
let scheduler: SchedulerService;
beforeEach(() => {
db = makeDb();
repo = new ScheduleRepository(db);
store = new SessionStore('./reports');
scheduler = new SchedulerService(repo, store);
});
afterEach(() => {
scheduler.stop();
db.close();
});
it('starts without error when no schedules exist', () => {
expect(() => scheduler.start()).not.toThrow();
});
it('registers a valid cron job', () => {
repo.create({
id: 'sched_1',
name: 'Daily check',
url: 'http://example.com',
configJson: '{}',
cronExpression: '0 2 * * *',
enabled: true,
});
expect(() => scheduler.register(repo.findById('sched_1')!)).not.toThrow();
});
it('skips invalid cron expressions', () => {
repo.create({
id: 'sched_bad',
name: 'Bad',
url: 'http://x.com',
configJson: '{}',
cronExpression: 'not-valid-cron',
enabled: true,
});
expect(() => scheduler.register(repo.findById('sched_bad')!)).not.toThrow();
});
it('unregisters a schedule job', () => {
repo.create({
id: 'sched_2',
name: 'Test',
url: 'http://example.com',
configJson: '{}',
cronExpression: '0 * * * *',
enabled: true,
});
scheduler.register(repo.findById('sched_2')!);
expect(() => scheduler.unregister('sched_2')).not.toThrow();
});
it('computeNextRunAt returns a future timestamp for valid cron', () => {
const nextRun = SchedulerService.computeNextRunAt('0 2 * * *');
expect(nextRun).not.toBeNull();
expect(nextRun!).toBeGreaterThan(Date.now());
});
it('computeNextRunAt returns null for invalid cron', () => {
expect(SchedulerService.computeNextRunAt('not-valid')).toBeNull();
});
it('does not register job for disabled schedule', () => {
repo.create({
id: 'sched_3',
name: 'Disabled',
url: 'http://example.com',
configJson: '{}',
cronExpression: '0 * * * *',
enabled: false,
});
// Should not throw and job should not be registered
expect(() => scheduler.register(repo.findById('sched_3')!)).not.toThrow();
// Unregistering a non-existing job is also safe
expect(() => scheduler.unregister('sched_3')).not.toThrow();
});
});
describe('ScheduleRepository', () => {
let db: Database.Database;
let repo: ScheduleRepository;
beforeEach(() => {
db = makeDb();
repo = new ScheduleRepository(db);
});
afterEach(() => db.close());
it('creates and retrieves a schedule', () => {
repo.create({
id: 's1',
name: 'Daily',
url: 'http://test.com',
configJson: '{"maxStates":10}',
cronExpression: '0 2 * * *',
});
const record = repo.findById('s1');
expect(record).toBeDefined();
expect(record!.name).toBe('Daily');
expect(record!.enabled).toBe(true);
});
it('findAll returns all schedules', () => {
repo.create({ id: 's1', name: 'A', url: 'http://a.com', configJson: '{}', cronExpression: '0 * * * *' });
repo.create({ id: 's2', name: 'B', url: 'http://b.com', configJson: '{}', cronExpression: '0 2 * * *' });
expect(repo.findAll()).toHaveLength(2);
});
it('findAll(true) returns only enabled schedules', () => {
repo.create({ id: 's1', name: 'Enabled', url: 'http://a.com', configJson: '{}', cronExpression: '0 * * * *', enabled: true });
repo.create({ id: 's2', name: 'Disabled', url: 'http://b.com', configJson: '{}', cronExpression: '0 * * * *', enabled: false });
expect(repo.findAll(true)).toHaveLength(1);
});
it('updates enabled field', () => {
repo.create({ id: 's1', name: 'A', url: 'http://a.com', configJson: '{}', cronExpression: '0 * * * *' });
repo.update('s1', { enabled: false });
expect(repo.findById('s1')!.enabled).toBe(false);
});
it('deletes a schedule', () => {
repo.create({ id: 's1', name: 'A', url: 'http://a.com', configJson: '{}', cronExpression: '0 * * * *' });
repo.delete('s1');
expect(repo.findById('s1')).toBeUndefined();
});
});

224
tests/server/server.test.ts Normal file
View File

@@ -0,0 +1,224 @@
/**
* Integration tests for the ABE API server.
* Uses supertest to hit the Express app directly (no real browser).
* The SessionStore is given a mock that never calls Playwright.
*/
import request from 'supertest';
import { createApp } from '../../src/server/index';
import { SessionStore, SessionRecord } from '../../src/server/SessionStore';
import { IAnomaly } from '../../src/core/interfaces';
// ─── Mock SessionStore ────────────────────────────────────────────────────────
function makeAnomaly(overrides: Partial<IAnomaly> = {}): IAnomaly {
return {
id: 'anom_test1',
type: 'http_error',
severity: 'high',
observationId: 'obs_1',
actionTrace: [],
description: 'HTTP 500 on form submit',
evidence: {},
timestamp: 1000000,
...overrides,
};
}
function makeSession(overrides: Partial<SessionRecord> = {}): SessionRecord {
return {
sessionId: 'sess_1',
url: 'http://localhost:3000',
seed: 42,
maxStates: 50,
status: 'running',
startedAt: '2026-01-01T00:00:00.000Z',
statesVisited: 5,
anomaliesFound: 1,
anomalies: [makeAnomaly()],
...overrides,
};
}
class MockSessionStore extends SessionStore {
private _sessions: SessionRecord[];
constructor(sessions: SessionRecord[] = []) {
super('./reports');
this._sessions = sessions;
}
getAllSessions() { return this._sessions; }
getSession(id: string) { return this._sessions.find((s) => s.sessionId === id); }
getAllAnomalies(sessionId?: string, severity?: string) {
return this._sessions
.flatMap((s) => s.anomalies)
.filter((a) => !sessionId || this.findSessionForAnomaly(a.id) === sessionId)
.filter((a) => !severity || a.severity === severity);
}
getAnomaly(id: string) {
return this._sessions.flatMap((s) => s.anomalies).find((a) => a.id === id);
}
findSessionForAnomaly(anomalyId: string) {
return this._sessions.find((s) => s.anomalies.some((a) => a.id === anomalyId))?.sessionId;
}
screenshotPath(_anomalyId: string) { return undefined; }
stopSession(id: string) {
const s = this.getSession(id);
if (!s || s.status !== 'running') return false;
s.status = 'stopped';
return true;
}
async startSession(params: { url: string; seed: number; maxStates: number }) {
const record = makeSession({
sessionId: `sess_new`,
url: params.url,
seed: params.seed,
maxStates: params.maxStates,
status: 'running',
anomalies: [],
anomaliesFound: 0,
});
this._sessions.push(record);
return record;
}
async replayAnomaly(anomalyId: string) {
if (!this.getAnomaly(anomalyId)) throw new Error('Anomaly not found');
return `replay_${anomalyId}`;
}
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('GET /api/sessions', () => {
it('returns empty array when no sessions', async () => {
const app = createApp(new MockSessionStore([]));
const res = await request(app).get('/api/sessions');
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it('returns list of sessions', async () => {
const app = createApp(new MockSessionStore([makeSession()]));
const res = await request(app).get('/api/sessions');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].sessionId).toBe('sess_1');
expect(res.body[0].status).toBe('running');
});
});
describe('GET /api/sessions/:sessionId', () => {
it('returns session detail', async () => {
const app = createApp(new MockSessionStore([makeSession()]));
const res = await request(app).get('/api/sessions/sess_1');
expect(res.status).toBe(200);
expect(res.body.sessionId).toBe('sess_1');
expect(res.body.seed).toBe(42);
});
it('returns 404 for unknown session', async () => {
const app = createApp(new MockSessionStore([]));
const res = await request(app).get('/api/sessions/no_such');
expect(res.status).toBe(404);
});
});
describe('POST /api/sessions', () => {
it('creates a new session', async () => {
const app = createApp(new MockSessionStore([]));
const res = await request(app)
.post('/api/sessions')
.send({ url: 'http://localhost:3000', seed: 1, maxStates: 10 });
expect(res.status).toBe(201);
expect(res.body.sessionId).toBeDefined();
expect(res.body.status).toBe('running');
});
it('returns 400 when url is missing', async () => {
const app = createApp(new MockSessionStore([]));
const res = await request(app).post('/api/sessions').send({ seed: 1 });
expect(res.status).toBe(400);
});
});
describe('DELETE /api/sessions/:sessionId', () => {
it('stops a running session', async () => {
const app = createApp(new MockSessionStore([makeSession()]));
const res = await request(app).delete('/api/sessions/sess_1');
expect(res.status).toBe(200);
expect(res.body.stopped).toBe(true);
});
it('returns 404 for unknown session', async () => {
const app = createApp(new MockSessionStore([]));
const res = await request(app).delete('/api/sessions/no_such');
expect(res.status).toBe(404);
});
});
describe('GET /api/anomalies', () => {
it('returns all anomalies', async () => {
const app = createApp(new MockSessionStore([makeSession()]));
const res = await request(app).get('/api/anomalies');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].id).toBe('anom_test1');
expect(res.body[0].severity).toBe('high');
});
it('filters by severity', async () => {
const session = makeSession({
anomalies: [
makeAnomaly({ id: 'anom_h', severity: 'high' }),
makeAnomaly({ id: 'anom_l', severity: 'low' }),
],
anomaliesFound: 2,
});
const app = createApp(new MockSessionStore([session]));
const res = await request(app).get('/api/anomalies?severity=low');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].id).toBe('anom_l');
});
});
describe('GET /api/anomalies/:anomalyId', () => {
it('returns anomaly detail', async () => {
const app = createApp(new MockSessionStore([makeSession()]));
const res = await request(app).get('/api/anomalies/anom_test1');
expect(res.status).toBe(200);
expect(res.body.type).toBe('http_error');
expect(res.body.description).toBe('HTTP 500 on form submit');
});
it('returns 404 for unknown anomaly', async () => {
const app = createApp(new MockSessionStore([]));
const res = await request(app).get('/api/anomalies/no_such');
expect(res.status).toBe(404);
});
});
describe('GET /api/anomalies/:anomalyId/screenshot', () => {
it('returns 404 when no screenshot exists', async () => {
const app = createApp(new MockSessionStore([makeSession()]));
const res = await request(app).get('/api/anomalies/anom_test1/screenshot');
expect(res.status).toBe(404);
});
});
describe('POST /api/anomalies/:anomalyId/replay', () => {
it('returns replayId and running status', async () => {
const app = createApp(new MockSessionStore([makeSession()]));
const res = await request(app).post('/api/anomalies/anom_test1/replay');
expect(res.status).toBe(200);
expect(res.body.replayId).toBeDefined();
expect(res.body.status).toBe('running');
});
it('returns 404 for unknown anomaly', async () => {
const app = createApp(new MockSessionStore([]));
const res = await request(app).post('/api/anomalies/no_such/replay');
expect(res.status).toBe(404);
});
});