diff --git a/apps/api/tests/broker/poller.test.ts b/apps/api/tests/broker/poller.test.ts index 2712175..fc8891d 100644 --- a/apps/api/tests/broker/poller.test.ts +++ b/apps/api/tests/broker/poller.test.ts @@ -1,24 +1,223 @@ /** - * Wave 0 stub — Broker: ctag polling + change detection - * - * These tests are RED stubs. Implementation lives in: - * apps/api/src/broker/poller.ts (Plan 03) - * - * Tests will be filled GREEN in Plan 03 when the poller is implemented. + * Broker: ctag polling + change detection * + * Tests runPoll in src/broker/poller.ts. * Key behavior (D-13): ctag unchanged → no DB write (skip sync entirely) + * Credentials decrypted via decryptPassword before client creation (T-03-04). */ -import { describe, it } from 'vitest' +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest' -describe('broker poller', () => { - it.todo('skips syncCalendar when ctag is unchanged (Plan 03)') +// --- module-level mocks (Vitest hoisting) --- - it.todo('calls syncCalendar when ctag changes (Plan 03)') +const mockSyncCalendar = vi.fn().mockResolvedValue(undefined) - it.todo('calls syncCalendar when ctag was null (first sync) (Plan 03)') +vi.mock('../../src/broker/sync.js', () => ({ + syncCalendar: mockSyncCalendar, +})) - it.todo('handles decryptPassword failure gracefully without crashing the poller (Plan 03)') +// decryptPassword returns a predictable plaintext for any input +vi.mock('../../src/broker/crypto.js', () => ({ + decryptPassword: vi.fn().mockReturnValue('decrypted-app-password'), +})) - it.todo('processes all member credentials in a poll cycle (Plan 03)') +// db mock: select() chain returns configurable results +const mockCalendarsSelectResult: Array<{ id: number; url: string; ctag: string | null }> = [] +const mockCredentialsSelectResult: Array<{ + id: number + userId: number + fastmailEmail: string + encryptedPassword: string +}> = [] + +// Each call to db.select() needs to return different chains +// We use a call counter to decide which data to return +let selectCallCount = 0 + +const mockSelectLimit = vi.fn() +const mockSelectWhere = vi.fn().mockReturnValue({ limit: mockSelectLimit }) +const mockSelectFrom = vi.fn() +const mockSelect = vi.fn().mockImplementation(() => ({ from: mockSelectFrom })) + +mockSelectFrom.mockImplementation(() => ({ + // For memberCredentials selects (no .where), resolve directly + where: mockSelectWhere, + // Support both: direct await (no where) and .where().limit() + then: (resolve: (v: typeof mockCredentialsSelectResult) => void) => { + selectCallCount++ + resolve(mockCredentialsSelectResult) + return Promise.resolve(mockCredentialsSelectResult) + }, +})) + +mockSelectLimit.mockImplementation(() => + Promise.resolve(mockCalendarsSelectResult), +) + +vi.mock('../../src/db/client.js', () => ({ + db: { select: mockSelect }, +})) + +// mockFetchCalendars: controlled per test +const mockFetchCalendars = vi.fn() +const mockCreateFastmailClient = vi.fn().mockResolvedValue({ + fetchCalendars: mockFetchCalendars, +}) + +vi.mock('../../src/broker/client.js', () => ({ + createFastmailClient: mockCreateFastmailClient, +})) + +describe('broker poller — runPoll', () => { + beforeEach(() => { + vi.clearAllMocks() + selectCallCount = 0 + + // Reset implementations + mockSyncCalendar.mockResolvedValue(undefined) + mockCreateFastmailClient.mockResolvedValue({ fetchCalendars: mockFetchCalendars }) + mockSelect.mockImplementation(() => ({ from: mockSelectFrom })) + mockSelectFrom.mockImplementation(() => ({ + where: mockSelectWhere, + then: (resolve: (v: typeof mockCredentialsSelectResult) => void) => { + resolve(mockCredentialsSelectResult) + return Promise.resolve(mockCredentialsSelectResult) + }, + })) + mockSelectLimit.mockResolvedValue(mockCalendarsSelectResult) + + // Clear arrays + mockCredentialsSelectResult.length = 0 + mockCalendarsSelectResult.length = 0 + }) + + it('skips syncCalendar when ctag is unchanged', async () => { + const { runPoll } = await import('../../src/broker/poller.js') + + // Set up one credential + mockCredentialsSelectResult.push({ + id: 1, + userId: 10, + fastmailEmail: 'lucas@fastmail.com', + encryptedPassword: 'encrypted-blob', + }) + + // Set up the stored calendar row with ctag 'ctag-v1' + mockCalendarsSelectResult.push({ id: 100, url: 'https://caldav.fastmail.com/cal/', ctag: 'ctag-v1' }) + + // fetchCalendars returns a calendar with the SAME ctag + mockFetchCalendars.mockResolvedValue([ + { + url: 'https://caldav.fastmail.com/cal/', + displayName: 'Test Calendar', + ctag: 'ctag-v1', // UNCHANGED + syncToken: null, + }, + ]) + + await runPoll() + + expect(mockSyncCalendar).not.toHaveBeenCalled() + }) + + it('calls syncCalendar when ctag changes', async () => { + const { runPoll } = await import('../../src/broker/poller.js') + + mockCredentialsSelectResult.push({ + id: 1, + userId: 10, + fastmailEmail: 'lucas@fastmail.com', + encryptedPassword: 'encrypted-blob', + }) + + mockCalendarsSelectResult.push({ id: 100, url: 'https://caldav.fastmail.com/cal/', ctag: 'ctag-v1' }) + + mockFetchCalendars.mockResolvedValue([ + { + url: 'https://caldav.fastmail.com/cal/', + displayName: 'Test Calendar', + ctag: 'ctag-v2', // CHANGED + syncToken: null, + }, + ]) + + await runPoll() + + expect(mockSyncCalendar).toHaveBeenCalledOnce() + }) + + it('calls syncCalendar when ctag was null (first sync)', async () => { + const { runPoll } = await import('../../src/broker/poller.js') + + mockCredentialsSelectResult.push({ + id: 1, + userId: 10, + fastmailEmail: 'lucas@fastmail.com', + encryptedPassword: 'encrypted-blob', + }) + + // No stored calendar row yet (empty array → first sync) + // mockCalendarsSelectResult is empty + + mockFetchCalendars.mockResolvedValue([ + { + url: 'https://caldav.fastmail.com/cal/', + displayName: 'My Calendar', + ctag: 'ctag-v1', + syncToken: null, + }, + ]) + + await runPoll() + + expect(mockSyncCalendar).toHaveBeenCalledOnce() + }) + + it('handles decryptPassword failure gracefully without crashing the poller', async () => { + const { runPoll } = await import('../../src/broker/poller.js') + const { decryptPassword } = await import('../../src/broker/crypto.js') + + mockCredentialsSelectResult.push({ + id: 1, + userId: 10, + fastmailEmail: 'lucas@fastmail.com', + encryptedPassword: 'corrupted', + }) + + // Make decryptPassword throw for this test + ;(decryptPassword as Mock).mockImplementationOnce(() => { + throw new Error('Decryption failed') + }) + + // runPoll should not throw — it should catch and skip the credential + await expect(runPoll()).resolves.not.toThrow() + expect(mockSyncCalendar).not.toHaveBeenCalled() + }) + + it('processes all member credentials in a poll cycle', async () => { + const { runPoll } = await import('../../src/broker/poller.js') + + // Two credentials + mockCredentialsSelectResult.push( + { id: 1, userId: 10, fastmailEmail: 'lucas@fastmail.com', encryptedPassword: 'enc1' }, + { id: 2, userId: 20, fastmailEmail: 'wife@icloud.com', encryptedPassword: 'enc2' }, + ) + + // Each member's calendar has a different (new) ctag → both trigger sync + mockFetchCalendars.mockResolvedValue([ + { + url: 'https://caldav.fastmail.com/cal/', + displayName: 'Calendar', + ctag: 'new-ctag', + syncToken: null, + }, + ]) + + await runPoll() + + // createFastmailClient called once per credential + expect(mockCreateFastmailClient).toHaveBeenCalledTimes(2) + // syncCalendar called once per credential (one calendar each, ctag changed) + expect(mockSyncCalendar).toHaveBeenCalledTimes(2) + }) })