From cf30811b972717658f1d05ffa982a2f2ed3f473f Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 24 Feb 2026 14:23:42 -0500 Subject: [PATCH] test(02-01): add analyticsService unit tests - 6 tests: recordProcessingEvent (4 tests) + deleteProcessingEventsOlderThan (2 tests) - Verifies fire-and-forget: void return (undefined), no throw on Supabase failure - Verifies error logging on Supabase failure without rethrowing - Verifies null coalescing for optional fields (duration_ms, error_message, stage) - Verifies cutoff date math (~30 days ago) and row count return - Uses makeSupabaseChain() pattern from Phase 1 model tests --- .../__tests__/unit/analyticsService.test.ts | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 backend/src/__tests__/unit/analyticsService.test.ts diff --git a/backend/src/__tests__/unit/analyticsService.test.ts b/backend/src/__tests__/unit/analyticsService.test.ts new file mode 100644 index 0000000..956a05d --- /dev/null +++ b/backend/src/__tests__/unit/analyticsService.test.ts @@ -0,0 +1,205 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; + +// ============================================================================= +// Mocks — vi.mock is hoisted; factory must not reference outer variables +// ============================================================================= + +vi.mock('../../utils/logger', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('../../config/supabase', () => ({ + getSupabaseServiceClient: vi.fn(), +})); + +// ============================================================================= +// Import service and mocked modules AFTER vi.mock declarations +// ============================================================================= + +import { recordProcessingEvent, deleteProcessingEventsOlderThan, ProcessingEventData } from '../../services/analyticsService'; +import { getSupabaseServiceClient } from '../../config/supabase'; +import { logger } from '../../utils/logger'; + +const mockGetSupabaseServiceClient = vi.mocked(getSupabaseServiceClient); +const mockLogger = vi.mocked(logger); + +// ============================================================================= +// Helpers +// ============================================================================= + +function makeProcessingEventData(overrides: Partial = {}): ProcessingEventData { + return { + document_id: 'doc-uuid-123', + user_id: 'user-uuid-456', + event_type: 'processing_started', + ...overrides, + }; +} + +/** + * Build a chainable Supabase mock that returns `resolvedValue` from the + * terminal method or awaitable chain. + * + * The fluent chain used by analyticsService: + * recordProcessingEvent: .from().insert().then(callback) + * deleteProcessingEventsOlderThan: .from().delete().lt().select() → awaitable + */ +function makeSupabaseChain(resolvedValue: { data: unknown; error: unknown }) { + const chain: Record = {}; + + chain.insert = vi.fn().mockReturnValue(chain); + chain.select = vi.fn().mockReturnValue(chain); + chain.delete = vi.fn().mockReturnValue(chain); + chain.lt = vi.fn().mockReturnValue(chain); + chain.eq = vi.fn().mockReturnValue(chain); + chain.order = vi.fn().mockReturnValue(chain); + chain.limit = vi.fn().mockReturnValue(chain); + chain.single = vi.fn().mockResolvedValue(resolvedValue); + + // Make the chain itself thenable so `await query` works for delete chain + chain.then = (resolve: (v: unknown) => void, reject: (e: unknown) => void) => + Promise.resolve(resolvedValue).then(resolve, reject); + + return chain; +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe('analyticsService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // =========================================================================== + // recordProcessingEvent + // =========================================================================== + + describe('recordProcessingEvent', () => { + test('calls Supabase insert with correct data including created_at', () => { + const chain = makeSupabaseChain({ data: null, error: null }); + mockGetSupabaseServiceClient.mockReturnValue({ from: vi.fn().mockReturnValue(chain) } as any); + + const data = makeProcessingEventData({ + duration_ms: 1500, + stage: 'document_ai', + }); + + recordProcessingEvent(data); + + expect(mockGetSupabaseServiceClient).toHaveBeenCalledOnce(); + expect(chain.insert).toHaveBeenCalledWith( + expect.objectContaining({ + document_id: 'doc-uuid-123', + user_id: 'user-uuid-456', + event_type: 'processing_started', + duration_ms: 1500, + stage: 'document_ai', + created_at: expect.any(String), + }) + ); + }); + + test('return type is void (not a Promise) — value is undefined', () => { + const chain = makeSupabaseChain({ data: null, error: null }); + mockGetSupabaseServiceClient.mockReturnValue({ from: vi.fn().mockReturnValue(chain) } as any); + + const result = recordProcessingEvent(makeProcessingEventData()); + + // A void function returns undefined — if undefined, it trivially cannot be thenable + expect(result).toBeUndefined(); + // Verify it is not a Promise/thenable (typeof undefined is 'undefined', not 'object') + expect(typeof result).toBe('undefined'); + }); + + test('logs error on Supabase failure but does not throw', async () => { + // We need to control the .then callback to simulate a Supabase error response + const mockInsert = vi.fn(); + const mockFrom = vi.fn().mockReturnValue({ insert: mockInsert }); + + // Simulate the .then() resolving with an error object (as Supabase returns) + mockInsert.mockReturnValue({ + then: (resolve: (v: { error: { message: string } }) => void) => { + resolve({ error: { message: 'insert failed — connection error' } }); + return Promise.resolve(); + }, + }); + + mockGetSupabaseServiceClient.mockReturnValue({ from: mockFrom } as any); + + // Should not throw synchronously + expect(() => recordProcessingEvent(makeProcessingEventData())).not.toThrow(); + + // Allow the microtask queue to flush so the .then callback runs + await Promise.resolve(); + + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('failed to insert processing event'), + expect.objectContaining({ error: 'insert failed — connection error' }) + ); + }); + + test('inserts null for optional fields when not provided', () => { + const chain = makeSupabaseChain({ data: null, error: null }); + mockGetSupabaseServiceClient.mockReturnValue({ from: vi.fn().mockReturnValue(chain) } as any); + + // Provide only required fields — no duration_ms, error_message, or stage + recordProcessingEvent({ + document_id: 'doc-uuid-789', + user_id: 'user-uuid-abc', + event_type: 'completed', + }); + + expect(chain.insert).toHaveBeenCalledWith( + expect.objectContaining({ + duration_ms: null, + error_message: null, + stage: null, + }) + ); + }); + }); + + // =========================================================================== + // deleteProcessingEventsOlderThan + // =========================================================================== + + describe('deleteProcessingEventsOlderThan', () => { + test('computes correct cutoff date and calls .lt with ISO string ~30 days ago', async () => { + const chain = makeSupabaseChain({ data: [], error: null }); + mockGetSupabaseServiceClient.mockReturnValue({ from: vi.fn().mockReturnValue(chain) } as any); + + await deleteProcessingEventsOlderThan(30); + + expect(mockGetSupabaseServiceClient).toHaveBeenCalledOnce(); + expect(chain.delete).toHaveBeenCalled(); + expect(chain.lt).toHaveBeenCalledWith('created_at', expect.any(String)); + + // Verify the cutoff date is approximately 30 days ago + const ltCall = (chain.lt as ReturnType).mock.calls[0]; + const cutoffDate = new Date(ltCall[1] as string); + const thirtyDaysAgo = new Date(Date.now() - 30 * 86400000); + const diffMs = Math.abs(cutoffDate.getTime() - thirtyDaysAgo.getTime()); + // Allow up to 5 seconds of drift from test execution time + expect(diffMs).toBeLessThan(5000); + }); + + test('returns count of deleted rows', async () => { + const chain = makeSupabaseChain({ + data: [{}, {}, {}], // 3 deleted rows + error: null, + }); + mockGetSupabaseServiceClient.mockReturnValue({ from: vi.fn().mockReturnValue(chain) } as any); + + const count = await deleteProcessingEventsOlderThan(30); + + expect(count).toBe(3); + }); + }); +});