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:
@@ -32,20 +32,24 @@ const {
|
|||||||
mockUpdateSet,
|
mockUpdateSet,
|
||||||
mockUpdate,
|
mockUpdate,
|
||||||
mockWherePending,
|
mockWherePending,
|
||||||
|
mockWhereCalEvents,
|
||||||
mockFromFn,
|
mockFromFn,
|
||||||
mockSelectFn,
|
mockSelectFn,
|
||||||
mockDecryptPassword,
|
mockDecryptPassword,
|
||||||
} = vi.hoisted(() => {
|
} = vi.hoisted(() => {
|
||||||
const mockUpdateSet = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
|
const mockUpdateSet = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
|
||||||
const mockUpdate = vi.fn().mockReturnValue({ set: mockUpdateSet })
|
const mockUpdate = vi.fn().mockReturnValue({ set: mockUpdateSet })
|
||||||
// mockWherePending is the terminal node of the select chain:
|
// mockWherePending: terminal node for calendarOutbox selects (pending-rows + sibling-status)
|
||||||
// db.select().from(table).where(and(cond1, cond2)) — resolves to the row array
|
// db.select().from(calendarOutbox).where(...) — resolves to the row array
|
||||||
const mockWherePending = vi.fn().mockImplementation(() => Promise.resolve([] as unknown[]))
|
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 mockFromFn = vi.fn().mockReturnValue({ where: mockWherePending })
|
||||||
const mockSelectFn = vi.fn().mockReturnValue({ from: mockFromFn })
|
const mockSelectFn = vi.fn().mockReturnValue({ from: mockFromFn })
|
||||||
// By default returns a dummy password so loadClientForUser succeeds
|
// By default returns a dummy password so loadClientForUser succeeds
|
||||||
const mockDecryptPassword = vi.fn().mockReturnValue('app-password')
|
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[] = []
|
let mockPendingRows: unknown[] = []
|
||||||
@@ -124,20 +128,22 @@ const makeResponse = (status: number): Response =>
|
|||||||
function wireMockChain() {
|
function wireMockChain() {
|
||||||
mockUpdateSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
|
mockUpdateSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
|
||||||
mockUpdate.mockReturnValue({ set: mockUpdateSet })
|
mockUpdate.mockReturnValue({ set: mockUpdateSet })
|
||||||
// mockFromFn differentiates by table argument:
|
// mockFromFn differentiates by table argument using Symbol.for('drizzle:Name'):
|
||||||
// - memberCredentials table → returns FAKE_CRED_ROW (so loadClientForUser succeeds by default)
|
// - memberCredentials → returns FAKE_CRED_ROW (so loadClientForUser succeeds by default)
|
||||||
// - anything else (calendarOutbox, calendarEvents) → returns mockWherePending
|
// - calendarEvents → returns mockWhereCalEvents (etag re-read for WR-02)
|
||||||
// Use Symbol.for('drizzle:Name') to identify the table — JSON.stringify throws on circular Drizzle
|
// - calendarOutbox (and anything else) → returns mockWherePending (pending-rows + sibling-status)
|
||||||
// table structures so it cannot be used for table identification.
|
// JSON.stringify throws on circular Drizzle table structures; use Symbol identity instead.
|
||||||
mockFromFn.mockImplementation((table: unknown) => {
|
mockFromFn.mockImplementation((table: unknown) => {
|
||||||
const tableName = (table as Record<symbol, string>)[Symbol.for('drizzle:Name')] ?? ''
|
const tableName = (table as Record<symbol, string>)[Symbol.for('drizzle:Name')] ?? ''
|
||||||
const isCred = tableName === 'member_credentials'
|
if (tableName === 'member_credentials') {
|
||||||
return {
|
return { where: vi.fn().mockResolvedValue([FAKE_CRED_ROW]) }
|
||||||
where: isCred
|
|
||||||
? vi.fn().mockResolvedValue([FAKE_CRED_ROW])
|
|
||||||
: mockWherePending,
|
|
||||||
}
|
}
|
||||||
|
if (tableName === 'calendar_events') {
|
||||||
|
return { where: mockWhereCalEvents }
|
||||||
|
}
|
||||||
|
return { where: mockWherePending }
|
||||||
})
|
})
|
||||||
|
mockWhereCalEvents.mockImplementation(() => Promise.resolve([]))
|
||||||
mockWherePending.mockImplementation(() => Promise.resolve(mockPendingRows))
|
mockWherePending.mockImplementation(() => Promise.resolve(mockPendingRows))
|
||||||
mockSelectFn.mockReturnValue({ from: mockFromFn })
|
mockSelectFn.mockReturnValue({ from: mockFromFn })
|
||||||
// Default: decryptPassword succeeds
|
// Default: decryptPassword succeeds
|
||||||
@@ -146,7 +152,7 @@ function wireMockChain() {
|
|||||||
|
|
||||||
describe('runOutboxDrain — state transitions', () => {
|
describe('runOutboxDrain — state transitions', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.resetAllMocks()
|
||||||
mockPendingRows = []
|
mockPendingRows = []
|
||||||
wireMockChain()
|
wireMockChain()
|
||||||
})
|
})
|
||||||
@@ -220,7 +226,7 @@ describe('runOutboxDrain — state transitions', () => {
|
|||||||
|
|
||||||
describe('runOutboxDrain — ICS building from form JSON (CR-02)', () => {
|
describe('runOutboxDrain — ICS building from form JSON (CR-02)', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.resetAllMocks()
|
||||||
mockPendingRows = []
|
mockPendingRows = []
|
||||||
wireMockChain()
|
wireMockChain()
|
||||||
})
|
})
|
||||||
@@ -271,7 +277,7 @@ describe('runOutboxDrain — ICS building from form JSON (CR-02)', () => {
|
|||||||
|
|
||||||
describe('runOutboxDrain — edit-as-move ordering (D-04)', () => {
|
describe('runOutboxDrain — edit-as-move ordering (D-04)', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.resetAllMocks()
|
||||||
mockPendingRows = []
|
mockPendingRows = []
|
||||||
wireMockChain()
|
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)', () => {
|
describe('runOutboxDrain — durable create-before-delete (CR-04) + concurrency guard (CR-05)', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.resetAllMocks()
|
||||||
mockPendingRows = []
|
mockPendingRows = []
|
||||||
wireMockChain()
|
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)', () => {
|
describe('runOutboxDrain — fail closed on bad credentials (CR-03) + backoff index fix (WR-01)', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.resetAllMocks()
|
||||||
mockPendingRows = []
|
mockPendingRows = []
|
||||||
wireMockChain()
|
wireMockChain()
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user