BUG 1: PATCH /:uid/edit and DELETE /:uid selected calendars.url/userId from .from(calendarEvents) with no join, causing Drizzle to throw at toSQL() time → 503. Added .innerJoin(calendars, ...) to both lookups, mirroring the working GET / join idiom. - Updated PATCH + DELETE beforeEach mocks to route through innerJoin→where - Updated CR-01 PATCH test mock similarly - Added regression: edit/delete lookups join calendars describe block with toSQL() assertions using vi.importActual (real drizzle, no DB needed) - All 21 tests pass; typecheck clean
761 lines
34 KiB
TypeScript
761 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', () => ({
|
|
upsertUser: (...args: unknown[]) => mockUpsertUserFn(...args),
|
|
COLOR_PALETTE: ['#4A90D9', '#E8734A', '#5BA85A', '#9B6DC5', '#E8A840', '#3AAFA9'],
|
|
}))
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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.
|
|
// ---------------------------------------------------------------------------
|
|
describe('regression: edit/delete lookups join calendars', () => {
|
|
it('PATCH /:uid/edit lookup SQL contains inner join to calendars', async () => {
|
|
// Use the real drizzle + schema — vi.importActual bypasses the vi.mock for db/client.
|
|
// drizzle does NOT need a live DB to produce SQL via toSQL().
|
|
const { drizzle } = await vi.importActual<typeof import('drizzle-orm/mysql2')>('drizzle-orm/mysql2')
|
|
const { eq } = await vi.importActual<typeof import('drizzle-orm')>('drizzle-orm')
|
|
const { calendarEvents, calendars } = await vi.importActual<typeof import('../../src/db/schema.js')>('../../src/db/schema.js')
|
|
|
|
// Construct a throwaway drizzle instance — client is never called by toSQL()
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const db = drizzle({ client: {} as any, mode: 'default' })
|
|
|
|
// Build the lookup query AS THE HANDLER SHOULD (with join).
|
|
// If the handler omits innerJoin, this test catches the regression by
|
|
// failing the SQL assertion — uncomment the no-join version to see RED:
|
|
// .from(calendarEvents)
|
|
// .where(eq(calendarEvents.uid, 'test-uid')) ← no join → toSQL omits join clause → FAILS
|
|
const lookupQuery = db
|
|
.select({
|
|
uid: calendarEvents.uid,
|
|
etag: calendarEvents.etag,
|
|
objectUrl: calendarEvents.objectUrl,
|
|
calendarId: calendarEvents.calendarId,
|
|
calendarUrl: calendars.url,
|
|
userId: calendars.userId,
|
|
})
|
|
.from(calendarEvents)
|
|
.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
|
|
.where(eq(calendarEvents.uid, 'test-uid'))
|
|
|
|
const { sql: generatedSql } = lookupQuery.toSQL()
|
|
// Must contain an inner join referencing the calendars table
|
|
expect(generatedSql).toMatch(/inner join[\s\S]*`calendars`/i)
|
|
})
|
|
|
|
it('DELETE /:uid lookup SQL contains inner join to calendars', async () => {
|
|
const { drizzle } = await vi.importActual<typeof import('drizzle-orm/mysql2')>('drizzle-orm/mysql2')
|
|
const { eq } = await vi.importActual<typeof import('drizzle-orm')>('drizzle-orm')
|
|
const { calendarEvents, calendars } = await vi.importActual<typeof import('../../src/db/schema.js')>('../../src/db/schema.js')
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const db = drizzle({ client: {} as any, mode: 'default' })
|
|
|
|
const lookupQuery = db
|
|
.select({
|
|
uid: calendarEvents.uid,
|
|
etag: calendarEvents.etag,
|
|
objectUrl: calendarEvents.objectUrl,
|
|
calendarId: calendarEvents.calendarId,
|
|
calendarUrl: calendars.url,
|
|
userId: calendars.userId,
|
|
})
|
|
.from(calendarEvents)
|
|
.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
|
|
.where(eq(calendarEvents.uid, 'test-uid'))
|
|
|
|
const { sql: generatedSql } = lookupQuery.toSQL()
|
|
expect(generatedSql).toMatch(/inner join[\s\S]*`calendars`/i)
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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)
|
|
})
|
|
})
|