Milestone v1.0: FamilySync MVP #1
@@ -32,20 +32,24 @@ const {
|
||||
mockUpdateSet,
|
||||
mockUpdate,
|
||||
mockWherePending,
|
||||
mockWhereCalEvents,
|
||||
mockFromFn,
|
||||
mockSelectFn,
|
||||
mockDecryptPassword,
|
||||
} = 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
|
||||
// mockWherePending: terminal node for calendarOutbox selects (pending-rows + sibling-status)
|
||||
// db.select().from(calendarOutbox).where(...) — resolves to the row array
|
||||
const mockWherePending = vi.fn().mockImplementation(() => Promise.resolve([] as unknown[]))
|
||||
// mockWhereCalEvents: terminal node for calendarEvents selects (etag re-read for WR-02)
|
||||
// db.select({etag}).from(calendarEvents).where(...) — resolves to the etag array
|
||||
const mockWhereCalEvents = vi.fn().mockImplementation(() => Promise.resolve([] as unknown[]))
|
||||
const mockFromFn = vi.fn().mockReturnValue({ where: mockWherePending })
|
||||
const mockSelectFn = vi.fn().mockReturnValue({ from: mockFromFn })
|
||||
// By default returns a dummy password so loadClientForUser succeeds
|
||||
const mockDecryptPassword = vi.fn().mockReturnValue('app-password')
|
||||
return { mockUpdateSet, mockUpdate, mockWherePending, mockFromFn, mockSelectFn, mockDecryptPassword }
|
||||
return { mockUpdateSet, mockUpdate, mockWherePending, mockWhereCalEvents, mockFromFn, mockSelectFn, mockDecryptPassword }
|
||||
})
|
||||
|
||||
let mockPendingRows: unknown[] = []
|
||||
@@ -124,20 +128,22 @@ const makeResponse = (status: number): Response =>
|
||||
function wireMockChain() {
|
||||
mockUpdateSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
|
||||
mockUpdate.mockReturnValue({ set: mockUpdateSet })
|
||||
// mockFromFn differentiates by table argument:
|
||||
// - memberCredentials table → returns FAKE_CRED_ROW (so loadClientForUser succeeds by default)
|
||||
// - anything else (calendarOutbox, calendarEvents) → returns mockWherePending
|
||||
// Use Symbol.for('drizzle:Name') to identify the table — JSON.stringify throws on circular Drizzle
|
||||
// table structures so it cannot be used for table identification.
|
||||
// mockFromFn differentiates by table argument using Symbol.for('drizzle:Name'):
|
||||
// - memberCredentials → returns FAKE_CRED_ROW (so loadClientForUser succeeds by default)
|
||||
// - calendarEvents → returns mockWhereCalEvents (etag re-read for WR-02)
|
||||
// - calendarOutbox (and anything else) → returns mockWherePending (pending-rows + sibling-status)
|
||||
// JSON.stringify throws on circular Drizzle table structures; use Symbol identity instead.
|
||||
mockFromFn.mockImplementation((table: unknown) => {
|
||||
const tableName = (table as Record<symbol, string>)[Symbol.for('drizzle:Name')] ?? ''
|
||||
const isCred = tableName === 'member_credentials'
|
||||
return {
|
||||
where: isCred
|
||||
? vi.fn().mockResolvedValue([FAKE_CRED_ROW])
|
||||
: mockWherePending,
|
||||
if (tableName === 'member_credentials') {
|
||||
return { where: vi.fn().mockResolvedValue([FAKE_CRED_ROW]) }
|
||||
}
|
||||
if (tableName === 'calendar_events') {
|
||||
return { where: mockWhereCalEvents }
|
||||
}
|
||||
return { where: mockWherePending }
|
||||
})
|
||||
mockWhereCalEvents.mockImplementation(() => Promise.resolve([]))
|
||||
mockWherePending.mockImplementation(() => Promise.resolve(mockPendingRows))
|
||||
mockSelectFn.mockReturnValue({ from: mockFromFn })
|
||||
// Default: decryptPassword succeeds
|
||||
@@ -146,7 +152,7 @@ function wireMockChain() {
|
||||
|
||||
describe('runOutboxDrain — state transitions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetAllMocks()
|
||||
mockPendingRows = []
|
||||
wireMockChain()
|
||||
})
|
||||
@@ -220,7 +226,7 @@ describe('runOutboxDrain — state transitions', () => {
|
||||
|
||||
describe('runOutboxDrain — ICS building from form JSON (CR-02)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetAllMocks()
|
||||
mockPendingRows = []
|
||||
wireMockChain()
|
||||
})
|
||||
@@ -271,7 +277,7 @@ describe('runOutboxDrain — ICS building from form JSON (CR-02)', () => {
|
||||
|
||||
describe('runOutboxDrain — edit-as-move ordering (D-04)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetAllMocks()
|
||||
mockPendingRows = []
|
||||
wireMockChain()
|
||||
})
|
||||
@@ -327,7 +333,7 @@ describe('runOutboxDrain — edit-as-move ordering (D-04)', () => {
|
||||
|
||||
describe('runOutboxDrain — durable create-before-delete (CR-04) + concurrency guard (CR-05)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetAllMocks()
|
||||
mockPendingRows = []
|
||||
wireMockChain()
|
||||
})
|
||||
@@ -443,9 +449,72 @@ describe('runOutboxDrain — durable create-before-delete (CR-04) + concurrency
|
||||
})
|
||||
})
|
||||
|
||||
describe('runOutboxDrain — fresh etag re-read before PUT (WR-02)', () => {
|
||||
beforeEach(() => {
|
||||
// Use resetAllMocks here (not clearAllMocks) so that unconsumed mockImplementationOnce
|
||||
// queues from prior tests do not bleed into subsequent tests via the shared mockWherePending.
|
||||
vi.resetAllMocks()
|
||||
mockPendingRows = []
|
||||
wireMockChain()
|
||||
})
|
||||
|
||||
it('WR-02 fresh etag: update PUT uses freshest calendarEvents.etag, not stale enqueue-time etag', async () => {
|
||||
const { updateCalendarEvent } = await import('../../src/broker/write.js')
|
||||
let capturedEtag: string | null = null
|
||||
vi.mocked(updateCalendarEvent).mockImplementation(async (_client, _url, _ics, etag) => {
|
||||
capturedEtag = etag
|
||||
return makeResponse(204)
|
||||
})
|
||||
|
||||
const updateRow = makeRow({
|
||||
operation: 'update',
|
||||
calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/uid.ics',
|
||||
etag: 'old-etag', // stale enqueue-time etag
|
||||
})
|
||||
mockPendingRows = [updateRow]
|
||||
|
||||
// Mock the calendarEvents etag lookup to return a fresher etag.
|
||||
// In RED (no fresh-etag code yet), mockWhereCalEvents is never called, so
|
||||
// the PUT uses row.etag = 'old-etag'. The assertion expects 'new-etag' → fails RED.
|
||||
mockWhereCalEvents.mockResolvedValue([{ etag: 'new-etag' }])
|
||||
|
||||
await runOutboxDrain()
|
||||
|
||||
// The PUT must use the freshest etag from calendarEvents, not the stale row.etag
|
||||
expect(capturedEtag).toBe('new-etag')
|
||||
expect(capturedEtag).not.toBe('old-etag')
|
||||
})
|
||||
|
||||
it('WR-02 etag fallback: update PUT falls back to row.etag when calendarEvents has no matching row', async () => {
|
||||
const { updateCalendarEvent } = await import('../../src/broker/write.js')
|
||||
let capturedEtag: string | null = null
|
||||
vi.mocked(updateCalendarEvent).mockImplementation(async (_client, _url, _ics, etag) => {
|
||||
capturedEtag = etag
|
||||
return makeResponse(204)
|
||||
})
|
||||
|
||||
const updateRow = makeRow({
|
||||
operation: 'update',
|
||||
calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/uid.ics',
|
||||
etag: 'fallback-etag',
|
||||
})
|
||||
mockPendingRows = [updateRow]
|
||||
|
||||
// mockWhereCalEvents is already configured to return [] by default in wireMockChain.
|
||||
// No row for the uid → worker falls back to row.etag.
|
||||
// In RED, mockWhereCalEvents is never called so the test passes (row.etag used directly).
|
||||
// In GREEN, mockWhereCalEvents returns [] so the fallback is exercised.
|
||||
|
||||
await runOutboxDrain()
|
||||
|
||||
// When calendarEvents has no row for the uid, fall back to row.etag
|
||||
expect(capturedEtag).toBe('fallback-etag')
|
||||
})
|
||||
})
|
||||
|
||||
describe('runOutboxDrain — fail closed on bad credentials (CR-03) + backoff index fix (WR-01)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetAllMocks()
|
||||
mockPendingRows = []
|
||||
wireMockChain()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user