/** * 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') }) })