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:
admin
2026-02-24 14:23:42 -05:00
parent a8ba884043
commit cf30811b97

View 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);
});
});
});