import { Finding } from '../../src/modules/findings/domain/entities/Finding'; import { Severity } from '../../src/modules/findings/domain/value-objects/Severity'; import { FindingType } from '../../src/modules/findings/domain/value-objects/FindingType'; import { FindingStatus } from '../../src/modules/findings/domain/value-objects/FindingStatus'; import { Evidence } from '../../src/modules/findings/domain/value-objects/Evidence'; import { CreateFindingCommand } from '../../src/modules/findings/application/commands/CreateFindingCommand'; import { ListFindingsQuery } from '../../src/modules/findings/application/queries/ListFindingsQuery'; import { IFindingRepository, FindingFilters } from '../../src/modules/findings/domain/ports/IFindingRepository'; import { EventBus } from '../../src/shared/application/EventBus'; import { DomainEvent } from '../../src/shared/domain/DomainEvent'; import { EventHandler } from '../../src/shared/application/EventHandler'; import { IAnomaly } from '../../src/core/interfaces'; // ─── Mock Repository ────────────────────────────────────────────────────────── class InMemoryFindingRepository implements IFindingRepository { private store = new Map(); async save(finding: Finding): Promise { this.store.set(finding.id.toString(), finding); } async findById(id: string): Promise { return this.store.get(id); } async findAll(filters?: FindingFilters): Promise { let findings = Array.from(this.store.values()); if (filters?.severity) findings = findings.filter(f => f.severity.value === filters.severity); if (filters?.status) findings = findings.filter(f => f.status.value === filters.status); if (filters?.sessionId) findings = findings.filter(f => f.sessionId === filters.sessionId); return findings; } async update(finding: Finding): Promise { this.store.set(finding.id.toString(), finding); } async count(filters?: FindingFilters): Promise { return (await this.findAll(filters)).length; } async countBySeverity(): Promise> { const result: Record = {}; for (const f of this.store.values()) { result[f.severity.value] = (result[f.severity.value] ?? 0) + 1; } return result; } } class MockEventBus implements EventBus { published: DomainEvent[] = []; async publish(event: DomainEvent): Promise { this.published.push(event); } subscribe(_name: string, _handler: EventHandler): void {} } // ─── Test Helpers ───────────────────────────────────────────────────────────── function makeAnomaly(overrides: Partial = {}): IAnomaly { return { id: 'anom-1', type: 'http_error', severity: 'high', observationId: 'obs-1', actionTrace: [], description: 'HTTP 500 error', evidence: { rawErrors: ['Error'] }, timestamp: Date.now(), ...overrides, }; } // ─── Tests ──────────────────────────────────────────────────────────────────── describe('Finding aggregate', () => { it('creates with open status and emits FindingCreated event', () => { const finding = Finding.create({ sessionId: 'sess-1', severity: Severity.high(), type: FindingType.fromString('http_error'), description: 'Test finding', evidence: Evidence.empty(), actionTrace: [], }); expect(finding.status.value).toBe('open'); expect(finding.domainEvents).toHaveLength(1); expect(finding.domainEvents[0].eventName).toBe('finding.created'); }); it('resolves and emits FindingResolved event', () => { const finding = Finding.create({ sessionId: 'sess-1', severity: Severity.low(), type: FindingType.fromString('console_error'), description: 'Console error', evidence: Evidence.empty(), actionTrace: [], }); finding.clearEvents(); finding.resolve(); expect(finding.status.value).toBe('resolved'); expect(finding.resolvedAt).toBeDefined(); expect(finding.domainEvents[0].eventName).toBe('finding.resolved'); }); it('transitions status: open → investigating → closed', () => { const finding = Finding.create({ sessionId: 'sess-1', severity: Severity.medium(), type: FindingType.fromString('js_exception'), description: 'JS error', evidence: Evidence.empty(), actionTrace: [], }); finding.investigate(); expect(finding.status.value).toBe('investigating'); finding.close(); expect(finding.status.value).toBe('closed'); }); }); describe('Severity value object', () => { it('creates from valid string', () => { expect(Severity.fromString('critical').value).toBe('critical'); }); it('throws on invalid severity', () => { expect(() => Severity.fromString('extreme')).toThrow(); }); it('equals by value', () => { expect(Severity.high().equals(Severity.high())).toBe(true); expect(Severity.high().equals(Severity.low())).toBe(false); }); }); describe('FindingStatus value object', () => { it('creates all valid statuses', () => { expect(FindingStatus.open().value).toBe('open'); expect(FindingStatus.investigating().value).toBe('investigating'); expect(FindingStatus.resolved().value).toBe('resolved'); expect(FindingStatus.closed().value).toBe('closed'); }); it('throws on invalid status', () => { expect(() => FindingStatus.fromString('unknown')).toThrow(); }); }); describe('CreateFindingCommand', () => { let repo: InMemoryFindingRepository; let bus: MockEventBus; let cmd: CreateFindingCommand; beforeEach(() => { repo = new InMemoryFindingRepository(); bus = new MockEventBus(); cmd = new CreateFindingCommand(repo, bus); }); it('creates a finding from an anomaly', async () => { const anomaly = makeAnomaly(); const result = await cmd.execute({ anomaly, sessionId: 'sess-1' }); expect(result.ok).toBe(true); if (result.ok) { const finding = await repo.findById(result.value.findingId); expect(finding).toBeDefined(); expect(finding?.severity.value).toBe('high'); expect(finding?.type.value).toBe('http_error'); expect(finding?.sessionId).toBe('sess-1'); } }); it('publishes FindingCreated event', async () => { await cmd.execute({ anomaly: makeAnomaly(), sessionId: 'sess-1' }); expect(bus.published.some(e => e.eventName === 'finding.created')).toBe(true); }); it('returns Err on invalid severity', async () => { const anomaly = makeAnomaly({ severity: 'extreme' as IAnomaly['severity'] }); const result = await cmd.execute({ anomaly, sessionId: 'sess-1' }); expect(result.ok).toBe(false); }); }); describe('ListFindingsQuery', () => { let repo: InMemoryFindingRepository; let query: ListFindingsQuery; beforeEach(async () => { repo = new InMemoryFindingRepository(); const bus = new MockEventBus(); const cmd = new CreateFindingCommand(repo, bus); await cmd.execute({ anomaly: makeAnomaly({ id: 'a1', severity: 'high', type: 'http_error' }), sessionId: 'sess-1' }); await cmd.execute({ anomaly: makeAnomaly({ id: 'a2', severity: 'low', type: 'console_error' }), sessionId: 'sess-1' }); await cmd.execute({ anomaly: makeAnomaly({ id: 'a3', severity: 'critical', type: 'xss_reflection' }), sessionId: 'sess-2' }); query = new ListFindingsQuery(repo); }); it('returns all findings without filters', async () => { const result = await query.execute({}); expect(result.ok).toBe(true); if (result.ok) { expect(result.value.total).toBe(3); } }); it('filters by severity', async () => { const result = await query.execute({ severity: 'high' }); expect(result.ok).toBe(true); if (result.ok) { expect(result.value.findings.every(f => f.severity.value === 'high')).toBe(true); } }); it('filters by sessionId', async () => { const result = await query.execute({ sessionId: 'sess-2' }); expect(result.ok).toBe(true); if (result.ok) { expect(result.value.total).toBe(1); expect(result.value.findings[0].sessionId).toBe('sess-2'); } }); });