/** * 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. */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { SAMPLE_VEVENT_RECURRING_TIMED, SAMPLE_VEVENT_RECURRING_ALLDAY, } from '../helpers/db.js' // Mock @hono/oidc-auth so tests do not need a live Authelia instance. // The mock makes oidcAuthMiddleware a no-op passthrough. vi.mock('@hono/oidc-auth', () => ({ oidcAuthMiddleware: () => async (_c: unknown, next: () => Promise) => next(), processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) => c.json({ ok: true }), getAuth: () => null, })) // Configurable mock for the DB — tests can set mockDbRows before calling the route. 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 }) vi.mock('../../src/db/client.js', () => ({ db: { select: mockSelectFn, }, })) 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 }) 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') }) }) /** * 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. */ // 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 = [] }) describe('POST /api/events/create', () => { 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', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ summary: 'New event', allDay: false, dtstart: '2026-06-15T10:00:00Z', dtend: '2026-06-15T11:00:00Z', 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) }) 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' }), }) // 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 () => { 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: 'Unauthorized write', allDay: false, dtstart: '2026-06-15T10:00:00Z', dtend: '2026-06-15T11:00:00Z', 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) }) }) describe('PATCH /api/events/:uid/edit', () => { 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({ summary: 'Updated title', allDay: false, dtstart: '2026-06-15T10:00:00Z', dtend: '2026-06-15T11:00:00Z', calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', }), }) // RED: route not registered yet expect(res.status).toBe(202) }) }) describe('DELETE /api/events/:uid', () => { 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) }) }) describe('GET /api/events/sync-status', () => { it('returns the outbox status for a given uid', async () => { 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) }) }) describe('GET /api/events/writable-calendars', () => { it('returns calendars writable by the current user (own personal + shared isShared=1)', async () => { 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) }) 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. 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 for (const cal of body.calendars) { const isOwnedByUser = cal.userId !== undefined const isShared = cal.isShared === true expect(isOwnedByUser || isShared).toBe(true) } }) })