/** * Unit tests for src/live/snapshot.ts — snapshot provider. * * All Postgres I/O is mocked. The pool.query mock captures SQL and params so * tests can assert the query is parameterized correctly. * * Covers (spec: task 1.5.5): * 1. Three devices in event, two have non-faulty positions — two entries returned. * 2. Event with no entry_devices rows — pool returns empty rows — empty array. * 3. Positions with faulty=true are excluded from results (WHERE faulty=false * is in the SQL; mock only returns non-faulty rows, mimicking Postgres). * 4. Returns most recent non-faulty position per device (DISTINCT ON semantics; * mock returns single rows as Postgres DISTINCT ON would). * 5. ts returned as Date is converted to epoch ms in the output. * 6. speed > 0 → included; speed = 0 → omitted. * 7. angle > 0 → included as course; angle = 0 → omitted. * 8. Metrics are observed (latency and snapshot size). */ import { describe, it, expect, vi } from 'vitest'; import type { Logger } from 'pino'; import type { Pool } from 'pg'; import type { Metrics } from '../src/shared/types.js'; import { createSnapshotProvider } from '../src/live/snapshot.js'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function makeSilentLogger(): Logger { return { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn(), trace: vi.fn(), child: vi.fn().mockReturnThis(), level: 'silent', silent: vi.fn(), } as unknown as Logger; } type RecordedMetrics = Metrics & { incCalls: Array<{ name: string }>; observeCalls: Array<{ name: string; value: number }>; }; function makeMetrics(): RecordedMetrics { const incCalls: Array<{ name: string }> = []; const observeCalls: Array<{ name: string; value: number }> = []; return { incCalls, observeCalls, inc(name) { incCalls.push({ name }); }, observe(name, value) { observeCalls.push({ name, value }); }, }; } /** * Snapshot row shape returned by node-postgres (ts is a Date object). */ type SnapshotRow = { device_id: string; latitude: number; longitude: number; ts: Date; speed: number; angle: number; }; /** * Creates a mock pg.Pool whose query() returns the given rows. */ function makeMockPool(rows: SnapshotRow[]): { pool: Pool; queryCalls: Array<{ sql: string; params: unknown[] }>; } { const queryCalls: Array<{ sql: string; params: unknown[] }> = []; const query = vi.fn(async (sql: string, params: unknown[] = []) => { queryCalls.push({ sql, params }); return { rows }; }); return { pool: { query } as unknown as Pool, queryCalls }; } /** * Creates a mock pool that throws on query(). */ function makeErrorPool(error: Error): Pool { return { query: vi.fn().mockRejectedValue(error), } as unknown as Pool; } const EVENT_ID = 'aaa00000-0000-0000-0000-000000000001'; const TS_A = new Date('2025-06-01T10:00:00.000Z'); const TS_B = new Date('2025-06-01T11:00:00.000Z'); // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('createSnapshotProvider.forEvent', () => { it('returns one entry per device when each has a non-faulty position', async () => { const rows: SnapshotRow[] = [ { device_id: 'IMEI001', latitude: 41.33, longitude: 19.83, ts: TS_A, speed: 60, angle: 90 }, { device_id: 'IMEI002', latitude: 41.34, longitude: 19.84, ts: TS_B, speed: 0, angle: 0 }, ]; const { pool } = makeMockPool(rows); const provider = createSnapshotProvider(pool, makeSilentLogger(), makeMetrics()); const result = await provider.forEvent(EVENT_ID); expect(result).toHaveLength(2); const entry1 = result.find((e) => e.deviceId === 'IMEI001'); expect(entry1).toBeDefined(); expect(entry1!.lat).toBe(41.33); expect(entry1!.lon).toBe(19.83); expect(entry1!.ts).toBe(TS_A.getTime()); expect(entry1!.speed).toBe(60); // speed > 0 → included expect(entry1!.course).toBe(90); // angle > 0 → included as course const entry2 = result.find((e) => e.deviceId === 'IMEI002'); expect(entry2).toBeDefined(); expect(entry2!.speed).toBeUndefined(); // speed = 0 → omitted expect(entry2!.course).toBeUndefined(); // angle = 0 → omitted }); it('returns an empty array when the event has no registered devices', async () => { const { pool } = makeMockPool([]); const provider = createSnapshotProvider(pool, makeSilentLogger(), makeMetrics()); const result = await provider.forEvent(EVENT_ID); expect(result).toEqual([]); }); it('excludes faulty positions — returns only non-faulty positions', async () => { // The Postgres query includes WHERE faulty=false; the mock returns what // Postgres would: only IMEI001 has a non-faulty position, IMEI002 does not. const rows: SnapshotRow[] = [ { device_id: 'IMEI001', latitude: 41.33, longitude: 19.83, ts: TS_A, speed: 30, angle: 45 }, // IMEI002 has only faulty positions → Postgres returns no row for it ]; const { pool, queryCalls } = makeMockPool(rows); const provider = createSnapshotProvider(pool, makeSilentLogger(), makeMetrics()); const result = await provider.forEvent(EVENT_ID); expect(result).toHaveLength(1); expect(result[0]!.deviceId).toBe('IMEI001'); // Verify the SQL contains the faulty filter expect(queryCalls[0]!.sql).toContain('faulty = false'); }); it('returns the most recent non-faulty position per device (DISTINCT ON semantics)', async () => { // Postgres DISTINCT ON (p.device_id) ORDER BY p.device_id, p.ts DESC returns // one row per device — the one with the highest ts. The mock simulates this. const rows: SnapshotRow[] = [ // IMEI001: Postgres selected the row with TS_B (more recent) { device_id: 'IMEI001', latitude: 41.50, longitude: 19.90, ts: TS_B, // most recent speed: 50, angle: 0, }, ]; const { pool } = makeMockPool(rows); const provider = createSnapshotProvider(pool, makeSilentLogger(), makeMetrics()); const result = await provider.forEvent(EVENT_ID); expect(result).toHaveLength(1); expect(result[0]!.ts).toBe(TS_B.getTime()); // epoch ms of the most recent position }); it('passes eventId as a parameterized query argument', async () => { const { pool, queryCalls } = makeMockPool([]); const provider = createSnapshotProvider(pool, makeSilentLogger(), makeMetrics()); await provider.forEvent(EVENT_ID); expect(queryCalls).toHaveLength(1); expect(queryCalls[0]!.params).toEqual([EVENT_ID]); }); it('observes snapshot query latency and snapshot size metrics', async () => { const rows: SnapshotRow[] = [ { device_id: 'IMEI001', latitude: 41.33, longitude: 19.83, ts: TS_A, speed: 10, angle: 5 }, { device_id: 'IMEI002', latitude: 41.34, longitude: 19.84, ts: TS_B, speed: 0, angle: 0 }, ]; const { pool } = makeMockPool(rows); const metrics = makeMetrics(); const provider = createSnapshotProvider(pool, makeSilentLogger(), metrics); await provider.forEvent(EVENT_ID); const latency = metrics.observeCalls.find( (c) => c.name === 'processor_live_snapshot_query_latency_ms', ); expect(latency).toBeDefined(); expect(latency!.value).toBeGreaterThanOrEqual(0); const size = metrics.observeCalls.find( (c) => c.name === 'processor_live_snapshot_size', ); expect(size).toBeDefined(); expect(size!.value).toBe(2); }); it('propagates Postgres errors (registry.fetchSnapshot catches them)', async () => { const pool = makeErrorPool(new Error('connection refused')); const provider = createSnapshotProvider(pool, makeSilentLogger(), makeMetrics()); // snapshot.ts does NOT catch errors — registry.ts's fetchSnapshot does. // This ensures the error propagates cleanly. await expect(provider.forEvent(EVENT_ID)).rejects.toThrow('connection refused'); }); });