test(03-11): RED — durable create-before-delete gating + concurrency guard (CR-04, CR-05)
- 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
This commit is contained in:
@@ -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)', () => {
|
describe('runOutboxDrain — fail closed on bad credentials (CR-03) + backoff index fix (WR-01)', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
|||||||
Reference in New Issue
Block a user