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
This commit is contained in:
205
backend/src/__tests__/unit/analyticsService.test.ts
Normal file
205
backend/src/__tests__/unit/analyticsService.test.ts
Normal file
@@ -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> = {}): 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<string, unknown> = {};
|
||||||
|
|
||||||
|
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<typeof vi.fn>).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user