Milestone v1.0: FamilySync MVP #1
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user