/** * 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, expect, vi, beforeEach, type Mock } from 'vitest' // --- module-level mocks (Vitest hoisting) --- const mockSyncCalendar = vi.fn().mockResolvedValue(undefined) vi.mock('../../src/broker/sync.js', () => ({ syncCalendar: mockSyncCalendar, })) // decryptPassword returns a predictable plaintext for any input vi.mock('../../src/broker/crypto.js', () => ({ decryptPassword: vi.fn().mockReturnValue('decrypted-app-password'), })) // 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) }) })