fix(02): include all-day recurring masters in events route pre-filter

- Old filter: hasRrule=1 AND dtstartUtc < windowEnd
  All-day recurring masters have dtstartUtc=NULL so the comparison evaluates
  to NULL/false — 11 such rows in live cache were never returned
- New filter: hasRrule=1 AND (dtstartUtc < windowEnd OR dtstartDate < end)
  The OR covers all-day masters whose only date column is dtstartDate (DATE)
- expandOccurrences already does precise per-occurrence window checks, so
  over-selecting a master on the DATE path is safe
- Extend events.test.ts: assert timed recurring master (dtstart 2024) returns
  occurrences in 2026 window; assert all-day recurring master (dtstartDate 2024,
  dtstartUtc NULL) returns its 2026-06-15 occurrence
This commit is contained in:
Lucas Berger
2026-06-05 14:50:27 -04:00
parent f70496871a
commit 1f0b9546a8
2 changed files with 106 additions and 18 deletions
+9 -2
View File
@@ -88,10 +88,17 @@ eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => {
.innerJoin(users, eq(calendars.userId, users.id)) .innerJoin(users, eq(calendars.userId, users.id))
.where( .where(
or( 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( and(
sql`${calendarEvents.hasRrule} = 1`, 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) // Non-recurring timed events: dtstartUtc falls in [windowStart, windowEnd)
and( and(
+97 -16
View File
@@ -1,13 +1,21 @@
/** /**
* Tests for GET /api/events — windowed endpoint (Plan 02 GREEN state). * Tests for GET /api/events — windowed endpoint.
* *
* Contracts: * Contracts:
* 1. Missing/malformed start or end params → 400 (input validation guard) * 1. Missing/malformed start or end params → 400 (input validation guard)
* 2. Window wider than 90 days → 400 (DoS cap) * 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. // Mock @hono/oidc-auth so tests do not need a live Authelia instance.
// The mock makes oidcAuthMiddleware a no-op passthrough. // The mock makes oidcAuthMiddleware a no-op passthrough.
@@ -17,23 +25,32 @@ vi.mock('@hono/oidc-auth', () => ({
getAuth: () => null, 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', () => ({ vi.mock('../../src/db/client.js', () => ({
db: { db: {
select: vi.fn().mockReturnValue({ select: mockSelectFn,
from: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
}),
where: vi.fn().mockResolvedValue([]),
}),
}),
}, },
})) }))
describe('GET /api/events', () => { 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 () => { it('returns 400 when start param is missing', async () => {
const { app } = await import('../../src/index.js') 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 () => { it('returns occurrences with color and isShared fields for valid window', async () => {
const { app } = await import('../../src/index.js') const { app } = await import('../../src/index.js')
const res = await app.request('/api/events?start=2026-06-01&end=2026-07-01') 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) expect(res.status).toBe(200)
const body = await res.json() as { occurrences: Array<{ color: string; isShared: boolean }> } const body = await res.json() as { occurrences: Array<{ color: string; isShared: boolean }> }
expect(body).toHaveProperty('occurrences') expect(body).toHaveProperty('occurrences')
@@ -70,4 +85,70 @@ describe('GET /api/events', () => {
expect(typeof occ.isShared).toBe('boolean') 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')
})
}) })