diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index 6daaaef..a2ce837 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -88,10 +88,17 @@ eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => { .innerJoin(users, eq(calendars.userId, users.id)) .where( or( - // Recurring masters: may have occurrences inside the window even if dtstartUtc is old + // Recurring masters: may have occurrences inside the window even if dtstartUtc is old. + // Two sub-cases: + // (a) Timed recurring masters: dtstartUtc < windowEnd + // (b) All-day recurring masters: dtstartUtc is NULL (DATE-only), use dtstartDate < end + // A NULL dtstartUtc causes the timed comparison to be NULL/false, so (b) carries it. and( sql`${calendarEvents.hasRrule} = 1`, - sql`${calendarEvents.dtstartUtc} < ${windowEndDate}`, + or( + sql`${calendarEvents.dtstartUtc} < ${windowEndDate}`, + sql`${calendarEvents.dtstartDate} < ${end}`, + ), ), // Non-recurring timed events: dtstartUtc falls in [windowStart, windowEnd) and( diff --git a/apps/api/tests/routes/events.test.ts b/apps/api/tests/routes/events.test.ts index d26dd96..3fd3bfc 100644 --- a/apps/api/tests/routes/events.test.ts +++ b/apps/api/tests/routes/events.test.ts @@ -1,13 +1,21 @@ /** - * Tests for GET /api/events — windowed endpoint (Plan 02 GREEN state). + * 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: [] } each with color + isShared fields when DB is empty + * 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 } from 'vitest' +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. @@ -17,23 +25,32 @@ vi.mock('@hono/oidc-auth', () => ({ getAuth: () => null, })) -// Mock DB to avoid real DB connections in unit tests +// 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: vi.fn().mockReturnValue({ - from: vi.fn().mockReturnValue({ - innerJoin: vi.fn().mockReturnValue({ - innerJoin: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([]), - }), - }), - where: vi.fn().mockResolvedValue([]), - }), - }), + 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') @@ -56,8 +73,6 @@ describe('GET /api/events', () => { it('returns occurrences with color and isShared 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') - // Route not yet evolved — currently returns raw events without windowing or color join. - // This test will pass once Plan 02 implements the windowed query + expansion. expect(res.status).toBe(200) const body = await res.json() as { occurrences: Array<{ color: string; isShared: boolean }> } expect(body).toHaveProperty('occurrences') @@ -70,4 +85,70 @@ describe('GET /api/events', () => { expect(typeof occ.isShared).toBe('boolean') } }) + + 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', + }, + ] + + 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 }> } + 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) + } + }) + + 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', + }, + ] + + 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') + }) })