/** * 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 _callCount = 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) => { _callCount++; 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(); _callCount = 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('BUG B: scopes the stored-calendar lookup to (userId, url), not url alone', async () => { // Capture the predicate passed to db.select().from(calendars).where(...). // The buggy code passed eq(url) only; the fix passes and(eq(userId), eq(url)). // We serialize the predicate and assert it references the member's user_id column. const capturedWhere: unknown[] = []; mockSelectWhere.mockImplementation((pred: unknown) => { capturedWhere.push(pred); return { limit: mockSelectLimit }; }); const { runPoll } = await import('../../src/broker/poller.js'); mockCredentialsSelectResult.push({ id: 7, userId: 42, fastmailEmail: 'lucas@fastmail.com', encryptedPassword: 'enc', }); mockFetchCalendars.mockResolvedValue([ { url: 'https://caldav.fastmail.com/cal/', displayName: 'Calendar', ctag: 'c', syncToken: null, }, ]); await runPoll(); expect(capturedWhere.length).toBeGreaterThan(0); // A composite and(...) predicate exposes multiple queryChunks; a single eq does not // contain a nested SQL referencing the user_id column. Serialize and inspect. const pred = capturedWhere[0] as { queryChunks?: unknown[] }; const serialized = JSON.stringify(pred, (_k, v) => typeof v === 'object' && v !== null && 'name' in (v as Record) ? (v as { name?: unknown }).name : v, ); expect(serialized).toContain('user_id'); expect(serialized).toContain('url'); }); 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); }); });