test(01-03): add failing tests for broker poller ctag change detection
- 5 tests: skip on unchanged ctag, sync on changed ctag, first-sync (null ctag), decrypt failure handled, all credentials processed - Mocks db.select, createFastmailClient, syncCalendar, decryptPassword at module level - RED gate: all fail (src/broker/poller.ts does not exist yet)
This commit is contained in:
@@ -1,24 +1,223 @@
|
|||||||
/**
|
/**
|
||||||
* Wave 0 stub — Broker: ctag polling + change detection
|
* 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.
|
|
||||||
*
|
*
|
||||||
|
* Tests runPoll in src/broker/poller.ts.
|
||||||
* Key behavior (D-13): ctag unchanged → no DB write (skip sync entirely)
|
* 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', () => {
|
// --- module-level mocks (Vitest hoisting) ---
|
||||||
it.todo('skips syncCalendar when ctag is unchanged (Plan 03)')
|
|
||||||
|
|
||||||
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user