test(260607-l6l): make BUG 1 join regression test couple to the handler

The original toSQL() regression test hand-built the joined query inside the
test body and asserted the SQL contained a join — tautological: it never
exercised the handler, so removing .innerJoin from events.ts left it green.

Replace it with two tests that issue real PATCH/DELETE requests against the
mocked select-chain (from → innerJoin → where) and assert the handler returns
202 (not 503) AND invokes the innerJoin spy. Verified RED: removing the
edit+delete joins fails both tests; GREEN with the joins present.
This commit is contained in:
Lucas Berger
2026-06-07 15:34:39 -04:00
parent 8b5ec797cf
commit 509f4b26e0
+50 -54
View File
@@ -646,64 +646,60 @@ describe('CR-06: OIDC iss/sub → users.id resolution on write handlers', () =>
// clause → the /inner join.*calendars/ assertion fails. // clause → the /inner join.*calendars/ assertion fails.
// GREEN: After adding .innerJoin(calendars, ...) the SQL contains the join. // GREEN: After adding .innerJoin(calendars, ...) the SQL contains the join.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe('regression: edit/delete lookups join calendars', () => { // Regression: BUG 1 — the edit/delete lookups select calendars.url/userId but
it('PATCH /:uid/edit lookup SQL contains inner join to calendars', async () => { // must JOIN calendars to do so. Without the join Drizzle throws at query-build
// Use the real drizzle + schema — vi.importActual bypasses the vi.mock for db/client. // the handler's catch returns 503 (delete dialog never closes).
// drizzle does NOT need a live DB to produce SQL via toSQL(). //
const { drizzle } = await vi.importActual<typeof import('drizzle-orm/mysql2')>('drizzle-orm/mysql2') // This couples to the HANDLER, not a query the test rebuilds. The select-chain
const { eq } = await vi.importActual<typeof import('drizzle-orm')>('drizzle-orm') // mock exposes from() → innerJoin() → where(); a no-join handler that calls
const { calendarEvents, calendars } = await vi.importActual<typeof import('../../src/db/schema.js')>('../../src/db/schema.js') // .from().where() hits an undefined .where() → throws → 503, AND never invokes
// the innerJoin spy. So both assertions go RED if the join is removed from
// events.ts; they pass only because the handler actually joins.
describe('regression: edit/delete lookups join calendars (BUG 1)', () => {
const seededRow = {
uid: 'uid-001@familysync',
etag: '"etag-abc"',
objectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/uid-001.ics',
calendarId: 1,
calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
userId: 1,
}
let innerJoinSpy: ReturnType<typeof vi.fn>
// Construct a throwaway drizzle instance — client is never called by toSQL() beforeEach(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any mockDbRows = [seededRow]
const db = drizzle({ client: {} as any, mode: 'default' }) // Wire from() → innerJoin() → where(); the innerJoin spy proves the handler
// routes through the join rather than calling .where() directly on from().
// Build the lookup query AS THE HANDLER SHOULD (with join). const innerJoinWhere = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows))
// If the handler omits innerJoin, this test catches the regression by innerJoinSpy = vi.fn().mockReturnValue({ where: innerJoinWhere })
// failing the SQL assertion — uncomment the no-join version to see RED: mockFromFn.mockReturnValue({ innerJoin: innerJoinSpy })
// .from(calendarEvents) mockSelectFn.mockReturnValue({ from: mockFromFn })
// .where(eq(calendarEvents.uid, 'test-uid')) ← no join → toSQL omits join clause → FAILS
const lookupQuery = db
.select({
uid: calendarEvents.uid,
etag: calendarEvents.etag,
objectUrl: calendarEvents.objectUrl,
calendarId: calendarEvents.calendarId,
calendarUrl: calendars.url,
userId: calendars.userId,
})
.from(calendarEvents)
.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
.where(eq(calendarEvents.uid, 'test-uid'))
const { sql: generatedSql } = lookupQuery.toSQL()
// Must contain an inner join referencing the calendars table
expect(generatedSql).toMatch(/inner join[\s\S]*`calendars`/i)
}) })
it('DELETE /:uid lookup SQL contains inner join to calendars', async () => { it('PATCH /:uid/edit joins calendars (no 503) and invokes innerJoin', async () => {
const { drizzle } = await vi.importActual<typeof import('drizzle-orm/mysql2')>('drizzle-orm/mysql2') const { app } = await import('../../src/index.js')
const { eq } = await vi.importActual<typeof import('drizzle-orm')>('drizzle-orm') const res = await app.request('/api/events/uid-001%40familysync/edit', {
const { calendarEvents, calendars } = await vi.importActual<typeof import('../../src/db/schema.js')>('../../src/db/schema.js') method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: 'Updated title',
allDay: false,
start: '2026-06-15T10:00:00Z',
end: '2026-06-15T11:00:00Z',
calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
}),
})
expect(res.status).toBe(202)
expect(innerJoinSpy).toHaveBeenCalled()
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any it('DELETE /:uid joins calendars (no 503) and invokes innerJoin', async () => {
const db = drizzle({ client: {} as any, mode: 'default' }) const { app } = await import('../../src/index.js')
const res = await app.request('/api/events/uid-001%40familysync', {
const lookupQuery = db method: 'DELETE',
.select({ })
uid: calendarEvents.uid, expect(res.status).toBe(202)
etag: calendarEvents.etag, expect(innerJoinSpy).toHaveBeenCalled()
objectUrl: calendarEvents.objectUrl,
calendarId: calendarEvents.calendarId,
calendarUrl: calendars.url,
userId: calendars.userId,
})
.from(calendarEvents)
.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
.where(eq(calendarEvents.uid, 'test-uid'))
const { sql: generatedSql } = lookupQuery.toSQL()
expect(generatedSql).toMatch(/inner join[\s\S]*`calendars`/i)
}) })
}) })