diff --git a/apps/api/tests/routes/events.test.ts b/apps/api/tests/routes/events.test.ts index 00194eb..883b59f 100644 --- a/apps/api/tests/routes/events.test.ts +++ b/apps/api/tests/routes/events.test.ts @@ -646,64 +646,60 @@ describe('CR-06: OIDC iss/sub → users.id resolution on write handlers', () => // clause → the /inner join.*calendars/ assertion fails. // GREEN: After adding .innerJoin(calendars, ...) the SQL contains the join. // --------------------------------------------------------------------------- -describe('regression: edit/delete lookups join calendars', () => { - it('PATCH /:uid/edit lookup SQL contains inner join to calendars', async () => { - // Use the real drizzle + schema — vi.importActual bypasses the vi.mock for db/client. - // drizzle does NOT need a live DB to produce SQL via toSQL(). - const { drizzle } = await vi.importActual('drizzle-orm/mysql2') - const { eq } = await vi.importActual('drizzle-orm') - const { calendarEvents, calendars } = await vi.importActual('../../src/db/schema.js') +// Regression: BUG 1 — the edit/delete lookups select calendars.url/userId but +// must JOIN calendars to do so. Without the join Drizzle throws at query-build +// → the handler's catch returns 503 (delete dialog never closes). +// +// This couples to the HANDLER, not a query the test rebuilds. The select-chain +// mock exposes from() → innerJoin() → where(); a no-join handler that calls +// .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 - // Construct a throwaway drizzle instance — client is never called by toSQL() - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const db = drizzle({ client: {} as any, mode: 'default' }) - - // Build the lookup query AS THE HANDLER SHOULD (with join). - // If the handler omits innerJoin, this test catches the regression by - // failing the SQL assertion — uncomment the no-join version to see RED: - // .from(calendarEvents) - // .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) + beforeEach(() => { + mockDbRows = [seededRow] + // Wire from() → innerJoin() → where(); the innerJoin spy proves the handler + // routes through the join rather than calling .where() directly on from(). + const innerJoinWhere = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows)) + innerJoinSpy = vi.fn().mockReturnValue({ where: innerJoinWhere }) + mockFromFn.mockReturnValue({ innerJoin: innerJoinSpy }) + mockSelectFn.mockReturnValue({ from: mockFromFn }) }) - it('DELETE /:uid lookup SQL contains inner join to calendars', async () => { - const { drizzle } = await vi.importActual('drizzle-orm/mysql2') - const { eq } = await vi.importActual('drizzle-orm') - const { calendarEvents, calendars } = await vi.importActual('../../src/db/schema.js') + it('PATCH /:uid/edit joins calendars (no 503) and invokes innerJoin', async () => { + const { app } = await import('../../src/index.js') + const res = await app.request('/api/events/uid-001%40familysync/edit', { + 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 - const db = drizzle({ client: {} as any, mode: 'default' }) - - 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() - expect(generatedSql).toMatch(/inner join[\s\S]*`calendars`/i) + it('DELETE /:uid joins calendars (no 503) and invokes innerJoin', async () => { + const { app } = await import('../../src/index.js') + const res = await app.request('/api/events/uid-001%40familysync', { + method: 'DELETE', + }) + expect(res.status).toBe(202) + expect(innerJoinSpy).toHaveBeenCalled() }) })