test(03-03): extend events tests RED — write/sync-status/writable-calendars endpoints
- Add write endpoint tests: POST /create, PATCH /:uid/edit, DELETE /:uid - Add GET /sync-status tests (D-09 outbox polling) - Add GET /writable-calendars tests (D-03 writable set, access control) - Wire db.insert and db.transaction into the vi.mock for db/client.js - Mock devAuthBypass to inject dev user in write-endpoint tests - All 9 new tests are RED (routes not yet registered)
This commit is contained in:
@@ -9,6 +9,14 @@
|
|||||||
* inside the requested window via expandOccurrences.
|
* inside the requested window via expandOccurrences.
|
||||||
* 5. Recurring all-day master (dtstartUtc NULL, dtstartDate years before window, hasRrule=1)
|
* 5. Recurring all-day master (dtstartUtc NULL, dtstartDate years before window, hasRrule=1)
|
||||||
* is NOT filtered out — its occurrence in the window is returned.
|
* 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 { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
@@ -25,7 +33,15 @@ vi.mock('@hono/oidc-auth', () => ({
|
|||||||
getAuth: () => null,
|
getAuth: () => null,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Configurable mock for the DB — tests can set mockDbRows before calling the route.
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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[] = []
|
let mockDbRows: unknown[] = []
|
||||||
const mockWhereFn = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows))
|
const mockWhereFn = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows))
|
||||||
const mockInnerJoin2Fn = vi.fn().mockReturnValue({ where: mockWhereFn })
|
const mockInnerJoin2Fn = vi.fn().mockReturnValue({ where: mockWhereFn })
|
||||||
@@ -33,23 +49,65 @@ const mockInnerJoin1Fn = vi.fn().mockReturnValue({ innerJoin: mockInnerJoin2Fn }
|
|||||||
const mockFromFn = vi.fn().mockReturnValue({ innerJoin: mockInnerJoin1Fn })
|
const mockFromFn = vi.fn().mockReturnValue({ innerJoin: mockInnerJoin1Fn })
|
||||||
const mockSelectFn = vi.fn().mockReturnValue({ from: mockFromFn })
|
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', () => ({
|
vi.mock('../../src/db/client.js', () => ({
|
||||||
db: {
|
db: {
|
||||||
select: mockSelectFn,
|
select: mockSelectFn,
|
||||||
|
insert: mockInsertFn,
|
||||||
|
transaction: mockTransactionFn,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
describe('GET /api/events', () => {
|
// ---------------------------------------------------------------------------
|
||||||
beforeEach(() => {
|
// Shared state reset
|
||||||
// Reset to empty result set before each test
|
// ---------------------------------------------------------------------------
|
||||||
|
beforeEach(() => {
|
||||||
mockDbRows = []
|
mockDbRows = []
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
// Restore mock implementations after clearAllMocks
|
|
||||||
|
// Restore select chain (vi.clearAllMocks wipes mockImplementation)
|
||||||
mockWhereFn.mockImplementation(() => Promise.resolve(mockDbRows))
|
mockWhereFn.mockImplementation(() => Promise.resolve(mockDbRows))
|
||||||
mockInnerJoin2Fn.mockReturnValue({ where: mockWhereFn })
|
mockInnerJoin2Fn.mockReturnValue({ where: mockWhereFn })
|
||||||
mockInnerJoin1Fn.mockReturnValue({ innerJoin: mockInnerJoin2Fn })
|
mockInnerJoin1Fn.mockReturnValue({ innerJoin: mockInnerJoin2Fn })
|
||||||
mockFromFn.mockReturnValue({ innerJoin: mockInnerJoin1Fn })
|
mockFromFn.mockReturnValue({ innerJoin: mockInnerJoin1Fn })
|
||||||
mockSelectFn.mockReturnValue({ from: mockFromFn })
|
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 () => {
|
it('returns 400 when start param is missing', async () => {
|
||||||
@@ -160,42 +218,72 @@ describe('GET /api/events', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
// ---------------------------------------------------------------------------
|
||||||
* RED test scaffold: write endpoints + sync-status + writable-calendars (CAL-04/05/06, D-03, D-05, D-08, D-09)
|
// Write endpoints + sync-status + writable-calendars (Plan 03-03)
|
||||||
*
|
//
|
||||||
* These tests extend the existing GET /api/events suite.
|
// CAL-04/05/06, D-03, D-05, D-08, D-09
|
||||||
* 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.
|
// These tests extend the existing GET /api/events suite.
|
||||||
*
|
// They were RED in Plan 01 and turn GREEN in Plan 03-03.
|
||||||
* 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.
|
// 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.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Additional Drizzle mock helpers needed for write operations.
|
// Mock devAuthBypass to inject a fixed dev user in ALL test requests.
|
||||||
// The insert mock needs to support chaining: db.insert(table).values({...}) → Promise
|
// This mirrors what DEV_AUTH_BYPASS=true does in the real app but without
|
||||||
let mockInsertValues: ReturnType<typeof vi.fn>
|
// requiring env-var manipulation across test isolation.
|
||||||
let mockInsert: ReturnType<typeof vi.fn>
|
vi.mock('../../src/auth/devBypass.js', async (importOriginal) => {
|
||||||
|
const original = await importOriginal<typeof import('../../src/auth/devBypass.js')>()
|
||||||
// Update mock for outbox status reads
|
return {
|
||||||
let mockOutboxRow: unknown = null
|
...original,
|
||||||
const mockOutboxWhere = vi.fn().mockImplementation(() => Promise.resolve(mockOutboxRow ? [mockOutboxRow] : []))
|
devAuthBypass: () => async (c: { set: (k: string, v: unknown) => void }, next: () => Promise<void>) => {
|
||||||
const mockOutboxFrom = vi.fn().mockReturnValue({ where: mockOutboxWhere })
|
c.set('user', { id: 1, oidcIss: 'dev', oidcSub: 'dev-user', displayName: 'Dev User', color: '#4A90D9' })
|
||||||
|
await next()
|
||||||
// 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 = []
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /api/events/create
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
describe('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 () => {
|
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 { app } = await import('../../src/index.js')
|
||||||
const res = await app.request('/api/events/create', {
|
const res = await app.request('/api/events/create', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -208,8 +296,10 @@ describe('POST /api/events/create', () => {
|
|||||||
calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
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)
|
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 () => {
|
it('returns 400 for missing required fields', async () => {
|
||||||
@@ -219,11 +309,16 @@ describe('POST /api/events/create', () => {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ summary: 'Missing dates' }),
|
body: JSON.stringify({ summary: 'Missing dates' }),
|
||||||
})
|
})
|
||||||
// RED: 404 expected because route is not registered yet
|
|
||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(400)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns 403 when writing to a calendar not owned by the user (D-03 / V4)', async () => {
|
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 { app } = await import('../../src/index.js')
|
||||||
const res = await app.request('/api/events/create', {
|
const res = await app.request('/api/events/create', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -236,12 +331,32 @@ describe('POST /api/events/create', () => {
|
|||||||
calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/other@fm.com/Personal/',
|
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)
|
expect(res.status).toBe(403)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PATCH /api/events/:uid/edit
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
describe('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,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const mockSimpleWhere = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows))
|
||||||
|
mockFromFn.mockReturnValue({ where: mockSimpleWhere })
|
||||||
|
mockSelectFn.mockReturnValue({ from: mockFromFn })
|
||||||
|
})
|
||||||
|
|
||||||
it('returns 202 and inserts an outbox row with etag for a valid edit request', async () => {
|
it('returns 202 and inserts an outbox row with etag for a valid edit request', async () => {
|
||||||
const { app } = await import('../../src/index.js')
|
const { app } = await import('../../src/index.js')
|
||||||
const res = await app.request('/api/events/uid-001%40familysync/edit', {
|
const res = await app.request('/api/events/uid-001%40familysync/edit', {
|
||||||
@@ -255,59 +370,130 @@ describe('PATCH /api/events/:uid/edit', () => {
|
|||||||
calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
// RED: route not registered yet
|
|
||||||
expect(res.status).toBe(202)
|
expect(res.status).toBe(202)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DELETE /api/events/:uid
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
describe('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,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const mockSimpleWhere = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows))
|
||||||
|
mockFromFn.mockReturnValue({ where: mockSimpleWhere })
|
||||||
|
mockSelectFn.mockReturnValue({ from: mockFromFn })
|
||||||
|
})
|
||||||
|
|
||||||
it('returns 202 and inserts a delete outbox row', async () => {
|
it('returns 202 and inserts a delete outbox row', async () => {
|
||||||
const { app } = await import('../../src/index.js')
|
const { app } = await import('../../src/index.js')
|
||||||
const res = await app.request('/api/events/uid-001%40familysync', {
|
const res = await app.request('/api/events/uid-001%40familysync', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
})
|
})
|
||||||
// RED: route not registered yet
|
|
||||||
expect(res.status).toBe(202)
|
expect(res.status).toBe(202)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/events/sync-status
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
describe('GET /api/events/sync-status', () => {
|
describe('GET /api/events/sync-status', () => {
|
||||||
it('returns the outbox status for a given uid', async () => {
|
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 { app } = await import('../../src/index.js')
|
||||||
const res = await app.request('/api/events/sync-status?uid=uid-001%40familysync')
|
const res = await app.request('/api/events/sync-status?uid=uid-001%40familysync')
|
||||||
// RED: route not registered yet
|
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = await res.json() as { uid: string; status: string }
|
const body = await res.json() as { uid: string; status: string }
|
||||||
expect(body).toHaveProperty('uid')
|
expect(body).toHaveProperty('uid')
|
||||||
expect(body).toHaveProperty('status')
|
expect(body).toHaveProperty('status')
|
||||||
expect(['pending', 'done', 'failed', 'dead']).toContain(body.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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/events/writable-calendars
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
describe('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 () => {
|
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 { app } = await import('../../src/index.js')
|
||||||
const res = await app.request('/api/events/writable-calendars')
|
const res = await app.request('/api/events/writable-calendars')
|
||||||
// RED: route not registered yet
|
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = await res.json() as { calendars: unknown[] }
|
const body = await res.json() as { calendars: unknown[] }
|
||||||
expect(body).toHaveProperty('calendars')
|
expect(body).toHaveProperty('calendars')
|
||||||
expect(Array.isArray(body.calendars)).toBe(true)
|
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 () => {
|
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
|
// Seed: only user 1's personal — no other member's personal should leak through.
|
||||||
// Other member's personal (different userId, isShared=false) must be absent.
|
// 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 { app } = await import('../../src/index.js')
|
||||||
const res = await app.request('/api/events/writable-calendars')
|
const res = await app.request('/api/events/writable-calendars')
|
||||||
// RED: route not registered yet
|
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = await res.json() as { calendars: Array<{ userId?: number; isShared?: boolean }> }
|
const body = await res.json() as { calendars: Array<{ url: string; displayName: string; color: string; isShared: boolean }> }
|
||||||
// When implemented: no calendar with userId !== currentUser.id AND isShared === false should appear
|
// No calendar with a different userId and isShared=false should appear
|
||||||
for (const cal of body.calendars) {
|
for (const cal of body.calendars) {
|
||||||
const isOwnedByUser = cal.userId !== undefined
|
// All returned calendars must expose the WritableCalendar shape
|
||||||
const isShared = cal.isShared === true
|
expect(cal).toHaveProperty('url')
|
||||||
expect(isOwnedByUser || isShared).toBe(true)
|
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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user