test(03-11): RED — fresh etag re-read before PUT to avoid spurious 412 (WR-02)

- WR-02 fresh: update PUT must use calendarEvents.etag not stale enqueue-time etag
  (fails RED: capturedEtag === 'old-etag', not 'new-etag')
- WR-02 fallback: when calendarEvents has no row, fall back to row.etag (passes in RED)
- Add mockWhereCalEvents to mock infrastructure to isolate calendarEvents selects
- Switch all beforeEach to vi.resetAllMocks() to prevent mockImplementationOnce bleed
This commit is contained in:
Lucas Berger
2026-06-05 21:05:40 -04:00
parent b409c09e25
commit 5eb26c0e6b
+87 -18
View File
@@ -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()
})