Files
familysync/apps/api/tests/broker/poller.test.ts
T
Lucas Berger 03e953158a fix(13-02): eliminate all ESLint violations — pnpm lint exits 0
- eslint.config.js: disable React Compiler rules (v7 flat.recommended enables
  them; codebase does not use the Compiler); add e2e/ to disableTypeChecked
  block; promote exhaustive-deps to error
- API broker: remove redundant as-casts (outboxWorker, poller, reminderScheduler,
  expand, sync, vevent, spike); add targeted ical.js no-unsafe-assignment/argument
  disables with justifying comments inside try blocks
- API routes/sse.ts: fix no-misused-promises on async writeSSE callback with
  void+IIFE+catch pattern
- API routes/lists.ts: let → const for updateValues
- API tests: remove unused imports (beforeEach, eq, vi); rename unused vars
  with _ prefix; remove unused lastActiveId assignment
- PWA components: void navigate() and void queryClient.invalidateQueries() on
  all fire-and-forget call sites; fix CalendarShell explicit-type-casts;
  Couldn't → HTML entity
- PWA test files: as unknown as Response for partial mock objects; string | null
  type annotation on mockLastSyncedUid; remove async from test callbacks without
  await; act(() => {}) not await act(async () => {}) for sync ops
- sw.ts: restructure Notification.data?.url access as let+if so disable
  comments land on the exact violation lines; void self.skipWaiting()
2026-06-11 20:23:38 -04:00

262 lines
8.2 KiB
TypeScript

/**
* 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<string, unknown>)
? (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)
})
})