The displayName claim-preference logic (name → preferred_username → email → sub fallback) was duplicated verbatim in me.ts and events.ts resolveUserId. Extract it to auth/user.ts as deriveDisplayName and use it in both call sites, so the rule has one definition. Update the events.test.ts user.js mock to keep the real helper (spread importActual) while stubbing only upsertUser.
763 lines
34 KiB
TypeScript
763 lines
34 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.
|
|
*
|
|
* Write endpoint contracts (added Plan 03-03):
|
|
* 6. POST /create validates input, enqueues outbox row, returns 202.
|
|
* 7. POST /create with non-owned calendarUrl returns 403 (D-03 / V4).
|
|
* 8. PATCH /:uid/edit enqueues update outbox row, returns 202.
|
|
* 9. DELETE /:uid enqueues delete outbox row, returns 202.
|
|
* 10. GET /sync-status?uid= returns outbox status for the member's UID.
|
|
* 11. GET /writable-calendars returns member's own + shared calendars, never other member's personal.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import {
|
|
SAMPLE_VEVENT_RECURRING_TIMED,
|
|
SAMPLE_VEVENT_RECURRING_ALLDAY,
|
|
} from '../helpers/db.js'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Configurable getAuth and devBypass so individual tests can control the auth
|
|
// path without reloading the module. Uses vi.hoisted() to avoid TDZ issues
|
|
// (D-03-04-hoisting: vi.hoisted() required when test file has static imports of
|
|
// modules that vi.mock() references in their factory callbacks).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// getAuthImpl: default returns null (unauthenticated); CR-06 OIDC tests override it.
|
|
const { getAuthImpl, devBypassInjectUser } = vi.hoisted(() => ({
|
|
getAuthImpl: { fn: null as (() => unknown) | null },
|
|
devBypassInjectUser: { active: true }, // true = inject dev user; false = passthrough
|
|
}))
|
|
|
|
// Mock @hono/oidc-auth so tests do not need a live Authelia instance.
|
|
// The mock makes oidcAuthMiddleware a no-op passthrough.
|
|
// getAuth delegates to getAuthImpl.fn so per-test overrides work at call time.
|
|
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: () => (getAuthImpl.fn ? getAuthImpl.fn() : null),
|
|
}))
|
|
|
|
// Mock upsertUser for the OIDC path — CR-06 tests override mockUpsertUser.fn.
|
|
const mockUpsertUserFn = vi.fn()
|
|
vi.mock('../../src/auth/user.js', async (importActual) => {
|
|
// Keep deriveDisplayName (a pure helper) real so the displayName the handler
|
|
// passes to upsertUser reflects production claim-preference logic; stub only
|
|
// upsertUser (the DB-touching function).
|
|
const actual = await importActual<typeof import('../../src/auth/user.js')>()
|
|
return {
|
|
...actual,
|
|
upsertUser: (...args: unknown[]) => mockUpsertUserFn(...args),
|
|
}
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Configurable mock for the DB.
|
|
// ALL db methods used by the events router are mocked here so vi.mock can
|
|
// reference them. Vitest hoists vi.mock to the top of the file — the factory
|
|
// must refer to variables that are mutable (reassigned in beforeEach) via
|
|
// wrapper fns rather than direct references to lets.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// --- select chain ---
|
|
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 })
|
|
|
|
// --- Simple select chain used by /sync-status and /writable-calendars (no joins) ---
|
|
// We intercept via mockSelectFn dispatcher — each test controls which path fires
|
|
// by configuring mockFromFn to return either join chain or where-only chain.
|
|
|
|
// --- insert chain ---
|
|
let mockInsertValuesFn = vi.fn().mockResolvedValue([{ insertId: 1 }])
|
|
const mockInsertFn = vi.fn().mockImplementation(() => ({ values: mockInsertValuesFn }))
|
|
|
|
// --- transaction ---
|
|
// Drizzle transaction receives a callback (tx => ...). The mock invokes the callback
|
|
// with a fake tx object that has insert: mockInsertFn so transactional inserts are counted.
|
|
const mockTransactionFn = vi.fn().mockImplementation(
|
|
async (cb: (tx: { insert: typeof mockInsertFn }) => Promise<void>) => {
|
|
await cb({ insert: mockInsertFn })
|
|
},
|
|
)
|
|
|
|
vi.mock('../../src/db/client.js', () => ({
|
|
db: {
|
|
select: mockSelectFn,
|
|
insert: mockInsertFn,
|
|
transaction: mockTransactionFn,
|
|
},
|
|
}))
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared state reset
|
|
// ---------------------------------------------------------------------------
|
|
beforeEach(() => {
|
|
mockDbRows = []
|
|
vi.clearAllMocks()
|
|
|
|
// Reset auth stubs to safe defaults for each test
|
|
getAuthImpl.fn = null // getAuth returns null (unauthenticated)
|
|
devBypassInjectUser.active = true // inject dev user (most tests use dev bypass)
|
|
mockUpsertUserFn.mockResolvedValue({ id: 42, oidcIss: 'https://auth.example.com', oidcSub: 'sub-abc', displayName: 'OIDC User', color: '#E8734A' })
|
|
|
|
// Restore select chain (vi.clearAllMocks wipes mockImplementation)
|
|
mockWhereFn.mockImplementation(() => Promise.resolve(mockDbRows))
|
|
mockInnerJoin2Fn.mockReturnValue({ where: mockWhereFn })
|
|
mockInnerJoin1Fn.mockReturnValue({ innerJoin: mockInnerJoin2Fn })
|
|
mockFromFn.mockReturnValue({ innerJoin: mockInnerJoin1Fn })
|
|
mockSelectFn.mockReturnValue({ from: mockFromFn })
|
|
|
|
// Restore insert chain
|
|
mockInsertValuesFn = vi.fn().mockResolvedValue([{ insertId: 1 }])
|
|
mockInsertFn.mockImplementation(() => ({ values: mockInsertValuesFn }))
|
|
|
|
// Restore transaction
|
|
mockTransactionFn.mockImplementation(
|
|
async (cb: (tx: { insert: typeof mockInsertFn }) => Promise<void>) => {
|
|
await cb({ insert: mockInsertFn })
|
|
},
|
|
)
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GET /api/events — windowed read
|
|
// ---------------------------------------------------------------------------
|
|
describe('GET /api/events', () => {
|
|
beforeEach(() => {
|
|
// The windowed GET uses the 3-join select chain; ensure it is wired.
|
|
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')
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Write endpoints + sync-status + writable-calendars (Plan 03-03)
|
|
//
|
|
// CAL-04/05/06, D-03, D-05, D-08, D-09
|
|
//
|
|
// These tests extend the existing GET /api/events suite.
|
|
// They were RED in Plan 01 and turn GREEN in Plan 03-03.
|
|
//
|
|
// Auth: the top-level vi.mock for @hono/oidc-auth sets getAuth → null.
|
|
// The dev-bypass (DEV_AUTH_BYPASS env) is NOT active in the test environment,
|
|
// so the routes fall through to getAuth(c) which returns null → 401.
|
|
//
|
|
// HOWEVER: for write routes in test, we need an authenticated user. The test
|
|
// environment sets DEV_AUTH_BYPASS=true via the vitest.config or the route's
|
|
// dev-bypass reads c.get('user'). But since DEV_AUTH_BYPASS env is not set in
|
|
// the test runner, c.get('user') will be undefined and getAuth(c) returns null.
|
|
//
|
|
// Resolution: the write route implementation falls back gracefully — when both
|
|
// devUser and getAuth return null/undefined, it returns 401. But the write tests
|
|
// do NOT check for 401; they expect 202/400/403. Therefore:
|
|
// - We need a way to inject a user. The cleanest approach is to mock the
|
|
// devBypass module so that c.get('user') returns DEV_USER in tests.
|
|
// - We mock '../auth/devBypass.js' to inject a fixed user into context.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Mock devAuthBypass: delegates to devBypassInjectUser.active flag so CR-06 tests
|
|
// can simulate the OIDC path by setting devBypassInjectUser.active = false.
|
|
vi.mock('../../src/auth/devBypass.js', async (importOriginal) => {
|
|
const original = await importOriginal<typeof import('../../src/auth/devBypass.js')>()
|
|
return {
|
|
...original,
|
|
devAuthBypass: () => async (c: { set: (k: string, v: unknown) => void }, next: () => Promise<void>) => {
|
|
if (devBypassInjectUser.active) {
|
|
c.set('user', { id: 1, oidcIss: 'dev', oidcSub: 'dev-user', displayName: 'Dev User', color: '#4A90D9' })
|
|
}
|
|
await next()
|
|
},
|
|
}
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// POST /api/events/create
|
|
// ---------------------------------------------------------------------------
|
|
describe('POST /api/events/create', () => {
|
|
beforeEach(() => {
|
|
// create: select chain used to look up calendar ownership.
|
|
// Default: mockDbRows=[] means no owned calendar found → 403 for calendarUrl given.
|
|
// To get 202, we need to either supply no calendarUrl (uses default) or supply a
|
|
// calendarUrl that matches a calendar row. For simplicity: mock a calendar row as
|
|
// owned by user 1 matching the test calendarUrl.
|
|
mockDbRows = [
|
|
{
|
|
id: 1,
|
|
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
|
displayName: 'Default',
|
|
color: '#4A90D9',
|
|
userId: 1,
|
|
isShared: false,
|
|
},
|
|
]
|
|
// Rewire select chain for simple where-only queries (no joins needed for calendar lookup).
|
|
const mockSimpleWhere = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows))
|
|
mockFromFn.mockReturnValue({ where: mockSimpleWhere })
|
|
mockSelectFn.mockReturnValue({ from: mockFromFn })
|
|
})
|
|
|
|
it('returns 202 and inserts a pending outbox row for a valid create request', 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({
|
|
title: 'New event',
|
|
allDay: false,
|
|
start: '2026-06-15T10:00:00Z',
|
|
end: '2026-06-15T11:00:00Z',
|
|
calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
|
}),
|
|
})
|
|
expect(res.status).toBe(202)
|
|
const body = await res.json() as { uid: string }
|
|
expect(body).toHaveProperty('uid')
|
|
expect(typeof body.uid).toBe('string')
|
|
})
|
|
|
|
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' }),
|
|
})
|
|
expect(res.status).toBe(400)
|
|
})
|
|
|
|
it('returns 403 when writing to a calendar not owned by the user (D-03 / V4)', async () => {
|
|
// Override: no owned calendar matches this URL
|
|
mockDbRows = []
|
|
const mockSimpleWhere = vi.fn().mockImplementation(() => Promise.resolve([]))
|
|
mockFromFn.mockReturnValue({ where: mockSimpleWhere })
|
|
mockSelectFn.mockReturnValue({ from: mockFromFn })
|
|
|
|
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({
|
|
title: 'Unauthorized write',
|
|
allDay: false,
|
|
start: '2026-06-15T10:00:00Z',
|
|
end: '2026-06-15T11:00:00Z',
|
|
calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/other@fm.com/Personal/',
|
|
}),
|
|
})
|
|
expect(res.status).toBe(403)
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PATCH /api/events/:uid/edit
|
|
// ---------------------------------------------------------------------------
|
|
describe('PATCH /api/events/:uid/edit', () => {
|
|
beforeEach(() => {
|
|
// Seed mock DB with a calendar event row that the edit will look up by uid.
|
|
// The event's calendar must be owned by user 1 (dev user).
|
|
mockDbRows = [
|
|
{
|
|
uid: 'uid-001@familysync',
|
|
etag: '"etag-abc"',
|
|
objectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/uid-001.ics',
|
|
calendarId: 1,
|
|
calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
|
userId: 1,
|
|
},
|
|
]
|
|
// After BUG 1 fix, the edit lookup uses .from(calendarEvents).innerJoin(calendars, ...).where(...)
|
|
// Wire mockFromFn to expose innerJoin → where so the handler resolves mockDbRows.
|
|
const mockInnerJoinWhere = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows))
|
|
const mockInnerJoin = vi.fn().mockReturnValue({ where: mockInnerJoinWhere })
|
|
mockFromFn.mockReturnValue({ innerJoin: mockInnerJoin })
|
|
mockSelectFn.mockReturnValue({ from: mockFromFn })
|
|
})
|
|
|
|
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({
|
|
title: 'Updated title',
|
|
allDay: false,
|
|
start: '2026-06-15T10:00:00Z',
|
|
end: '2026-06-15T11:00:00Z',
|
|
calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
|
}),
|
|
})
|
|
expect(res.status).toBe(202)
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DELETE /api/events/:uid
|
|
// ---------------------------------------------------------------------------
|
|
describe('DELETE /api/events/:uid', () => {
|
|
beforeEach(() => {
|
|
// Seed a matching event row
|
|
mockDbRows = [
|
|
{
|
|
uid: 'uid-001@familysync',
|
|
etag: '"etag-abc"',
|
|
objectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/uid-001.ics',
|
|
calendarId: 1,
|
|
calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
|
userId: 1,
|
|
},
|
|
]
|
|
// After BUG 1 fix, the delete lookup uses .from(calendarEvents).innerJoin(calendars, ...).where(...)
|
|
// Wire mockFromFn to expose innerJoin → where so the handler resolves mockDbRows.
|
|
const mockInnerJoinWhere = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows))
|
|
const mockInnerJoin = vi.fn().mockReturnValue({ where: mockInnerJoinWhere })
|
|
mockFromFn.mockReturnValue({ innerJoin: mockInnerJoin })
|
|
mockSelectFn.mockReturnValue({ from: mockFromFn })
|
|
})
|
|
|
|
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',
|
|
})
|
|
expect(res.status).toBe(202)
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GET /api/events/sync-status
|
|
// ---------------------------------------------------------------------------
|
|
describe('GET /api/events/sync-status', () => {
|
|
it('returns the outbox status for a given uid (row found → pending)', async () => {
|
|
// Seed a pending outbox row
|
|
mockDbRows = [{ uid: 'uid-001@familysync', status: 'pending', lastError: null, userId: 1 }]
|
|
// Simple select chain: .from().where() returns the outbox row
|
|
const mockOrderBy = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows))
|
|
const mockLimit = vi.fn().mockReturnValue(mockOrderBy)
|
|
const mockSimpleWhere = vi.fn().mockReturnValue({ limit: mockLimit })
|
|
const mockOrderByDirect = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows))
|
|
// Some implementations use .where().orderBy() or just .where()
|
|
mockSimpleWhere.mockReturnValue({ limit: mockLimit, orderBy: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(mockDbRows) }) })
|
|
mockFromFn.mockReturnValue({ where: mockSimpleWhere })
|
|
mockSelectFn.mockReturnValue({ from: mockFromFn })
|
|
|
|
const { app } = await import('../../src/index.js')
|
|
const res = await app.request('/api/events/sync-status?uid=uid-001%40familysync')
|
|
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)
|
|
})
|
|
|
|
it('returns status done when no outbox row exists for uid', async () => {
|
|
mockDbRows = []
|
|
const mockSimpleWhere = vi.fn().mockReturnValue({
|
|
orderBy: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }),
|
|
})
|
|
mockFromFn.mockReturnValue({ where: mockSimpleWhere })
|
|
mockSelectFn.mockReturnValue({ from: mockFromFn })
|
|
|
|
const { app } = await import('../../src/index.js')
|
|
const res = await app.request('/api/events/sync-status?uid=uid-999%40familysync')
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json() as { uid: string; status: string }
|
|
expect(body.status).toBe('done')
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CR-01 contract tests — canonical client payload shape (title/start/end)
|
|
//
|
|
// The PWA sends {title, start, end, allDay, recurrence} — the exact
|
|
// CreateEventPayload shape from apps/pwa/src/api/client.ts:119-128.
|
|
// These tests assert the server schema accepts that shape (202), NOT 400.
|
|
// RED before Task 1 schema rename; GREEN after.
|
|
// ---------------------------------------------------------------------------
|
|
describe('CR-01: canonical client payload (title/start/end) accepted by server', () => {
|
|
beforeEach(() => {
|
|
// Seed a calendar row for calendar ownership check in create
|
|
mockDbRows = [
|
|
{
|
|
id: 1,
|
|
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
|
displayName: 'Default',
|
|
color: '#4A90D9',
|
|
userId: 1,
|
|
isShared: false,
|
|
},
|
|
]
|
|
const mockSimpleWhere = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows))
|
|
mockFromFn.mockReturnValue({ where: mockSimpleWhere })
|
|
mockSelectFn.mockReturnValue({ from: mockFromFn })
|
|
})
|
|
|
|
it('POST /create with exact CreateEventPayload shape {title,start,end,allDay,recurrence} returns 202 not 400', async () => {
|
|
// This mirrors the exact payload apps/pwa/src/api/client.ts CreateEventPayload sends
|
|
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({
|
|
title: 'Team standup',
|
|
allDay: false,
|
|
start: '2026-06-15T10:00:00Z',
|
|
end: '2026-06-15T10:30:00Z',
|
|
recurrence: 'none',
|
|
calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
|
}),
|
|
})
|
|
expect(res.status).toBe(202)
|
|
const body = await res.json() as { uid: string }
|
|
expect(body).toHaveProperty('uid')
|
|
})
|
|
|
|
it('PATCH /:uid/edit with exact CreateEventPayload shape {title,start,end,allDay,recurrence} returns 202 not 400', async () => {
|
|
// Seed an event row for edit lookup
|
|
mockDbRows = [
|
|
{
|
|
uid: 'uid-cr01@familysync',
|
|
etag: '"etag-cr01"',
|
|
objectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/uid-cr01.ics',
|
|
calendarId: 1,
|
|
calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
|
userId: 1,
|
|
},
|
|
]
|
|
// Edit lookup uses innerJoin after BUG 1 fix
|
|
const mockInnerJoinWhere = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows))
|
|
const mockInnerJoin = vi.fn().mockReturnValue({ where: mockInnerJoinWhere })
|
|
mockFromFn.mockReturnValue({ innerJoin: mockInnerJoin })
|
|
mockSelectFn.mockReturnValue({ from: mockFromFn })
|
|
|
|
const { app } = await import('../../src/index.js')
|
|
const res = await app.request('/api/events/uid-cr01%40familysync/edit', {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
title: 'Updated standup',
|
|
allDay: false,
|
|
start: '2026-06-15T10:00:00Z',
|
|
end: '2026-06-15T10:30:00Z',
|
|
recurrence: 'weekly',
|
|
calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
|
}),
|
|
})
|
|
expect(res.status).toBe(202)
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CR-06 contract tests — real OIDC iss/sub → users.id resolution on write handlers
|
|
//
|
|
// The current handler returns 401 unconditionally when c.get('user') is null,
|
|
// even if getAuth() returns a valid OIDC session. These tests assert:
|
|
// - OIDC session (no dev bypass) → resolveUserId calls upsertUser → 202 (not 401)
|
|
// - No session at all → 401 (still unauthenticated)
|
|
//
|
|
// RED before Task 2 resolveUserId async rewrite; GREEN after.
|
|
// ---------------------------------------------------------------------------
|
|
describe('CR-06: OIDC iss/sub → users.id resolution on write handlers', () => {
|
|
beforeEach(() => {
|
|
// Disable dev bypass — simulate production OIDC path
|
|
devBypassInjectUser.active = false
|
|
|
|
// Seed a calendar row for the create endpoint's ownership check
|
|
mockDbRows = [
|
|
{
|
|
id: 1,
|
|
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
|
displayName: 'Default',
|
|
color: '#4A90D9',
|
|
userId: 42, // matches the upserted user id
|
|
isShared: false,
|
|
},
|
|
]
|
|
const mockSimpleWhere = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows))
|
|
mockFromFn.mockReturnValue({ where: mockSimpleWhere })
|
|
mockSelectFn.mockReturnValue({ from: mockFromFn })
|
|
})
|
|
|
|
it('POST /create with valid OIDC session (no dev bypass) returns 202, not 401', async () => {
|
|
// getAuth returns a real OIDC payload — upsertUser resolves user.id = 42
|
|
getAuthImpl.fn = () => ({ iss: 'https://auth.example.com', sub: 'sub-abc', email: 'user@example.com' })
|
|
mockUpsertUserFn.mockResolvedValue({ id: 42, oidcIss: 'https://auth.example.com', oidcSub: 'sub-abc', displayName: 'OIDC User', color: '#E8734A' })
|
|
|
|
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({
|
|
title: 'OIDC event',
|
|
allDay: false,
|
|
start: '2026-06-15T10:00:00Z',
|
|
end: '2026-06-15T10:30:00Z',
|
|
recurrence: 'none',
|
|
calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
|
}),
|
|
})
|
|
// Handler must NOT return 401 — the OIDC session is valid and resolves a user
|
|
expect(res.status).toBe(202)
|
|
// upsertUser was called with the OIDC iss/sub
|
|
expect(mockUpsertUserFn).toHaveBeenCalledWith('https://auth.example.com', 'sub-abc', 'user@example.com')
|
|
})
|
|
|
|
it('POST /create with no session (getAuth returns null) returns 401', async () => {
|
|
// getAuth returns null → no session; should return 401
|
|
getAuthImpl.fn = () => null
|
|
|
|
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({
|
|
title: 'Unauthenticated event',
|
|
allDay: false,
|
|
start: '2026-06-15T10:00:00Z',
|
|
end: '2026-06-15T10:30:00Z',
|
|
recurrence: 'none',
|
|
}),
|
|
})
|
|
expect(res.status).toBe(401)
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Regression: edit/delete lookups must join calendars table
|
|
//
|
|
// Uses the real Drizzle query builder (via vi.importActual) to produce SQL via
|
|
// toSQL() — no DB connection needed. The test builds the query the same way the
|
|
// handler does and asserts the generated SQL contains an inner join to calendars.
|
|
//
|
|
// RED: Without the join (current buggy handler shape), toSQL() omits the join
|
|
// clause → the /inner join.*calendars/ assertion fails.
|
|
// GREEN: After adding .innerJoin(calendars, ...) the SQL contains the join.
|
|
// ---------------------------------------------------------------------------
|
|
// Regression: BUG 1 — the edit/delete lookups select calendars.url/userId but
|
|
// must JOIN calendars to do so. Without the join Drizzle throws at query-build
|
|
// → the handler's catch returns 503 (delete dialog never closes).
|
|
//
|
|
// This couples to the HANDLER, not a query the test rebuilds. The select-chain
|
|
// mock exposes from() → innerJoin() → where(); a no-join handler that calls
|
|
// .from().where() hits an undefined .where() → throws → 503, AND never invokes
|
|
// the innerJoin spy. So both assertions go RED if the join is removed from
|
|
// events.ts; they pass only because the handler actually joins.
|
|
describe('regression: edit/delete lookups join calendars (BUG 1)', () => {
|
|
const seededRow = {
|
|
uid: 'uid-001@familysync',
|
|
etag: '"etag-abc"',
|
|
objectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/uid-001.ics',
|
|
calendarId: 1,
|
|
calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
|
userId: 1,
|
|
}
|
|
let innerJoinSpy: ReturnType<typeof vi.fn>
|
|
|
|
beforeEach(() => {
|
|
mockDbRows = [seededRow]
|
|
// Wire from() → innerJoin() → where(); the innerJoin spy proves the handler
|
|
// routes through the join rather than calling .where() directly on from().
|
|
const innerJoinWhere = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows))
|
|
innerJoinSpy = vi.fn().mockReturnValue({ where: innerJoinWhere })
|
|
mockFromFn.mockReturnValue({ innerJoin: innerJoinSpy })
|
|
mockSelectFn.mockReturnValue({ from: mockFromFn })
|
|
})
|
|
|
|
it('PATCH /:uid/edit joins calendars (no 503) and invokes innerJoin', 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({
|
|
title: 'Updated title',
|
|
allDay: false,
|
|
start: '2026-06-15T10:00:00Z',
|
|
end: '2026-06-15T11:00:00Z',
|
|
calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
|
}),
|
|
})
|
|
expect(res.status).toBe(202)
|
|
expect(innerJoinSpy).toHaveBeenCalled()
|
|
})
|
|
|
|
it('DELETE /:uid joins calendars (no 503) and invokes innerJoin', async () => {
|
|
const { app } = await import('../../src/index.js')
|
|
const res = await app.request('/api/events/uid-001%40familysync', {
|
|
method: 'DELETE',
|
|
})
|
|
expect(res.status).toBe(202)
|
|
expect(innerJoinSpy).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GET /api/events/writable-calendars
|
|
// ---------------------------------------------------------------------------
|
|
describe('GET /api/events/writable-calendars', () => {
|
|
it('returns calendars writable by the current user (own personal + shared isShared=1)', async () => {
|
|
// Seed: user 1's personal + a shared calendar
|
|
mockDbRows = [
|
|
{ id: 1, url: 'https://caldav.fm/personal/', displayName: 'Personal', color: '#4A90D9', userId: 1, isShared: false },
|
|
{ id: 3, url: 'https://caldav.fm/family/', displayName: 'Family', color: '#F25C7A', userId: 1, isShared: true },
|
|
]
|
|
const mockSimpleWhere = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows))
|
|
mockFromFn.mockReturnValue({ where: mockSimpleWhere })
|
|
mockSelectFn.mockReturnValue({ from: mockFromFn })
|
|
|
|
const { app } = await import('../../src/index.js')
|
|
const res = await app.request('/api/events/writable-calendars')
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json() as { calendars: unknown[] }
|
|
expect(body).toHaveProperty('calendars')
|
|
expect(Array.isArray(body.calendars)).toBe(true)
|
|
expect(body.calendars.length).toBe(2)
|
|
})
|
|
|
|
it('does NOT return another member\'s personal calendar (D-03 / V4 access control)', async () => {
|
|
// Seed: only user 1's personal — no other member's personal should leak through.
|
|
// The DB query itself is scoped (WHERE userId=1 OR isShared=1) so a second member's
|
|
// personal calendar (userId=2, isShared=false) never appears in the result set.
|
|
mockDbRows = [
|
|
{ id: 1, url: 'https://caldav.fm/personal/', displayName: 'Personal', color: '#4A90D9', userId: 1, isShared: false },
|
|
]
|
|
const mockSimpleWhere = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows))
|
|
mockFromFn.mockReturnValue({ where: mockSimpleWhere })
|
|
mockSelectFn.mockReturnValue({ from: mockFromFn })
|
|
|
|
const { app } = await import('../../src/index.js')
|
|
const res = await app.request('/api/events/writable-calendars')
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json() as { calendars: Array<{ url: string; displayName: string; color: string; isShared: boolean }> }
|
|
// No calendar with a different userId and isShared=false should appear
|
|
for (const cal of body.calendars) {
|
|
// All returned calendars must expose the WritableCalendar shape
|
|
expect(cal).toHaveProperty('url')
|
|
expect(cal).toHaveProperty('displayName')
|
|
expect(cal).toHaveProperty('color')
|
|
expect(cal).toHaveProperty('isShared')
|
|
}
|
|
// Specifically: user 2's personal calendar (id=2, userId=2, isShared=false) is absent.
|
|
// Since the DB mock only returns user 1's calendar, this is guaranteed by the query scope.
|
|
expect(body.calendars.length).toBe(1)
|
|
})
|
|
})
|