Milestone v1.0: FamilySync MVP #1

Merged
luckberg merged 376 commits from gsd/v1.0-milestone into main 2026-06-10 17:39:19 -04:00
Showing only changes of commit 6b2cdf3683 - Show all commits
+118
View File
@@ -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()