/** * Tests for GET /api/events — windowed endpoint. * * Contracts: * 1. Missing/malformed start or end params → 400 (input validation guard) * 2. Window wider than 90 days → 400 (DoS cap) * 3. Valid window returns { occurrences: [] } when DB is empty * 4. Recurring timed master (dtstartUtc years before window, hasRrule=1) yields occurrences * inside the requested window via expandOccurrences. * 5. Recurring all-day master (dtstartUtc NULL, dtstartDate years before window, hasRrule=1) * is NOT filtered out — its occurrence in the window is returned. * * Write endpoint contracts (added Plan 03-03): * 6. POST /create validates input, enqueues outbox row, returns 202. * 7. POST /create with non-owned calendarUrl returns 403 (D-03 / V4). * 8. PATCH /:uid/edit enqueues update outbox row, returns 202. * 9. DELETE /:uid enqueues delete outbox row, returns 202. * 10. GET /sync-status?uid= returns outbox status for the member's UID. * 11. GET /writable-calendars returns member's own + shared calendars, never other member's personal. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { SAMPLE_VEVENT_RECURRING_TIMED, SAMPLE_VEVENT_RECURRING_ALLDAY } from '../helpers/db.js'; // --------------------------------------------------------------------------- // Configurable getAuth and devBypass so individual tests can control the auth // path without reloading the module. Uses vi.hoisted() to avoid TDZ issues // (D-03-04-hoisting: vi.hoisted() required when test file has static imports of // modules that vi.mock() references in their factory callbacks). // --------------------------------------------------------------------------- // getAuthImpl: default returns null (unauthenticated); CR-06 OIDC tests override it. const { getAuthImpl, devBypassInjectUser } = vi.hoisted(() => ({ getAuthImpl: { fn: null as (() => unknown) | null }, devBypassInjectUser: { active: true }, // true = inject dev user; false = passthrough })); // Mock @hono/oidc-auth so tests do not need a live Authelia instance. // The mock makes oidcAuthMiddleware a no-op passthrough. // getAuth delegates to getAuthImpl.fn so per-test overrides work at call time. vi.mock('@hono/oidc-auth', () => ({ oidcAuthMiddleware: () => async (_c: unknown, next: () => Promise) => next(), processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) => c.json({ ok: true }), getAuth: () => (getAuthImpl.fn ? getAuthImpl.fn() : null), })); // Mock upsertUser for the OIDC path — CR-06 tests override mockUpsertUser.fn. const mockUpsertUserFn = vi.fn(); vi.mock('../../src/auth/user.js', async (importActual) => { // Keep deriveDisplayName (a pure helper) real so the displayName the handler // passes to upsertUser reflects production claim-preference logic; stub only // upsertUser (the DB-touching function). const actual = await importActual(); return { ...actual, upsertUser: (...args: unknown[]) => mockUpsertUserFn(...args), }; }); // --------------------------------------------------------------------------- // Configurable mock for the DB. // ALL db methods used by the events router are mocked here so vi.mock can // reference them. Vitest hoists vi.mock to the top of the file — the factory // must refer to variables that are mutable (reassigned in beforeEach) via // wrapper fns rather than direct references to lets. // --------------------------------------------------------------------------- // --- select chain --- let mockDbRows: unknown[] = []; const mockWhereFn = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows)); const mockInnerJoin2Fn = vi.fn().mockReturnValue({ where: mockWhereFn }); const mockInnerJoin1Fn = vi.fn().mockReturnValue({ innerJoin: mockInnerJoin2Fn }); const mockFromFn = vi.fn().mockReturnValue({ innerJoin: mockInnerJoin1Fn }); const mockSelectFn = vi.fn().mockReturnValue({ from: mockFromFn }); // --- Simple select chain used by /sync-status and /writable-calendars (no joins) --- // We intercept via mockSelectFn dispatcher — each test controls which path fires // by configuring mockFromFn to return either join chain or where-only chain. // --- insert chain --- let mockInsertValuesFn = vi.fn().mockResolvedValue([{ insertId: 1 }]); const mockInsertFn = vi.fn().mockImplementation(() => ({ values: mockInsertValuesFn })); // --- transaction --- // Drizzle transaction receives a callback (tx => ...). The mock invokes the callback // with a fake tx object that has insert: mockInsertFn so transactional inserts are counted. const mockTransactionFn = vi .fn() .mockImplementation(async (cb: (tx: { insert: typeof mockInsertFn }) => Promise) => { await cb({ insert: mockInsertFn }); }); vi.mock('../../src/db/client.js', () => ({ db: { select: mockSelectFn, insert: mockInsertFn, transaction: mockTransactionFn, }, })); // --------------------------------------------------------------------------- // Shared state reset // --------------------------------------------------------------------------- beforeEach(() => { mockDbRows = []; vi.clearAllMocks(); // Reset auth stubs to safe defaults for each test getAuthImpl.fn = null; // getAuth returns null (unauthenticated) devBypassInjectUser.active = true; // inject dev user (most tests use dev bypass) mockUpsertUserFn.mockResolvedValue({ id: 42, oidcIss: 'https://auth.example.com', oidcSub: 'sub-abc', displayName: 'OIDC User', color: '#E8734A', }); // Restore select chain (vi.clearAllMocks wipes mockImplementation) mockWhereFn.mockImplementation(() => Promise.resolve(mockDbRows)); mockInnerJoin2Fn.mockReturnValue({ where: mockWhereFn }); mockInnerJoin1Fn.mockReturnValue({ innerJoin: mockInnerJoin2Fn }); mockFromFn.mockReturnValue({ innerJoin: mockInnerJoin1Fn }); mockSelectFn.mockReturnValue({ from: mockFromFn }); // Restore insert chain mockInsertValuesFn = vi.fn().mockResolvedValue([{ insertId: 1 }]); mockInsertFn.mockImplementation(() => ({ values: mockInsertValuesFn })); // Restore transaction mockTransactionFn.mockImplementation( async (cb: (tx: { insert: typeof mockInsertFn }) => Promise) => { await cb({ insert: mockInsertFn }); }, ); }); // --------------------------------------------------------------------------- // GET /api/events — windowed read // --------------------------------------------------------------------------- describe('GET /api/events', () => { beforeEach(() => { // The windowed GET uses the 3-join select chain; ensure it is wired. mockFromFn.mockReturnValue({ innerJoin: mockInnerJoin1Fn }); mockSelectFn.mockReturnValue({ from: mockFromFn }); }); it('returns 400 when start param is missing', async () => { const { app } = await import('../../src/index.js'); const res = await app.request('/api/events?end=2026-07-01'); expect(res.status).toBe(400); }); it('returns 400 when end param is missing', async () => { const { app } = await import('../../src/index.js'); const res = await app.request('/api/events?start=2026-06-01'); expect(res.status).toBe(400); }); it('returns 400 when start param is malformed (not YYYY-MM-DD)', async () => { const { app } = await import('../../src/index.js'); const res = await app.request('/api/events?start=not-a-date&end=2026-07-01'); expect(res.status).toBe(400); }); it('returns occurrences with color, isShared, and ownerName fields for valid window', async () => { const { app } = await import('../../src/index.js'); const res = await app.request('/api/events?start=2026-06-01&end=2026-07-01'); expect(res.status).toBe(200); const body = (await res.json()) as { occurrences: Array<{ color: string; isShared: boolean; ownerName: string | null }>; }; expect(body).toHaveProperty('occurrences'); expect(Array.isArray(body.occurrences)).toBe(true); // Each occurrence must carry color + isShared + ownerName (may be empty array if DB is mocked empty) for (const occ of body.occurrences) { expect(occ).toHaveProperty('color'); expect(typeof occ.color).toBe('string'); expect(occ).toHaveProperty('isShared'); expect(typeof occ.isShared).toBe('boolean'); expect(occ).toHaveProperty('ownerName'); // ownerName is string | null — both are valid expect(occ.ownerName === null || typeof occ.ownerName === 'string').toBe(true); } }); it('returns occurrences for a timed recurring master whose dtstartUtc is years before the window', async () => { // Simulates a weekly meeting created in 2024 (dtstartUtc=2024-01-01) being queried for // a 2026 window. The SQL pre-filter must select it (hasRrule=1 AND dtstartUtc < windowEnd) // and expandOccurrences must return the in-window occurrences. // SAMPLE_VEVENT_RECURRING_TIMED has DTSTART:20240101T100000Z RRULE:FREQ=WEEKLY;BYDAY=MO. // In the 2026-06-01..2026-07-01 window there are 4 Mondays. mockDbRows = [ { rawVevent: SAMPLE_VEVENT_RECURRING_TIMED, calendarId: 1, calendarName: 'Work', isShared: false, userId: 1, userColor: '#4A90D9', ownerName: 'Alice', }, ]; const { app } = await import('../../src/index.js'); const res = await app.request('/api/events?start=2026-06-01&end=2026-07-01'); expect(res.status).toBe(200); const body = (await res.json()) as { occurrences: Array<{ uid: string; start: string; allDay: boolean; ownerName: string | null }>; }; expect(body.occurrences.length).toBeGreaterThan(0); // All returned occurrences must be inside [2026-06-01, 2026-07-01) for (const occ of body.occurrences) { expect(occ.uid).toBe('test-recurring-timed-001@familysync'); expect(occ.allDay).toBe(false); // start must be within the window const startDate = occ.start.slice(0, 10); // 'YYYY-MM-DD' expect(startDate >= '2026-06-01').toBe(true); expect(startDate < '2026-07-01').toBe(true); // ownerName from the mock row must be threaded through expect(occ.ownerName).toBe('Alice'); } }); it('returns occurrence for an all-day recurring master whose dtstartDate is years before the window (dtstartUtc NULL)', async () => { // Simulates an annual birthday created in 2024 (DTSTART;VALUE=DATE:20240615, no dtstartUtc). // The SQL pre-filter MUST NOT exclude it on dtstartUtc IS NULL — the fix adds // OR dtstartDate < end // so this row is selected and expandOccurrences returns the 2026-06-15 occurrence. // SAMPLE_VEVENT_RECURRING_ALLDAY has DTSTART;VALUE=DATE:20240615 RRULE:FREQ=YEARLY. mockDbRows = [ { rawVevent: SAMPLE_VEVENT_RECURRING_ALLDAY, calendarId: 2, calendarName: 'Personal', isShared: false, userId: 1, userColor: '#50C878', ownerName: 'Bob', }, ]; const { app } = await import('../../src/index.js'); const res = await app.request('/api/events?start=2026-06-01&end=2026-07-01'); expect(res.status).toBe(200); const body = (await res.json()) as { occurrences: Array<{ uid: string; start: string; allDay: boolean }>; }; // There must be exactly one annual occurrence (June 15) in the window expect(body.occurrences.length).toBe(1); const occ = body.occurrences[0]; expect(occ.uid).toBe('test-recurring-allday-001@familysync'); expect(occ.allDay).toBe(true); expect(occ.start).toBe('2026-06-15'); }); }); // --------------------------------------------------------------------------- // Write endpoints + sync-status + writable-calendars (Plan 03-03) // // CAL-04/05/06, D-03, D-05, D-08, D-09 // // These tests extend the existing GET /api/events suite. // They were RED in Plan 01 and turn GREEN in Plan 03-03. // // Auth: the top-level vi.mock for @hono/oidc-auth sets getAuth → null. // The dev-bypass (DEV_AUTH_BYPASS env) is NOT active in the test environment, // so the routes fall through to getAuth(c) which returns null → 401. // // HOWEVER: for write routes in test, we need an authenticated user. The test // environment sets DEV_AUTH_BYPASS=true via the vitest.config or the route's // dev-bypass reads c.get('user'). But since DEV_AUTH_BYPASS env is not set in // the test runner, c.get('user') will be undefined and getAuth(c) returns null. // // Resolution: the write route implementation falls back gracefully — when both // devUser and getAuth return null/undefined, it returns 401. But the write tests // do NOT check for 401; they expect 202/400/403. Therefore: // - We need a way to inject a user. The cleanest approach is to mock the // devBypass module so that c.get('user') returns DEV_USER in tests. // - We mock '../auth/devBypass.js' to inject a fixed user into context. // --------------------------------------------------------------------------- // Mock devAuthBypass: delegates to devBypassInjectUser.active flag so CR-06 tests // can simulate the OIDC path by setting devBypassInjectUser.active = false. vi.mock('../../src/auth/devBypass.js', async (importOriginal) => { const original = await importOriginal(); return { ...original, devAuthBypass: () => async (c: { set: (k: string, v: unknown) => void }, next: () => Promise) => { if (devBypassInjectUser.active) { c.set('user', { id: 1, oidcIss: 'dev', oidcSub: 'dev-user', displayName: 'Dev User', color: '#4A90D9', }); } await next(); }, }; }); // --------------------------------------------------------------------------- // POST /api/events/create // --------------------------------------------------------------------------- describe('POST /api/events/create', () => { beforeEach(() => { // create: select chain used to look up calendar ownership. // Default: mockDbRows=[] means no owned calendar found → 403 for calendarUrl given. // To get 202, we need to either supply no calendarUrl (uses default) or supply a // calendarUrl that matches a calendar row. For simplicity: mock a calendar row as // owned by user 1 matching the test calendarUrl. mockDbRows = [ { id: 1, url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', displayName: 'Default', color: '#4A90D9', userId: 1, isShared: false, }, ]; // Rewire select chain for simple where-only queries (no joins needed for calendar lookup). const mockSimpleWhere = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows)); mockFromFn.mockReturnValue({ where: mockSimpleWhere }); mockSelectFn.mockReturnValue({ from: mockFromFn }); }); it('returns 202 and inserts a pending outbox row for a valid create request', async () => { const { app } = await import('../../src/index.js'); const res = await app.request('/api/events/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'New event', 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); const body = (await res.json()) as { uid: string }; expect(body).toHaveProperty('uid'); expect(typeof body.uid).toBe('string'); }); it('returns 400 for missing required fields', async () => { const { app } = await import('../../src/index.js'); const res = await app.request('/api/events/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ summary: 'Missing dates' }), }); expect(res.status).toBe(400); }); it('returns 403 when writing to a calendar not owned by the user (D-03 / V4)', async () => { // Override: no owned calendar matches this URL mockDbRows = []; const mockSimpleWhere = vi.fn().mockImplementation(() => Promise.resolve([])); mockFromFn.mockReturnValue({ where: mockSimpleWhere }); mockSelectFn.mockReturnValue({ from: mockFromFn }); const { app } = await import('../../src/index.js'); const res = await app.request('/api/events/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Unauthorized write', allDay: false, start: '2026-06-15T10:00:00Z', end: '2026-06-15T11:00:00Z', calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/other@fm.com/Personal/', }), }); expect(res.status).toBe(403); }); }); // --------------------------------------------------------------------------- // PATCH /api/events/:uid/edit // --------------------------------------------------------------------------- describe('PATCH /api/events/:uid/edit', () => { beforeEach(() => { // Seed mock DB with a calendar event row that the edit will look up by uid. // The event's calendar must be owned by user 1 (dev user). mockDbRows = [ { 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, }, ]; // After BUG 1 + CR-01 fix, the edit lookup uses // .from(calendarEvents).innerJoin(calendars, ...).where(...).orderBy(...).limit(1). // Wire mockFromFn to expose innerJoin → where → orderBy → limit so the handler resolves mockDbRows. const mockInnerJoinWhere = vi.fn().mockReturnValue({ orderBy: vi.fn().mockReturnValue({ limit: vi.fn().mockImplementation(() => Promise.resolve(mockDbRows)), }), }); const mockInnerJoin = vi.fn().mockReturnValue({ where: mockInnerJoinWhere }); mockFromFn.mockReturnValue({ innerJoin: mockInnerJoin }); mockSelectFn.mockReturnValue({ from: mockFromFn }); }); it('returns 202 and inserts an outbox row with etag for a valid edit request', 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); }); // CR-01 (iteration 2): edit-as-move must carry the source event's RRULE through // to the create row so a moved recurring series does not silently collapse into a // single occurrence. The edit payload omits `recurrence`; the route must extract the // RRULE from the source event's rawVevent and stash it on the create payload as // `_preservedRrule` so the worker re-applies it. it('CR-01: move of a recurring event stashes the source RRULE on the create outbox row', async () => { // Seed a recurring source event (RRULE:FREQ=WEEKLY;BYDAY=MO) on the user's calendar. mockDbRows = [ { 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, rawVevent: SAMPLE_VEVENT_RECURRING_TIMED, }, ]; const mockInnerJoinWhere = vi.fn().mockReturnValue({ orderBy: vi.fn().mockReturnValue({ limit: vi.fn().mockImplementation(() => Promise.resolve(mockDbRows)), }), }); const mockInnerJoin = vi.fn().mockReturnValue({ where: mockInnerJoinWhere }); mockFromFn.mockReturnValue({ innerJoin: mockInnerJoin }); mockSelectFn.mockReturnValue({ from: mockFromFn }); 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: 'Moved event', allDay: false, start: '2026-06-15T10:00:00Z', end: '2026-06-15T11:00:00Z', // Different calendarUrl than the source → triggers the edit-as-move path (D-04) calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Family/', }), }); expect(res.status).toBe(202); // The move inserts a delete+create pair inside a transaction. Find the create row's // payload and assert the preserved RRULE survived. const createInsertCall = mockInsertValuesFn.mock.calls.find((call) => { const arg = call[0] as { operation?: string; payload?: string }; return arg?.operation === 'create'; }); expect(createInsertCall).toBeDefined(); const createArg = createInsertCall![0] as { payload: string }; const parsed = JSON.parse(createArg.payload) as { _preservedRrule?: string }; expect(parsed._preservedRrule).toBe('FREQ=WEEKLY;BYDAY=MO'); }); }); // --------------------------------------------------------------------------- // DELETE /api/events/:uid // --------------------------------------------------------------------------- describe('DELETE /api/events/:uid', () => { beforeEach(() => { // Seed a matching event row mockDbRows = [ { 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, }, ]; // After BUG 1 + CR-01 fix, the delete lookup uses // .from(calendarEvents).innerJoin(calendars, ...).where(...).orderBy(...).limit(1). // Wire mockFromFn to expose innerJoin → where → orderBy → limit so the handler resolves mockDbRows. const mockInnerJoinWhere = vi.fn().mockReturnValue({ orderBy: vi.fn().mockReturnValue({ limit: vi.fn().mockImplementation(() => Promise.resolve(mockDbRows)), }), }); const mockInnerJoin = vi.fn().mockReturnValue({ where: mockInnerJoinWhere }); mockFromFn.mockReturnValue({ innerJoin: mockInnerJoin }); mockSelectFn.mockReturnValue({ from: mockFromFn }); }); it('returns 202 and inserts a delete outbox row', 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); }); }); // --------------------------------------------------------------------------- // GET /api/events/sync-status // --------------------------------------------------------------------------- describe('GET /api/events/sync-status', () => { it('returns the outbox status for a given uid (row found → pending)', async () => { // Seed a pending outbox row mockDbRows = [{ uid: 'uid-001@familysync', status: 'pending', lastError: null, userId: 1 }]; // Simple select chain: .from().where() returns the outbox row const mockOrderBy = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows)); const mockLimit = vi.fn().mockReturnValue(mockOrderBy); const mockSimpleWhere = vi.fn().mockReturnValue({ limit: mockLimit }); const _mockOrderByDirect = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows)); // Some implementations use .where().orderBy() or just .where() mockSimpleWhere.mockReturnValue({ limit: mockLimit, orderBy: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(mockDbRows) }), }); mockFromFn.mockReturnValue({ where: mockSimpleWhere }); mockSelectFn.mockReturnValue({ from: mockFromFn }); const { app } = await import('../../src/index.js'); const res = await app.request('/api/events/sync-status?uid=uid-001%40familysync'); expect(res.status).toBe(200); const body = (await res.json()) as { uid: string; status: string }; expect(body).toHaveProperty('uid'); expect(body).toHaveProperty('status'); expect(['pending', 'done', 'failed', 'dead']).toContain(body.status); }); it('returns status done when no outbox row exists for uid', async () => { mockDbRows = []; const mockSimpleWhere = vi.fn().mockReturnValue({ orderBy: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), }); mockFromFn.mockReturnValue({ where: mockSimpleWhere }); mockSelectFn.mockReturnValue({ from: mockFromFn }); const { app } = await import('../../src/index.js'); const res = await app.request('/api/events/sync-status?uid=uid-999%40familysync'); expect(res.status).toBe(200); const body = (await res.json()) as { uid: string; status: string }; expect(body.status).toBe('done'); }); // WR-04 (iteration 2): with multiple rows for one uid, the query must rank a // failed/dead row above a later done row so a silently-failed queued write is not // masked by a subsequent success. The handler returns the top-ranked row's status and // surfaces its error. The orderBy must include the status-priority CASE expression so // the ranking is enforced in SQL (the mock cannot run real ORDER BY). it('WR-04: prioritizes a dead row over a done row and surfaces its error', async () => { // Seed the row the priority-ordered query WOULD return first: the dead one. mockDbRows = [{ uid: 'uid-001@familysync', status: 'dead', lastError: 'boom', userId: 1 }]; const orderBySpy = vi.fn().mockReturnValue({ limit: vi.fn().mockImplementation(() => Promise.resolve(mockDbRows)), }); const mockSimpleWhere = vi.fn().mockReturnValue({ orderBy: orderBySpy }); mockFromFn.mockReturnValue({ where: mockSimpleWhere }); mockSelectFn.mockReturnValue({ from: mockFromFn }); const { app } = await import('../../src/index.js'); const res = await app.request('/api/events/sync-status?uid=uid-001%40familysync'); expect(res.status).toBe(200); const body = (await res.json()) as { uid: string; status: string; error?: string }; expect(body.status).toBe('dead'); expect(body.error).toBe('boom'); // The status-priority CASE expression must be part of the ORDER BY (WR-04). // Drizzle sql`` builds a SQL object whose static text lives in queryChunks; scan // those for the literal 'case' rather than JSON.stringify (the Drizzle structures // are circular and throw on serialization). expect(orderBySpy).toHaveBeenCalled(); const orderByArgs = orderBySpy.mock.calls[0] as Array<{ queryChunks?: unknown[] }>; const caseText = orderByArgs .flatMap((arg) => (Array.isArray(arg?.queryChunks) ? arg.queryChunks : [])) .map((chunk) => { // String chunks are { value: string[] } (StringChunk); join their values. const value = (chunk as { value?: unknown })?.value; return Array.isArray(value) ? value.join('') : ''; }) .join(' ') .toLowerCase(); expect(caseText).toContain('case'); }); }); // --------------------------------------------------------------------------- // CR-01 contract tests — canonical client payload shape (title/start/end) // // The PWA sends {title, start, end, allDay, recurrence} — the exact // CreateEventPayload shape from apps/pwa/src/api/client.ts:119-128. // These tests assert the server schema accepts that shape (202), NOT 400. // RED before Task 1 schema rename; GREEN after. // --------------------------------------------------------------------------- describe('CR-01: canonical client payload (title/start/end) accepted by server', () => { beforeEach(() => { // Seed a calendar row for calendar ownership check in create mockDbRows = [ { id: 1, url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', displayName: 'Default', color: '#4A90D9', userId: 1, isShared: false, }, ]; const mockSimpleWhere = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows)); mockFromFn.mockReturnValue({ where: mockSimpleWhere }); mockSelectFn.mockReturnValue({ from: mockFromFn }); }); it('POST /create with exact CreateEventPayload shape {title,start,end,allDay,recurrence} returns 202 not 400', async () => { // This mirrors the exact payload apps/pwa/src/api/client.ts CreateEventPayload sends const { app } = await import('../../src/index.js'); const res = await app.request('/api/events/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Team standup', allDay: false, start: '2026-06-15T10:00:00Z', end: '2026-06-15T10:30:00Z', recurrence: 'none', calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', }), }); expect(res.status).toBe(202); const body = (await res.json()) as { uid: string }; expect(body).toHaveProperty('uid'); }); it('PATCH /:uid/edit with exact CreateEventPayload shape {title,start,end,allDay,recurrence} returns 202 not 400', async () => { // Seed an event row for edit lookup mockDbRows = [ { uid: 'uid-cr01@familysync', etag: '"etag-cr01"', objectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/uid-cr01.ics', calendarId: 1, calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', userId: 1, }, ]; // Edit lookup uses innerJoin → where → orderBy → limit after BUG 1 + CR-01 fix const mockInnerJoinWhere = vi.fn().mockReturnValue({ orderBy: vi.fn().mockReturnValue({ limit: vi.fn().mockImplementation(() => Promise.resolve(mockDbRows)), }), }); const mockInnerJoin = vi.fn().mockReturnValue({ where: mockInnerJoinWhere }); mockFromFn.mockReturnValue({ innerJoin: mockInnerJoin }); mockSelectFn.mockReturnValue({ from: mockFromFn }); const { app } = await import('../../src/index.js'); const res = await app.request('/api/events/uid-cr01%40familysync/edit', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Updated standup', allDay: false, start: '2026-06-15T10:00:00Z', end: '2026-06-15T10:30:00Z', recurrence: 'weekly', calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', }), }); expect(res.status).toBe(202); }); }); // --------------------------------------------------------------------------- // CR-06 contract tests — real OIDC iss/sub → users.id resolution on write handlers // // The current handler returns 401 unconditionally when c.get('user') is null, // even if getAuth() returns a valid OIDC session. These tests assert: // - OIDC session (no dev bypass) → resolveUserId calls upsertUser → 202 (not 401) // - No session at all → 401 (still unauthenticated) // // RED before Task 2 resolveUserId async rewrite; GREEN after. // --------------------------------------------------------------------------- describe('CR-06: OIDC iss/sub → users.id resolution on write handlers', () => { beforeEach(() => { // Disable dev bypass — simulate production OIDC path devBypassInjectUser.active = false; // Seed a calendar row for the create endpoint's ownership check mockDbRows = [ { id: 1, url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', displayName: 'Default', color: '#4A90D9', userId: 42, // matches the upserted user id isShared: false, }, ]; const mockSimpleWhere = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows)); mockFromFn.mockReturnValue({ where: mockSimpleWhere }); mockSelectFn.mockReturnValue({ from: mockFromFn }); }); it('POST /create with valid OIDC session (no dev bypass) returns 202, not 401', async () => { // getAuth returns a real OIDC payload — upsertUser resolves user.id = 42 getAuthImpl.fn = () => ({ iss: 'https://auth.example.com', sub: 'sub-abc', email: 'user@example.com', }); mockUpsertUserFn.mockResolvedValue({ id: 42, oidcIss: 'https://auth.example.com', oidcSub: 'sub-abc', displayName: 'OIDC User', color: '#E8734A', }); const { app } = await import('../../src/index.js'); const res = await app.request('/api/events/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'OIDC event', allDay: false, start: '2026-06-15T10:00:00Z', end: '2026-06-15T10:30:00Z', recurrence: 'none', calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', }), }); // Handler must NOT return 401 — the OIDC session is valid and resolves a user expect(res.status).toBe(202); // upsertUser was called with the OIDC iss/sub expect(mockUpsertUserFn).toHaveBeenCalledWith( 'https://auth.example.com', 'sub-abc', 'user@example.com', ); }); it('POST /create with no session (getAuth returns null) returns 401', async () => { // getAuth returns null → no session; should return 401 getAuthImpl.fn = () => null; const { app } = await import('../../src/index.js'); const res = await app.request('/api/events/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Unauthenticated event', allDay: false, start: '2026-06-15T10:00:00Z', end: '2026-06-15T10:30:00Z', recurrence: 'none', }), }); expect(res.status).toBe(401); }); }); // --------------------------------------------------------------------------- // Regression: edit/delete lookups must join calendars table // // Uses the real Drizzle query builder (via vi.importActual) to produce SQL via // toSQL() — no DB connection needed. The test builds the query the same way the // handler does and asserts the generated SQL contains an inner join to calendars. // // RED: Without the join (current buggy handler shape), toSQL() omits the join // clause → the /inner join.*calendars/ assertion fails. // GREEN: After adding .innerJoin(calendars, ...) the SQL contains the join. // --------------------------------------------------------------------------- // 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; beforeEach(() => { mockDbRows = [seededRow]; // Wire from() → innerJoin() → where() → orderBy() → limit(); the innerJoin spy // proves the handler routes through the join rather than calling .where() directly // on from(). The CR-01 fix appends .orderBy(...).limit(1) after .where(...). const innerJoinWhere = vi.fn().mockReturnValue({ orderBy: vi.fn().mockReturnValue({ limit: vi.fn().mockImplementation(() => Promise.resolve(mockDbRows)), }), }); innerJoinSpy = vi.fn().mockReturnValue({ where: innerJoinWhere }); mockFromFn.mockReturnValue({ innerJoin: innerJoinSpy }); mockSelectFn.mockReturnValue({ from: mockFromFn }); }); 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(); }); 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(); }); }); // --------------------------------------------------------------------------- // GET /api/events/writable-calendars // --------------------------------------------------------------------------- describe('GET /api/events/writable-calendars', () => { it('returns calendars writable by the current user (own personal + shared isShared=1)', async () => { // Seed: user 1's personal + a shared calendar mockDbRows = [ { id: 1, url: 'https://caldav.fm/personal/', displayName: 'Personal', color: '#4A90D9', userId: 1, isShared: false, }, { id: 3, url: 'https://caldav.fm/family/', displayName: 'Family', color: '#F25C7A', userId: 1, isShared: true, }, ]; const mockSimpleWhere = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows)); mockFromFn.mockReturnValue({ where: mockSimpleWhere }); mockSelectFn.mockReturnValue({ from: mockFromFn }); const { app } = await import('../../src/index.js'); const res = await app.request('/api/events/writable-calendars'); expect(res.status).toBe(200); const body = (await res.json()) as { calendars: unknown[] }; expect(body).toHaveProperty('calendars'); expect(Array.isArray(body.calendars)).toBe(true); expect(body.calendars.length).toBe(2); }); it("does NOT return another member's personal calendar (D-03 / V4 access control)", async () => { // Seed: only user 1's personal — no other member's personal should leak through. // The DB query itself is scoped (WHERE userId=1 OR isShared=1) so a second member's // personal calendar (userId=2, isShared=false) never appears in the result set. mockDbRows = [ { id: 1, url: 'https://caldav.fm/personal/', displayName: 'Personal', color: '#4A90D9', userId: 1, isShared: false, }, ]; const mockSimpleWhere = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows)); mockFromFn.mockReturnValue({ where: mockSimpleWhere }); mockSelectFn.mockReturnValue({ from: mockFromFn }); const { app } = await import('../../src/index.js'); const res = await app.request('/api/events/writable-calendars'); expect(res.status).toBe(200); const body = (await res.json()) as { calendars: Array<{ url: string; displayName: string; color: string; isShared: boolean }>; }; // No calendar with a different userId and isShared=false should appear for (const cal of body.calendars) { // All returned calendars must expose the WritableCalendar shape expect(cal).toHaveProperty('url'); expect(cal).toHaveProperty('displayName'); expect(cal).toHaveProperty('color'); expect(cal).toHaveProperty('isShared'); } // Specifically: user 2's personal calendar (id=2, userId=2, isShared=false) is absent. // Since the DB mock only returns user 1's calendar, this is guaranteed by the query scope. expect(body.calendars.length).toBe(1); }); });