- vevent.test.ts: D-13 form-parsed contract block — timed and all-day cases (all-day DTEND+1 fails: emits 20260610 not 20260611) - outboxWorker.test.ts: worker integration — create/update must pass BEGIN:VCALENDAR to CalDAV write functions (fails: raw JSON passes through today) - worker: unparseable payload must mark row failed (fails: marks done today) - Update makeRow default payload to form JSON shape the worker should parse
291 lines
11 KiB
TypeScript
291 lines
11 KiB
TypeScript
/**
|
|
* RED test scaffold: broker/outboxWorker.ts — outbox state machine (D-04, D-07, D-08)
|
|
*
|
|
* Behaviors under test:
|
|
* 1. runOutboxDrain transitions pending→done on mock 204 response
|
|
* 2. runOutboxDrain transitions pending→failed on mock 412 (conflict, no retry), triggers re-sync
|
|
* 3. runOutboxDrain transitions pending→backoff (nextAttemptAt advanced, attemptCount++)
|
|
* on mock 500 (transient error)
|
|
* 4. runOutboxDrain transitions pending→dead when attemptCount reaches MAX_ATTEMPTS
|
|
* 5. Edit-as-move (D-04): create row processed BEFORE the linked delete row (groupId)
|
|
*
|
|
* These tests FAIL (RED) because broker/outboxWorker.ts does not exist yet.
|
|
* They will turn GREEN in Plan 03-03 when the implementation is added.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
// This import fails (RED) — broker/outboxWorker.ts does not exist yet.
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore intentional RED import
|
|
import { runOutboxDrain } from '../../src/broker/outboxWorker.js'
|
|
|
|
// ── Drizzle DB mock ────────────────────────────────────────────────────────
|
|
// Follows the pattern from PATTERNS.md §Drizzle DB mock in tests.
|
|
//
|
|
// vi.hoisted() is required for variables referenced inside vi.mock() factories.
|
|
// vi.mock() is hoisted to the top of the file by vitest's transform; without
|
|
// vi.hoisted(), variables declared with const/let are in the TDZ when the factory
|
|
// runs (static imports trigger module loading before declarations are evaluated).
|
|
|
|
const {
|
|
mockUpdateSet,
|
|
mockUpdate,
|
|
mockWherePending,
|
|
mockFromFn,
|
|
mockSelectFn,
|
|
} = 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
|
|
const mockWherePending = vi.fn().mockImplementation(() => Promise.resolve([] as unknown[]))
|
|
const mockFromFn = vi.fn().mockReturnValue({ where: mockWherePending })
|
|
const mockSelectFn = vi.fn().mockReturnValue({ from: mockFromFn })
|
|
return { mockUpdateSet, mockUpdate, mockWherePending, mockFromFn, mockSelectFn }
|
|
})
|
|
|
|
let mockPendingRows: unknown[] = []
|
|
|
|
vi.mock('../../src/db/client.js', () => ({
|
|
db: {
|
|
select: mockSelectFn,
|
|
update: mockUpdate,
|
|
},
|
|
}))
|
|
|
|
// Mock write functions — these are called by outboxWorker for the actual CalDAV ops
|
|
vi.mock('../../src/broker/write.js', () => ({
|
|
createCalendarEvent: vi.fn(),
|
|
updateCalendarEvent: vi.fn(),
|
|
deleteCalendarEvent: vi.fn(),
|
|
}))
|
|
|
|
// Mock sync — called after successful write (D-06)
|
|
vi.mock('../../src/broker/sync.js', () => ({
|
|
syncCalendar: vi.fn().mockResolvedValue(undefined),
|
|
}))
|
|
|
|
// Mock client creation — the worker needs a DAVClient to call sync
|
|
vi.mock('../../src/broker/client.js', () => ({
|
|
createFastmailClient: vi.fn().mockResolvedValue({
|
|
fetchCalendars: vi.fn().mockResolvedValue([]),
|
|
}),
|
|
}))
|
|
|
|
// Default payload is form JSON (the worker must build ICS from this — not pass raw JSON to CalDAV)
|
|
const DEFAULT_FORM_PAYLOAD = JSON.stringify({
|
|
title: 'Lunch',
|
|
allDay: false,
|
|
start: '2026-06-10T12:00:00',
|
|
end: '2026-06-10T13:00:00',
|
|
recurrence: 'none',
|
|
})
|
|
|
|
const makeRow = (overrides: Record<string, unknown> = {}) => ({
|
|
id: 1,
|
|
userId: 42,
|
|
operation: 'create' as const,
|
|
status: 'pending' as const,
|
|
uid: 'test-uid@familysync',
|
|
calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
|
calendarObjectUrl: null,
|
|
etag: null,
|
|
payload: DEFAULT_FORM_PAYLOAD,
|
|
attemptCount: 0,
|
|
nextAttemptAt: new Date(Date.now() - 5000), // already due
|
|
lastError: null,
|
|
groupId: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
...overrides,
|
|
})
|
|
|
|
const makeResponse = (status: number): Response =>
|
|
({ status, ok: status >= 200 && status < 300, headers: new Headers() }) as unknown as Response
|
|
|
|
describe('runOutboxDrain — state transitions', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockPendingRows = []
|
|
// Restore mock chain after clearAllMocks:
|
|
// db.select().from(table).where(and(cond1, cond2)) → Promise<rows>
|
|
mockUpdateSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
|
|
mockUpdate.mockReturnValue({ set: mockUpdateSet })
|
|
mockWherePending.mockImplementation(() => Promise.resolve(mockPendingRows))
|
|
mockFromFn.mockReturnValue({ where: mockWherePending })
|
|
mockSelectFn.mockReturnValue({ from: mockFromFn })
|
|
})
|
|
|
|
it('transitions pending→done on 204 response and triggers re-sync (D-06)', async () => {
|
|
const { createCalendarEvent } = await import('../../src/broker/write.js')
|
|
vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(204))
|
|
mockPendingRows = [makeRow()]
|
|
|
|
await runOutboxDrain()
|
|
|
|
// Must update status to 'done'
|
|
expect(mockUpdate).toHaveBeenCalled()
|
|
const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string }
|
|
expect(setArg?.status).toBe('done')
|
|
})
|
|
|
|
it('transitions pending→failed on 412 (conflict — no retry), marks failed (D-08)', async () => {
|
|
const { createCalendarEvent } = await import('../../src/broker/write.js')
|
|
vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(412))
|
|
mockPendingRows = [makeRow()]
|
|
|
|
await runOutboxDrain()
|
|
|
|
// 412 = hard fail (conflict) — must NOT retry, must mark failed
|
|
const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string; lastError?: string }
|
|
expect(setArg?.status).toBe('failed')
|
|
expect(setArg?.lastError).toBeTruthy()
|
|
})
|
|
|
|
it('transitions pending→backoff (attemptCount++, nextAttemptAt advanced) on 500 (transient)', async () => {
|
|
const { createCalendarEvent } = await import('../../src/broker/write.js')
|
|
vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(500))
|
|
const row = makeRow({ attemptCount: 0 })
|
|
mockPendingRows = [row]
|
|
|
|
const beforeDrain = Date.now()
|
|
await runOutboxDrain()
|
|
|
|
// Must NOT transition to done or failed — backoff
|
|
const setArg = mockUpdateSet.mock.calls[0]?.[0] as {
|
|
status?: string
|
|
attemptCount?: number
|
|
nextAttemptAt?: Date
|
|
}
|
|
expect(setArg?.status).not.toBe('done')
|
|
expect(setArg?.status).not.toBe('failed')
|
|
expect(setArg?.attemptCount).toBe(1)
|
|
// nextAttemptAt must be in the future
|
|
expect(setArg?.nextAttemptAt?.getTime()).toBeGreaterThan(beforeDrain)
|
|
})
|
|
|
|
it('transitions pending→dead when attemptCount reaches MAX_ATTEMPTS on transient error', async () => {
|
|
const { createCalendarEvent } = await import('../../src/broker/write.js')
|
|
vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(500))
|
|
// MAX_ATTEMPTS is 5 per RESEARCH.md Pattern 4 — at attempt 4 (0-indexed) → dead
|
|
const row = makeRow({ attemptCount: 4 })
|
|
mockPendingRows = [row]
|
|
|
|
await runOutboxDrain()
|
|
|
|
const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string }
|
|
expect(setArg?.status).toBe('dead')
|
|
})
|
|
|
|
it('does not crash when pending rows list is empty', async () => {
|
|
mockPendingRows = []
|
|
await expect(runOutboxDrain()).resolves.not.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('runOutboxDrain — ICS building from form JSON (CR-02)', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockPendingRows = []
|
|
mockUpdateSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
|
|
mockUpdate.mockReturnValue({ set: mockUpdateSet })
|
|
mockWherePending.mockImplementation(() => Promise.resolve(mockPendingRows))
|
|
mockFromFn.mockReturnValue({ where: mockWherePending })
|
|
mockSelectFn.mockReturnValue({ from: mockFromFn })
|
|
})
|
|
|
|
it('create row: icsString passed to createCalendarEvent starts with BEGIN:VCALENDAR and contains SUMMARY', async () => {
|
|
const { createCalendarEvent } = await import('../../src/broker/write.js')
|
|
let capturedIcsString: unknown = null
|
|
vi.mocked(createCalendarEvent).mockImplementation(async (_client, _cal, _uid, icsString) => {
|
|
capturedIcsString = icsString
|
|
return makeResponse(201)
|
|
})
|
|
mockPendingRows = [makeRow()]
|
|
|
|
await runOutboxDrain()
|
|
|
|
expect(typeof capturedIcsString).toBe('string')
|
|
expect((capturedIcsString as string).startsWith('BEGIN:VCALENDAR')).toBe(true)
|
|
expect(capturedIcsString).toContain('SUMMARY:Lunch')
|
|
})
|
|
|
|
it('update row: icsString passed to updateCalendarEvent starts with BEGIN:VCALENDAR', async () => {
|
|
const { updateCalendarEvent } = await import('../../src/broker/write.js')
|
|
let capturedIcsString: unknown = null
|
|
vi.mocked(updateCalendarEvent).mockImplementation(async (_client, _url, icsString, _etag) => {
|
|
capturedIcsString = icsString
|
|
return makeResponse(204)
|
|
})
|
|
mockPendingRows = [makeRow({
|
|
operation: 'update',
|
|
calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/uid.ics',
|
|
})]
|
|
|
|
await runOutboxDrain()
|
|
|
|
expect(typeof capturedIcsString).toBe('string')
|
|
expect((capturedIcsString as string).startsWith('BEGIN:VCALENDAR')).toBe(true)
|
|
})
|
|
|
|
it('create row with unparseable payload marks the row failed (hard fail, no retry)', async () => {
|
|
mockPendingRows = [makeRow({ payload: 'NOT_VALID_JSON{{{' })]
|
|
|
|
await runOutboxDrain()
|
|
|
|
const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string }
|
|
expect(setArg?.status).toBe('failed')
|
|
})
|
|
})
|
|
|
|
describe('runOutboxDrain — edit-as-move ordering (D-04)', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockPendingRows = []
|
|
mockUpdateSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
|
|
mockUpdate.mockReturnValue({ set: mockUpdateSet })
|
|
mockWherePending.mockImplementation(() => Promise.resolve(mockPendingRows))
|
|
mockFromFn.mockReturnValue({ where: mockWherePending })
|
|
mockSelectFn.mockReturnValue({ from: mockFromFn })
|
|
})
|
|
|
|
it('processes the create row BEFORE the delete row when both share a groupId', async () => {
|
|
const { createCalendarEvent, deleteCalendarEvent } = await import('../../src/broker/write.js')
|
|
const createResponse = makeResponse(201)
|
|
const deleteResponse = makeResponse(204)
|
|
vi.mocked(createCalendarEvent).mockResolvedValue(createResponse)
|
|
vi.mocked(deleteCalendarEvent).mockResolvedValue(deleteResponse)
|
|
|
|
const groupId = 'edit-move-group-001'
|
|
// delete row listed first (to verify ordering is enforced regardless of order in the array)
|
|
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,
|
|
})
|
|
const createRow = makeRow({
|
|
id: 3,
|
|
operation: 'create',
|
|
calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/New/',
|
|
groupId,
|
|
})
|
|
|
|
// Both rows in the pending list
|
|
mockPendingRows = [deleteRow, createRow]
|
|
|
|
await runOutboxDrain()
|
|
|
|
// CREATE must be called before DELETE
|
|
const createCall = vi.mocked(createCalendarEvent).mock.invocationCallOrder[0]
|
|
const deleteCall = vi.mocked(deleteCalendarEvent).mock.invocationCallOrder[0]
|
|
|
|
// If either was never called, the test will fail naturally.
|
|
// If create order index > delete order index, create ran AFTER delete — fail.
|
|
expect(createCall).toBeLessThan(deleteCall)
|
|
})
|
|
})
|