- vevent.test.ts: DTSTART UTC 'Z' for timed, DATE for all-day (D-13), RRULE (CAL-04/07) - write.test.ts: createCalendarEvent uid.ics filename, updateCalendarEvent/deleteCalendarEvent etag/If-Match shapes (CAL-04/05/06, D-08) - outboxWorker.test.ts: pending→done on 204, pending→failed on 412 (no retry), pending→backoff on 500, pending→dead at MAX_ATTEMPTS, edit-as-move create-before-delete ordering (D-04/D-07/D-08) - events.test.ts (extended): POST /create 202+outbox row, PATCH /edit 202+etag, DELETE /:uid 202, GET /sync-status, GET /writable-calendars D-03 access control, 403 unauthorized calendar (V4) - InstallPrompt.test.tsx: isIOSSafariNonStandalone UA detection, useAndroidInstallPrompt canInstall lifecycle (PWA-01/PWA-02) All tests fail RED — implementation modules do not exist yet
314 lines
13 KiB
TypeScript
314 lines
13 KiB
TypeScript
/**
|
|
* 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<void>) => 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<typeof vi.fn>
|
|
let mockInsert: ReturnType<typeof vi.fn>
|
|
|
|
// 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)
|
|
}
|
|
})
|
|
})
|