/** * Unit tests for src/repositories/AuditRepository.ts * Uses a mocked pg.Pool — no real database connection. */ import { Pool } from 'pg'; import { AuditRepository } from '../../../src/repositories/AuditRepository'; import { IAuditEvent, ICreateAuditEventInput, IAuditListFilters } from '../../../src/types/index'; jest.mock('pg', () => ({ Pool: jest.fn().mockImplementation(() => ({ query: jest.fn(), connect: jest.fn(), })), })); // ─── helpers ───────────────────────────────────────────────────────────────── const AUDIT_ROW = { event_id: 'evt-0000-0000-0000-000000000001', agent_id: 'agent-0000-0000-0000-000000000001', action: 'agent.created', outcome: 'success', ip_address: '127.0.0.1', user_agent: 'test-agent/1.0', metadata: { agentType: 'screener' }, timestamp: new Date('2026-03-28T09:00:00Z'), }; const EXPECTED_EVENT: IAuditEvent = { eventId: AUDIT_ROW.event_id, agentId: AUDIT_ROW.agent_id, action: 'agent.created', outcome: 'success', ipAddress: AUDIT_ROW.ip_address, userAgent: AUDIT_ROW.user_agent, metadata: AUDIT_ROW.metadata, timestamp: AUDIT_ROW.timestamp, }; const RETENTION_CUTOFF = new Date('2026-01-01T00:00:00Z'); // ─── suite ─────────────────────────────────────────────────────────────────── describe('AuditRepository', () => { let pool: jest.Mocked; let repo: AuditRepository; beforeEach(() => { jest.clearAllMocks(); pool = new Pool() as jest.Mocked; repo = new AuditRepository(pool); }); // ── create ────────────────────────────────────────────────────────────────── describe('create()', () => { const eventInput: ICreateAuditEventInput = { agentId: AUDIT_ROW.agent_id, action: 'agent.created', outcome: 'success', ipAddress: '127.0.0.1', userAgent: 'test-agent/1.0', metadata: { agentType: 'screener' }, }; it('should insert a row and return a mapped IAuditEvent', async () => { // create() first SELECTs the previous hash, then INSERTs the new event (pool.query as jest.Mock) .mockResolvedValueOnce({ rows: [], rowCount: 0 }) // SELECT hash (no previous event) .mockResolvedValueOnce({ rows: [AUDIT_ROW], rowCount: 1 }); // INSERT const result = await repo.create(eventInput); expect(pool.query).toHaveBeenCalledTimes(2); // Second call is the INSERT const [sql, params] = (pool.query as jest.Mock).mock.calls[1] as [string, unknown[]]; expect(sql).toContain('INSERT INTO audit_events'); expect(params).toContain(eventInput.agentId); expect(params).toContain(eventInput.action); expect(params).toContain(eventInput.outcome); expect(params).toContain(eventInput.ipAddress); expect(params).toContain(eventInput.userAgent); expect(result).toMatchObject(EXPECTED_EVENT); }); it('should JSON-stringify the metadata field', async () => { // create() first SELECTs the previous hash, then INSERTs the new event (pool.query as jest.Mock) .mockResolvedValueOnce({ rows: [], rowCount: 0 }) // SELECT hash (no previous event) .mockResolvedValueOnce({ rows: [AUDIT_ROW], rowCount: 1 }); // INSERT await repo.create(eventInput); // Second call is the INSERT const [, params] = (pool.query as jest.Mock).mock.calls[1] as [string, unknown[]]; // metadata param should be a JSON string const metadataParam = params.find((p) => typeof p === 'string' && p.startsWith('{')); expect(metadataParam).toBe(JSON.stringify(eventInput.metadata)); }); }); // ── findById ───────────────────────────────────────────────────────────────── describe('findById()', () => { it('should return a mapped IAuditEvent when found', async () => { (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [AUDIT_ROW], rowCount: 1 }); const result = await repo.findById(AUDIT_ROW.event_id); expect(pool.query).toHaveBeenCalledWith( expect.stringContaining('event_id'), [AUDIT_ROW.event_id], ); expect(result).toMatchObject(EXPECTED_EVENT); }); it('should return null when not found', async () => { (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 }); const result = await repo.findById('nonexistent'); expect(result).toBeNull(); }); }); // ── findAll ────────────────────────────────────────────────────────────────── describe('findAll()', () => { it('should return paginated events with total count (no optional filters)', async () => { (pool.query as jest.Mock) .mockResolvedValueOnce({ rows: [{ count: '1' }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [AUDIT_ROW], rowCount: 1 }); const filters: IAuditListFilters = { page: 1, limit: 50 }; const result = await repo.findAll(filters, RETENTION_CUTOFF); expect(pool.query).toHaveBeenCalledTimes(2); expect(result.total).toBe(1); expect(result.events).toHaveLength(1); expect(result.events[0]).toMatchObject(EXPECTED_EVENT); }); it('should include retention cutoff in the WHERE clause', async () => { (pool.query as jest.Mock) .mockResolvedValueOnce({ rows: [{ count: '0' }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 0 }); await repo.findAll({ page: 1, limit: 50 }, RETENTION_CUTOFF); const [countSql, countParams] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]]; expect(countSql).toContain('timestamp'); expect(countParams).toContain(RETENTION_CUTOFF); }); it('should apply agentId filter', async () => { (pool.query as jest.Mock) .mockResolvedValueOnce({ rows: [{ count: '1' }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [AUDIT_ROW], rowCount: 1 }); const filters: IAuditListFilters = { page: 1, limit: 50, agentId: AUDIT_ROW.agent_id }; await repo.findAll(filters, RETENTION_CUTOFF); const [countSql] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]]; expect(countSql).toContain('agent_id'); }); it('should apply action filter', async () => { (pool.query as jest.Mock) .mockResolvedValueOnce({ rows: [{ count: '0' }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 0 }); await repo.findAll({ page: 1, limit: 50, action: 'token.issued' }, RETENTION_CUTOFF); const [countSql] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]]; expect(countSql).toContain('action'); }); it('should apply outcome filter', async () => { (pool.query as jest.Mock) .mockResolvedValueOnce({ rows: [{ count: '0' }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 0 }); await repo.findAll({ page: 1, limit: 50, outcome: 'failure' }, RETENTION_CUTOFF); const [countSql] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]]; expect(countSql).toContain('outcome'); }); it('should apply fromDate filter', async () => { (pool.query as jest.Mock) .mockResolvedValueOnce({ rows: [{ count: '0' }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 0 }); await repo.findAll( { page: 1, limit: 50, fromDate: '2026-03-01T00:00:00Z' }, RETENTION_CUTOFF, ); const [countSql] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]]; expect(countSql).toContain('timestamp'); }); it('should apply toDate filter', async () => { (pool.query as jest.Mock) .mockResolvedValueOnce({ rows: [{ count: '0' }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 0 }); await repo.findAll( { page: 1, limit: 50, toDate: '2026-03-31T23:59:59Z' }, RETENTION_CUTOFF, ); const [countSql] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]]; expect(countSql).toContain('timestamp'); }); it('should return empty list when no events exist', async () => { (pool.query as jest.Mock) .mockResolvedValueOnce({ rows: [{ count: '0' }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 0 }); const result = await repo.findAll({ page: 1, limit: 50 }, RETENTION_CUTOFF); expect(result.total).toBe(0); expect(result.events).toEqual([]); }); }); });