From 6b2cdf36835e2c2f9277db0d26bd71a8f23e9e3f Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Fri, 5 Jun 2026 20:56:07 -0400 Subject: [PATCH] =?UTF-8?q?test(03-11):=20RED=20=E2=80=94=20durable=20crea?= =?UTF-8?q?te-before-delete=20gating=20+=20concurrency=20guard=20(CR-04,?= =?UTF-8?q?=20CR-05)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CR-04 cross-batch drain 1: sibling create 'pending' must block delete dispatch - CR-04 cross-batch drain 2: sibling create 'done' must allow delete dispatch - CR-04 paired-create-failed: sibling create 'failed'/'dead' marks delete failed, preserves original - CR-05: two overlapping drain calls must invoke createCalendarEvent exactly once --- apps/api/tests/broker/outboxWorker.test.ts | 118 +++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/apps/api/tests/broker/outboxWorker.test.ts b/apps/api/tests/broker/outboxWorker.test.ts index 8b794e9..d31bcf3 100644 --- a/apps/api/tests/broker/outboxWorker.test.ts +++ b/apps/api/tests/broker/outboxWorker.test.ts @@ -319,6 +319,124 @@ describe('runOutboxDrain — edit-as-move ordering (D-04)', () => { }) }) +describe('runOutboxDrain — durable create-before-delete (CR-04) + concurrency guard (CR-05)', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPendingRows = [] + wireMockChain() + }) + + it('CR-04 cross-batch: drain 1 (sibling create still pending) leaves the delete pending and never calls deleteCalendarEvent', async () => { + const { deleteCalendarEvent } = await import('../../src/broker/write.js') + vi.mocked(deleteCalendarEvent).mockResolvedValue(makeResponse(204)) + + const groupId = 'edit-move-group-001' + const deleteRow = makeRow({ + id: 2, + operation: 'delete', + calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/Old/uid.ics', + etag: '"etag-old"', + payload: null, + groupId, + }) + + // Drain 1: only the delete row is returned as pending (the create hasn't been fetched yet) + // First mockWherePending call → pending-rows select (only the delete row) + // Second mockWherePending call → sibling-status select (create is still 'pending') + mockWherePending + .mockImplementationOnce(() => Promise.resolve([deleteRow])) + .mockImplementationOnce(() => Promise.resolve([{ status: 'pending' }])) + + await runOutboxDrain() + + // The delete must NOT have been dispatched — sibling create is not yet done + expect(deleteCalendarEvent).not.toHaveBeenCalled() + + // The delete row's status must NOT have been updated to done or failed + const statusCalls = mockUpdateSet.mock.calls.filter((call) => { + const arg = call[0] as { status?: string } + return arg?.status === 'done' || arg?.status === 'failed' + }) + expect(statusCalls.length).toBe(0) + }) + + it('CR-04 cross-batch: drain 2 (sibling create now done) dispatches the delete exactly once', async () => { + const { deleteCalendarEvent } = await import('../../src/broker/write.js') + vi.mocked(deleteCalendarEvent).mockResolvedValue(makeResponse(204)) + + const groupId = 'edit-move-group-001' + const deleteRow = makeRow({ + id: 2, + operation: 'delete', + calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/Old/uid.ics', + etag: '"etag-old"', + payload: null, + groupId, + }) + + // Drain 2: delete row is pending again, sibling create is now 'done' + mockWherePending + .mockImplementationOnce(() => Promise.resolve([deleteRow])) + .mockImplementationOnce(() => Promise.resolve([{ status: 'done' }])) + + await runOutboxDrain() + + expect(deleteCalendarEvent).toHaveBeenCalledTimes(1) + }) + + it('CR-04 paired-create-failed: if sibling create is failed, delete is marked failed and never dispatched (D-04 preserved)', async () => { + const { deleteCalendarEvent } = await import('../../src/broker/write.js') + vi.mocked(deleteCalendarEvent).mockResolvedValue(makeResponse(204)) + + const groupId = 'edit-move-group-001' + const deleteRow = makeRow({ + id: 2, + operation: 'delete', + calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/Old/uid.ics', + etag: '"etag-old"', + payload: null, + groupId, + }) + + // Sibling create is 'failed' — the delete must be permanently skipped + mockWherePending + .mockImplementationOnce(() => Promise.resolve([deleteRow])) + .mockImplementationOnce(() => Promise.resolve([{ status: 'failed' }])) + + await runOutboxDrain() + + // The original event must be preserved — delete must NOT be dispatched + expect(deleteCalendarEvent).not.toHaveBeenCalled() + + // The delete row must be marked failed (permanently, not just skipped this cycle) + const failedCall = mockUpdateSet.mock.calls.find((call) => { + const arg = call[0] as { status?: string; lastError?: string } + return arg?.status === 'failed' && typeof arg?.lastError === 'string' + }) + expect(failedCall).toBeDefined() + const failArg = failedCall![0] as { lastError: string } + expect(failArg.lastError).toMatch(/paired create/) + }) + + it('CR-05: two overlapping runOutboxDrain calls invoke createCalendarEvent exactly once', async () => { + const { createCalendarEvent } = await import('../../src/broker/write.js') + // Simulate a slow create so the second drain starts while first is still running + vi.mocked(createCalendarEvent).mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve(makeResponse(201)), 20)), + ) + + mockPendingRows = [makeRow({ id: 1 })] + + // Start both drains concurrently WITHOUT awaiting the first + const drain1 = runOutboxDrain() + const drain2 = runOutboxDrain() + await Promise.all([drain1, drain2]) + + // Only one dispatch must have happened — the second drain must have been a no-op + expect(createCalendarEvent).toHaveBeenCalledTimes(1) + }) +}) + describe('runOutboxDrain — fail closed on bad credentials (CR-03) + backoff index fix (WR-01)', () => { beforeEach(() => { vi.clearAllMocks()