/** * Unit tests for src/services/AnalyticsService.ts */ import { Pool, QueryResult } from 'pg'; import { AnalyticsService } from '../../../src/services/AnalyticsService'; // ── Mock pg Pool ────────────────────────────────────────────────────────────── function makePool(queryFn: jest.Mock): Pool { return { query: queryFn } as unknown as Pool; } // ════════════════════════════════════════════════════════════════════════════ // AnalyticsService // ════════════════════════════════════════════════════════════════════════════ describe('AnalyticsService', () => { beforeEach(() => jest.clearAllMocks()); // ──────────────────────────────────────────────────────────────── // recordEvent() // ──────────────────────────────────────────────────────────────── describe('recordEvent()', () => { it('should call pool.query with the upsert SQL on success', async () => { const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult); const service = new AnalyticsService(makePool(mockQuery)); await service.recordEvent('tenant-1', 'token_issued'); expect(mockQuery).toHaveBeenCalledTimes(1); expect(mockQuery).toHaveBeenCalledWith( expect.stringContaining('INSERT INTO analytics_events'), ['tenant-1', 'token_issued'], ); expect(mockQuery).toHaveBeenCalledWith( expect.stringContaining('ON CONFLICT'), expect.any(Array), ); }); it('should silently swallow errors — never rejects or throws', async () => { const mockQuery = jest.fn().mockRejectedValue(new Error('DB connection failed')); const service = new AnalyticsService(makePool(mockQuery)); // Must not throw — fire-and-forget contract await expect(service.recordEvent('tenant-err', 'token_issued')).resolves.toBeUndefined(); }); }); // ──────────────────────────────────────────────────────────────── // getTokenTrend() // ──────────────────────────────────────────────────────────────── describe('getTokenTrend()', () => { it('should cap days at 90 (MAX_TREND_DAYS)', async () => { const mockQuery = jest.fn().mockResolvedValue({ rows: [{ date: '2026-04-04', count: '5' }], } as unknown as QueryResult); const service = new AnalyticsService(makePool(mockQuery)); await service.getTokenTrend('tenant-1', 200); // The first positional parameter passed to pool.query should be 90, not 200 const callArgs = mockQuery.mock.calls[0] as [string, unknown[]]; expect(callArgs[1][0]).toBe(90); }); it('should return mapped rows with date and count as numbers', async () => { const mockQuery = jest.fn().mockResolvedValue({ rows: [ { date: '2026-04-01', count: '3' }, { date: '2026-04-02', count: '7' }, { date: '2026-04-03', count: '0' }, ], } as unknown as QueryResult); const service = new AnalyticsService(makePool(mockQuery)); const result = await service.getTokenTrend('tenant-1', 3); expect(result).toHaveLength(3); expect(result[0]).toEqual({ date: '2026-04-01', count: 3 }); expect(result[1]).toEqual({ date: '2026-04-02', count: 7 }); expect(result[2]).toEqual({ date: '2026-04-03', count: 0 }); // count must be a number, not a string expect(typeof result[0].count).toBe('number'); }); }); // ──────────────────────────────────────────────────────────────── // getAgentActivity() // ──────────────────────────────────────────────────────────────── describe('getAgentActivity()', () => { it('should return rows mapped to correct IAgentActivityEntry shape', async () => { const mockQuery = jest.fn().mockResolvedValue({ rows: [ { agent_id: 'agent-uuid-1', dow: '1', hour: '0', count: '12' }, { agent_id: 'agent-uuid-1', dow: '3', hour: '0', count: '5' }, { agent_id: 'agent-uuid-2', dow: '5', hour: '0', count: '20' }, ], } as unknown as QueryResult); const service = new AnalyticsService(makePool(mockQuery)); const result = await service.getAgentActivity('tenant-1'); expect(result).toHaveLength(3); expect(result[0]).toEqual({ agent_id: 'agent-uuid-1', dow: 1, hour: 0, count: 12 }); expect(result[1]).toEqual({ agent_id: 'agent-uuid-1', dow: 3, hour: 0, count: 5 }); expect(result[2]).toEqual({ agent_id: 'agent-uuid-2', dow: 5, hour: 0, count: 20 }); // Numeric types expect(typeof result[0].dow).toBe('number'); expect(typeof result[0].hour).toBe('number'); expect(typeof result[0].count).toBe('number'); }); it('should return an empty array when no activity rows exist', async () => { const mockQuery = jest.fn().mockResolvedValue({ rows: [], } as unknown as QueryResult); const service = new AnalyticsService(makePool(mockQuery)); const result = await service.getAgentActivity('tenant-empty'); expect(result).toEqual([]); }); }); // ──────────────────────────────────────────────────────────────── // getAgentUsageSummary() // ──────────────────────────────────────────────────────────────── describe('getAgentUsageSummary()', () => { it('should return rows mapped to correct IAgentUsageSummaryEntry shape', async () => { const mockQuery = jest.fn().mockResolvedValue({ rows: [ { agent_id: 'agent-uuid-1', name: 'team-a', token_count: '200' }, { agent_id: 'agent-uuid-2', name: 'team-b', token_count: '50' }, ], } as unknown as QueryResult); const service = new AnalyticsService(makePool(mockQuery)); const result = await service.getAgentUsageSummary('tenant-1'); expect(result).toHaveLength(2); expect(result[0]).toEqual({ agent_id: 'agent-uuid-1', name: 'team-a', token_count: 200 }); expect(result[1]).toEqual({ agent_id: 'agent-uuid-2', name: 'team-b', token_count: 50 }); // token_count must be a number expect(typeof result[0].token_count).toBe('number'); }); it('should return an empty array when no agents exist', async () => { const mockQuery = jest.fn().mockResolvedValue({ rows: [], } as unknown as QueryResult); const service = new AnalyticsService(makePool(mockQuery)); const result = await service.getAgentUsageSummary('tenant-empty'); expect(result).toEqual([]); }); }); });