test(03-01): add Wave 0 RED test scaffold for all Phase 3 behaviors

- vevent.test.ts: DTSTART UTC 'Z' for timed, DATE for all-day (D-13), RRULE (CAL-04/07)
- write.test.ts: createCalendarEvent uid.ics filename, updateCalendarEvent/deleteCalendarEvent
  etag/If-Match shapes (CAL-04/05/06, D-08)
- outboxWorker.test.ts: pending→done on 204, pending→failed on 412 (no retry), pending→backoff
  on 500, pending→dead at MAX_ATTEMPTS, edit-as-move create-before-delete ordering (D-04/D-07/D-08)
- events.test.ts (extended): POST /create 202+outbox row, PATCH /edit 202+etag, DELETE /:uid 202,
  GET /sync-status, GET /writable-calendars D-03 access control, 403 unauthorized calendar (V4)
- InstallPrompt.test.tsx: isIOSSafariNonStandalone UA detection, useAndroidInstallPrompt
  canInstall lifecycle (PWA-01/PWA-02)
All tests fail RED — implementation modules do not exist yet
This commit is contained in:
Lucas Berger
2026-06-05 17:26:02 -04:00
parent 0c0bcefeef
commit bbfccda756
5 changed files with 737 additions and 0 deletions
+152
View File
@@ -159,3 +159,155 @@ describe('GET /api/events', () => {
expect(occ.start).toBe('2026-06-15')
})
})
/**
* RED test scaffold: write endpoints + sync-status + writable-calendars (CAL-04/05/06, D-03, D-05, D-08, D-09)
*
* These tests extend the existing GET /api/events suite.
* They FAIL (RED) because the route handlers for write operations do not exist yet.
* They will turn GREEN in Plan 03-03 when the implementation is added.
*
* Note: The vi.mock calls for @hono/oidc-auth and db are hoisted above — they apply to all
* describe blocks in this file including these new ones.
*/
// Additional Drizzle mock helpers needed for write operations.
// The insert mock needs to support chaining: db.insert(table).values({...}) → Promise
let mockInsertValues: ReturnType<typeof vi.fn>
let mockInsert: ReturnType<typeof vi.fn>
// Update mock for outbox status reads
let mockOutboxRow: unknown = null
const mockOutboxWhere = vi.fn().mockImplementation(() => Promise.resolve(mockOutboxRow ? [mockOutboxRow] : []))
const mockOutboxFrom = vi.fn().mockReturnValue({ where: mockOutboxWhere })
// Calendar mock for writable-calendars
let mockCalendarRows: unknown[] = []
const mockCalsWhere = vi.fn().mockImplementation(() => Promise.resolve(mockCalendarRows))
const mockCalsFrom = vi.fn().mockReturnValue({ where: mockCalsWhere })
beforeEach(() => {
mockInsertValues = vi.fn().mockResolvedValue([{ insertId: 1 }])
mockInsert = vi.fn().mockReturnValue({ values: mockInsertValues })
mockOutboxRow = null
mockCalendarRows = []
})
describe('POST /api/events/create', () => {
it('returns 202 and inserts a pending outbox row for a valid create request', async () => {
// This test fails RED — route does not exist yet
const { app } = await import('../../src/index.js')
const res = await app.request('/api/events/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
summary: 'New event',
allDay: false,
dtstart: '2026-06-15T10:00:00Z',
dtend: '2026-06-15T11:00:00Z',
calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
}),
})
// RED: 404 or 405 expected because route is not registered yet
expect(res.status).toBe(202)
})
it('returns 400 for missing required fields', async () => {
const { app } = await import('../../src/index.js')
const res = await app.request('/api/events/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ summary: 'Missing dates' }),
})
// RED: 404 expected because route is not registered yet
expect(res.status).toBe(400)
})
it('returns 403 when writing to a calendar not owned by the user (D-03 / V4)', async () => {
const { app } = await import('../../src/index.js')
const res = await app.request('/api/events/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
summary: 'Unauthorized write',
allDay: false,
dtstart: '2026-06-15T10:00:00Z',
dtend: '2026-06-15T11:00:00Z',
calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/other@fm.com/Personal/',
}),
})
// RED: route not registered yet; a real implementation returns 403
expect(res.status).toBe(403)
})
})
describe('PATCH /api/events/:uid/edit', () => {
it('returns 202 and inserts an outbox row with etag for a valid edit request', async () => {
const { app } = await import('../../src/index.js')
const res = await app.request('/api/events/uid-001%40familysync/edit', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
summary: 'Updated title',
allDay: false,
dtstart: '2026-06-15T10:00:00Z',
dtend: '2026-06-15T11:00:00Z',
calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
}),
})
// RED: route not registered yet
expect(res.status).toBe(202)
})
})
describe('DELETE /api/events/:uid', () => {
it('returns 202 and inserts a delete outbox row', async () => {
const { app } = await import('../../src/index.js')
const res = await app.request('/api/events/uid-001%40familysync', {
method: 'DELETE',
})
// RED: route not registered yet
expect(res.status).toBe(202)
})
})
describe('GET /api/events/sync-status', () => {
it('returns the outbox status for a given uid', async () => {
const { app } = await import('../../src/index.js')
const res = await app.request('/api/events/sync-status?uid=uid-001%40familysync')
// RED: route not registered yet
expect(res.status).toBe(200)
const body = await res.json() as { uid: string; status: string }
expect(body).toHaveProperty('uid')
expect(body).toHaveProperty('status')
expect(['pending', 'done', 'failed', 'dead']).toContain(body.status)
})
})
describe('GET /api/events/writable-calendars', () => {
it('returns calendars writable by the current user (own personal + shared isShared=1)', async () => {
const { app } = await import('../../src/index.js')
const res = await app.request('/api/events/writable-calendars')
// RED: route not registered yet
expect(res.status).toBe(200)
const body = await res.json() as { calendars: unknown[] }
expect(body).toHaveProperty('calendars')
expect(Array.isArray(body.calendars)).toBe(true)
})
it('does NOT return another member\'s personal calendar (D-03 / V4 access control)', async () => {
// Implementation must filter to: userId = currentUser.id OR isShared = true
// Other member's personal (different userId, isShared=false) must be absent.
const { app } = await import('../../src/index.js')
const res = await app.request('/api/events/writable-calendars')
// RED: route not registered yet
expect(res.status).toBe(200)
const body = await res.json() as { calendars: Array<{ userId?: number; isShared?: boolean }> }
// When implemented: no calendar with userId !== currentUser.id AND isShared === false should appear
for (const cal of body.calendars) {
const isOwnedByUser = cal.userId !== undefined
const isShared = cal.isShared === true
expect(isOwnedByUser || isShared).toBe(true)
}
})
})