From 5eb26c0e6bcf653179efc59505579624d29a8a6a Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Fri, 5 Jun 2026 21:05:40 -0400 Subject: [PATCH] =?UTF-8?q?test(03-11):=20RED=20=E2=80=94=20fresh=20etag?= =?UTF-8?q?=20re-read=20before=20PUT=20to=20avoid=20spurious=20412=20(WR-0?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WR-02 fresh: update PUT must use calendarEvents.etag not stale enqueue-time etag (fails RED: capturedEtag === 'old-etag', not 'new-etag') - WR-02 fallback: when calendarEvents has no row, fall back to row.etag (passes in RED) - Add mockWhereCalEvents to mock infrastructure to isolate calendarEvents selects - Switch all beforeEach to vi.resetAllMocks() to prevent mockImplementationOnce bleed --- apps/api/tests/broker/outboxWorker.test.ts | 105 +++++++++++++++++---- 1 file changed, 87 insertions(+), 18 deletions(-) diff --git a/apps/api/tests/broker/outboxWorker.test.ts b/apps/api/tests/broker/outboxWorker.test.ts index e998103..fd30ac3 100644 --- a/apps/api/tests/broker/outboxWorker.test.ts +++ b/apps/api/tests/broker/outboxWorker.test.ts @@ -32,20 +32,24 @@ const { mockUpdateSet, mockUpdate, mockWherePending, + mockWhereCalEvents, mockFromFn, mockSelectFn, mockDecryptPassword, } = vi.hoisted(() => { const mockUpdateSet = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) const mockUpdate = vi.fn().mockReturnValue({ set: mockUpdateSet }) - // mockWherePending is the terminal node of the select chain: - // db.select().from(table).where(and(cond1, cond2)) — resolves to the row array + // mockWherePending: terminal node for calendarOutbox selects (pending-rows + sibling-status) + // db.select().from(calendarOutbox).where(...) — resolves to the row array const mockWherePending = vi.fn().mockImplementation(() => Promise.resolve([] as unknown[])) + // mockWhereCalEvents: terminal node for calendarEvents selects (etag re-read for WR-02) + // db.select({etag}).from(calendarEvents).where(...) — resolves to the etag array + const mockWhereCalEvents = vi.fn().mockImplementation(() => Promise.resolve([] as unknown[])) const mockFromFn = vi.fn().mockReturnValue({ where: mockWherePending }) const mockSelectFn = vi.fn().mockReturnValue({ from: mockFromFn }) // By default returns a dummy password so loadClientForUser succeeds const mockDecryptPassword = vi.fn().mockReturnValue('app-password') - return { mockUpdateSet, mockUpdate, mockWherePending, mockFromFn, mockSelectFn, mockDecryptPassword } + return { mockUpdateSet, mockUpdate, mockWherePending, mockWhereCalEvents, mockFromFn, mockSelectFn, mockDecryptPassword } }) let mockPendingRows: unknown[] = [] @@ -124,20 +128,22 @@ const makeResponse = (status: number): Response => function wireMockChain() { mockUpdateSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) mockUpdate.mockReturnValue({ set: mockUpdateSet }) - // mockFromFn differentiates by table argument: - // - memberCredentials table → returns FAKE_CRED_ROW (so loadClientForUser succeeds by default) - // - anything else (calendarOutbox, calendarEvents) → returns mockWherePending - // Use Symbol.for('drizzle:Name') to identify the table — JSON.stringify throws on circular Drizzle - // table structures so it cannot be used for table identification. + // mockFromFn differentiates by table argument using Symbol.for('drizzle:Name'): + // - memberCredentials → returns FAKE_CRED_ROW (so loadClientForUser succeeds by default) + // - calendarEvents → returns mockWhereCalEvents (etag re-read for WR-02) + // - calendarOutbox (and anything else) → returns mockWherePending (pending-rows + sibling-status) + // JSON.stringify throws on circular Drizzle table structures; use Symbol identity instead. mockFromFn.mockImplementation((table: unknown) => { const tableName = (table as Record)[Symbol.for('drizzle:Name')] ?? '' - const isCred = tableName === 'member_credentials' - return { - where: isCred - ? vi.fn().mockResolvedValue([FAKE_CRED_ROW]) - : mockWherePending, + if (tableName === 'member_credentials') { + return { where: vi.fn().mockResolvedValue([FAKE_CRED_ROW]) } } + if (tableName === 'calendar_events') { + return { where: mockWhereCalEvents } + } + return { where: mockWherePending } }) + mockWhereCalEvents.mockImplementation(() => Promise.resolve([])) mockWherePending.mockImplementation(() => Promise.resolve(mockPendingRows)) mockSelectFn.mockReturnValue({ from: mockFromFn }) // Default: decryptPassword succeeds @@ -146,7 +152,7 @@ function wireMockChain() { describe('runOutboxDrain — state transitions', () => { beforeEach(() => { - vi.clearAllMocks() + vi.resetAllMocks() mockPendingRows = [] wireMockChain() }) @@ -220,7 +226,7 @@ describe('runOutboxDrain — state transitions', () => { describe('runOutboxDrain — ICS building from form JSON (CR-02)', () => { beforeEach(() => { - vi.clearAllMocks() + vi.resetAllMocks() mockPendingRows = [] wireMockChain() }) @@ -271,7 +277,7 @@ describe('runOutboxDrain — ICS building from form JSON (CR-02)', () => { describe('runOutboxDrain — edit-as-move ordering (D-04)', () => { beforeEach(() => { - vi.clearAllMocks() + vi.resetAllMocks() mockPendingRows = [] wireMockChain() }) @@ -327,7 +333,7 @@ describe('runOutboxDrain — edit-as-move ordering (D-04)', () => { describe('runOutboxDrain — durable create-before-delete (CR-04) + concurrency guard (CR-05)', () => { beforeEach(() => { - vi.clearAllMocks() + vi.resetAllMocks() mockPendingRows = [] wireMockChain() }) @@ -443,9 +449,72 @@ describe('runOutboxDrain — durable create-before-delete (CR-04) + concurrency }) }) +describe('runOutboxDrain — fresh etag re-read before PUT (WR-02)', () => { + beforeEach(() => { + // Use resetAllMocks here (not clearAllMocks) so that unconsumed mockImplementationOnce + // queues from prior tests do not bleed into subsequent tests via the shared mockWherePending. + vi.resetAllMocks() + mockPendingRows = [] + wireMockChain() + }) + + it('WR-02 fresh etag: update PUT uses freshest calendarEvents.etag, not stale enqueue-time etag', async () => { + const { updateCalendarEvent } = await import('../../src/broker/write.js') + let capturedEtag: string | null = null + vi.mocked(updateCalendarEvent).mockImplementation(async (_client, _url, _ics, etag) => { + capturedEtag = etag + return makeResponse(204) + }) + + const updateRow = makeRow({ + operation: 'update', + calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/uid.ics', + etag: 'old-etag', // stale enqueue-time etag + }) + mockPendingRows = [updateRow] + + // Mock the calendarEvents etag lookup to return a fresher etag. + // In RED (no fresh-etag code yet), mockWhereCalEvents is never called, so + // the PUT uses row.etag = 'old-etag'. The assertion expects 'new-etag' → fails RED. + mockWhereCalEvents.mockResolvedValue([{ etag: 'new-etag' }]) + + await runOutboxDrain() + + // The PUT must use the freshest etag from calendarEvents, not the stale row.etag + expect(capturedEtag).toBe('new-etag') + expect(capturedEtag).not.toBe('old-etag') + }) + + it('WR-02 etag fallback: update PUT falls back to row.etag when calendarEvents has no matching row', async () => { + const { updateCalendarEvent } = await import('../../src/broker/write.js') + let capturedEtag: string | null = null + vi.mocked(updateCalendarEvent).mockImplementation(async (_client, _url, _ics, etag) => { + capturedEtag = etag + return makeResponse(204) + }) + + const updateRow = makeRow({ + operation: 'update', + calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/uid.ics', + etag: 'fallback-etag', + }) + mockPendingRows = [updateRow] + + // mockWhereCalEvents is already configured to return [] by default in wireMockChain. + // No row for the uid → worker falls back to row.etag. + // In RED, mockWhereCalEvents is never called so the test passes (row.etag used directly). + // In GREEN, mockWhereCalEvents returns [] so the fallback is exercised. + + await runOutboxDrain() + + // When calendarEvents has no row for the uid, fall back to row.etag + expect(capturedEtag).toBe('fallback-etag') + }) +}) + describe('runOutboxDrain — fail closed on bad credentials (CR-03) + backoff index fix (WR-01)', () => { beforeEach(() => { - vi.clearAllMocks() + vi.resetAllMocks() mockPendingRows = [] wireMockChain() })