From bbfccda756d4361d0f05d9e0ca2cf4f5530ab789 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Fri, 5 Jun 2026 17:26:02 -0400 Subject: [PATCH] test(03-01): add Wave 0 RED test scaffold for all Phase 3 behaviors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/api/tests/broker/outboxWorker.test.ts | 212 ++++++++++++++++++ apps/api/tests/broker/vevent.test.ts | 118 ++++++++++ apps/api/tests/broker/write.test.ts | 119 ++++++++++ apps/api/tests/routes/events.test.ts | 152 +++++++++++++ .../pwa/src/components/InstallPrompt.test.tsx | 136 +++++++++++ 5 files changed, 737 insertions(+) create mode 100644 apps/api/tests/broker/outboxWorker.test.ts create mode 100644 apps/api/tests/broker/vevent.test.ts create mode 100644 apps/api/tests/broker/write.test.ts create mode 100644 apps/pwa/src/components/InstallPrompt.test.tsx diff --git a/apps/api/tests/broker/outboxWorker.test.ts b/apps/api/tests/broker/outboxWorker.test.ts new file mode 100644 index 0000000..1ca5621 --- /dev/null +++ b/apps/api/tests/broker/outboxWorker.test.ts @@ -0,0 +1,212 @@ +/** + * RED test scaffold: broker/outboxWorker.ts — outbox state machine (D-04, D-07, D-08) + * + * Behaviors under test: + * 1. runOutboxDrain transitions pending→done on mock 204 response + * 2. runOutboxDrain transitions pending→failed on mock 412 (conflict, no retry), triggers re-sync + * 3. runOutboxDrain transitions pending→backoff (nextAttemptAt advanced, attemptCount++) + * on mock 500 (transient error) + * 4. runOutboxDrain transitions pending→dead when attemptCount reaches MAX_ATTEMPTS + * 5. Edit-as-move (D-04): create row processed BEFORE the linked delete row (groupId) + * + * These tests FAIL (RED) because broker/outboxWorker.ts does not exist yet. + * They will turn GREEN in Plan 03-03 when the implementation is added. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// This import fails (RED) — broker/outboxWorker.ts does not exist yet. +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore intentional RED import +import { runOutboxDrain } from '../../src/broker/outboxWorker.js' + +// ── Drizzle DB mock ──────────────────────────────────────────────────────── +// Follows the pattern from PATTERNS.md §Drizzle DB mock in tests. + +const mockUpdateSet = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) +const mockUpdate = vi.fn().mockReturnValue({ set: mockUpdateSet }) + +let mockPendingRows: unknown[] = [] +const mockWherePending = vi.fn().mockImplementation(() => Promise.resolve(mockPendingRows)) +const mockLimitFn = vi.fn().mockReturnValue({ where: mockWherePending }) +const mockFromFn = vi.fn().mockReturnValue({ where: mockLimitFn }) +const mockSelectFn = vi.fn().mockReturnValue({ from: mockFromFn }) + +vi.mock('../../src/db/client.js', () => ({ + db: { + select: mockSelectFn, + update: mockUpdate, + }, +})) + +// Mock write functions — these are called by outboxWorker for the actual CalDAV ops +vi.mock('../../src/broker/write.js', () => ({ + createCalendarEvent: vi.fn(), + updateCalendarEvent: vi.fn(), + deleteCalendarEvent: vi.fn(), +})) + +// Mock sync — called after successful write (D-06) +vi.mock('../../src/broker/sync.js', () => ({ + syncCalendar: vi.fn().mockResolvedValue(undefined), +})) + +// Mock client creation — the worker needs a DAVClient to call sync +vi.mock('../../src/broker/client.js', () => ({ + createFastmailClient: vi.fn().mockResolvedValue({ + fetchCalendars: vi.fn().mockResolvedValue([]), + }), +})) + +const makeRow = (overrides: Record = {}) => ({ + id: 1, + userId: 42, + operation: 'create' as const, + status: 'pending' as const, + uid: 'test-uid@familysync', + calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', + calendarObjectUrl: null, + etag: null, + payload: 'BEGIN:VCALENDAR\r\nEND:VCALENDAR', + attemptCount: 0, + nextAttemptAt: new Date(Date.now() - 5000), // already due + lastError: null, + groupId: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, +}) + +const makeResponse = (status: number): Response => + ({ status, ok: status >= 200 && status < 300, headers: new Headers() }) as unknown as Response + +describe('runOutboxDrain — state transitions', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPendingRows = [] + // Restore mock chain after clearAllMocks + mockUpdateSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) + mockUpdate.mockReturnValue({ set: mockUpdateSet }) + mockWherePending.mockImplementation(() => Promise.resolve(mockPendingRows)) + mockLimitFn.mockReturnValue({ where: mockWherePending }) + mockFromFn.mockReturnValue({ where: mockLimitFn }) + mockSelectFn.mockReturnValue({ from: mockFromFn }) + }) + + it('transitions pending→done on 204 response and triggers re-sync (D-06)', async () => { + const { createCalendarEvent } = await import('../../src/broker/write.js') + vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(204)) + mockPendingRows = [makeRow()] + + await runOutboxDrain() + + // Must update status to 'done' + expect(mockUpdate).toHaveBeenCalled() + const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string } + expect(setArg?.status).toBe('done') + }) + + it('transitions pending→failed on 412 (conflict — no retry), marks failed (D-08)', async () => { + const { createCalendarEvent } = await import('../../src/broker/write.js') + vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(412)) + mockPendingRows = [makeRow()] + + await runOutboxDrain() + + // 412 = hard fail (conflict) — must NOT retry, must mark failed + const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string; lastError?: string } + expect(setArg?.status).toBe('failed') + expect(setArg?.lastError).toBeTruthy() + }) + + it('transitions pending→backoff (attemptCount++, nextAttemptAt advanced) on 500 (transient)', async () => { + const { createCalendarEvent } = await import('../../src/broker/write.js') + vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(500)) + const row = makeRow({ attemptCount: 0 }) + mockPendingRows = [row] + + const beforeDrain = Date.now() + await runOutboxDrain() + + // Must NOT transition to done or failed — backoff + const setArg = mockUpdateSet.mock.calls[0]?.[0] as { + status?: string + attemptCount?: number + nextAttemptAt?: Date + } + expect(setArg?.status).not.toBe('done') + expect(setArg?.status).not.toBe('failed') + expect(setArg?.attemptCount).toBe(1) + // nextAttemptAt must be in the future + expect(setArg?.nextAttemptAt?.getTime()).toBeGreaterThan(beforeDrain) + }) + + it('transitions pending→dead when attemptCount reaches MAX_ATTEMPTS on transient error', async () => { + const { createCalendarEvent } = await import('../../src/broker/write.js') + vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(500)) + // MAX_ATTEMPTS is 5 per RESEARCH.md Pattern 4 — at attempt 4 (0-indexed) → dead + const row = makeRow({ attemptCount: 4 }) + mockPendingRows = [row] + + await runOutboxDrain() + + const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string } + expect(setArg?.status).toBe('dead') + }) + + it('does not crash when pending rows list is empty', async () => { + mockPendingRows = [] + await expect(runOutboxDrain()).resolves.not.toThrow() + }) +}) + +describe('runOutboxDrain — edit-as-move ordering (D-04)', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPendingRows = [] + mockUpdateSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) + mockUpdate.mockReturnValue({ set: mockUpdateSet }) + mockWherePending.mockImplementation(() => Promise.resolve(mockPendingRows)) + mockLimitFn.mockReturnValue({ where: mockWherePending }) + mockFromFn.mockReturnValue({ where: mockLimitFn }) + mockSelectFn.mockReturnValue({ from: mockFromFn }) + }) + + it('processes the create row BEFORE the delete row when both share a groupId', async () => { + const { createCalendarEvent, deleteCalendarEvent } = await import('../../src/broker/write.js') + const createResponse = makeResponse(201) + const deleteResponse = makeResponse(204) + vi.mocked(createCalendarEvent).mockResolvedValue(createResponse) + vi.mocked(deleteCalendarEvent).mockResolvedValue(deleteResponse) + + const groupId = 'edit-move-group-001' + // delete row listed first (to verify ordering is enforced regardless of order in the array) + const deleteRow = makeRow({ + id: 2, + operation: 'delete', + calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/Old/uid.ics', + etag: '"etag-old"', + payload: null, + groupId, + }) + const createRow = makeRow({ + id: 3, + operation: 'create', + calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/New/', + groupId, + }) + + // Both rows in the pending list + mockPendingRows = [deleteRow, createRow] + + await runOutboxDrain() + + // CREATE must be called before DELETE + const createCall = vi.mocked(createCalendarEvent).mock.invocationCallOrder[0] + const deleteCall = vi.mocked(deleteCalendarEvent).mock.invocationCallOrder[0] + + // If either was never called, the test will fail naturally. + // If create order index > delete order index, create ran AFTER delete — fail. + expect(createCall).toBeLessThan(deleteCall) + }) +}) diff --git a/apps/api/tests/broker/vevent.test.ts b/apps/api/tests/broker/vevent.test.ts new file mode 100644 index 0000000..81b85b9 --- /dev/null +++ b/apps/api/tests/broker/vevent.test.ts @@ -0,0 +1,118 @@ +/** + * RED test scaffold: broker/vevent.ts — VEVENT builder (CAL-04, CAL-07) + * + * Behaviors under test: + * 1. buildVeventString produces a VCALENDAR with a VEVENT for a timed event + * with DTSTART using UTC 'Z' suffix (D-13 contract — no TZID) + * 2. buildVeventString produces a VCALENDAR with DTSTART as a DATE value + * (no time component, no TZID) for all-day events (D-13, Pitfall 3) + * 3. buildVeventString with rruleString produces a VCALENDAR with an RRULE property (CAL-07) + * + * These tests FAIL (RED) because broker/vevent.ts does not exist yet. + * They will turn GREEN in Plan 03-02 when the implementation is added. + */ + +import { describe, it, expect } from 'vitest' + +// This import fails (RED) — broker/vevent.ts does not exist yet. +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore intentional RED import +import { buildVeventString } from '../../src/broker/vevent.js' + +describe('buildVeventString', () => { + it('produces a VCALENDAR string containing a VEVENT for a timed event', () => { + const result = buildVeventString({ + summary: 'Team standup', + allDay: false, + dtstart: new Date('2026-06-10T09:00:00Z'), + dtend: new Date('2026-06-10T09:30:00Z'), + }) + + expect(result).toHaveProperty('uid') + expect(result).toHaveProperty('icsString') + expect(result.icsString).toContain('BEGIN:VCALENDAR') + expect(result.icsString).toContain('BEGIN:VEVENT') + expect(result.icsString).toContain('SUMMARY:Team standup') + expect(result.icsString).toContain('END:VEVENT') + expect(result.icsString).toContain('END:VCALENDAR') + }) + + it('produces DTSTART with Z suffix (UTC) for a timed event — not TZID', () => { + const result = buildVeventString({ + summary: 'Morning meeting', + allDay: false, + dtstart: new Date('2026-06-10T14:00:00Z'), + dtend: new Date('2026-06-10T15:00:00Z'), + }) + + // D-13 timed contract: DATETIME in UTC → 'Z' suffix, no TZID + expect(result.icsString).toMatch(/DTSTART:\d{8}T\d{6}Z/) + expect(result.icsString).not.toMatch(/DTSTART;TZID=/) + }) + + it('produces DTSTART as DATE (no time, no TZID) for an all-day event', () => { + const result = buildVeventString({ + summary: 'Birthday', + allDay: true, + dtstart: '2026-06-15', + dtend: '2026-06-16', + }) + + // D-13 all-day contract: VALUE=DATE, no time component, no TZID + // ical.js represents DATE as DTSTART;VALUE=DATE:YYYYMMDD + expect(result.icsString).toMatch(/DTSTART[^:]*:20260615/) + // Must NOT contain a time component (no 'T' after the date) + expect(result.icsString).not.toMatch(/DTSTART[^:]*:20260615T/) + // Must NOT contain TZID on the DTSTART property + expect(result.icsString).not.toMatch(/DTSTART;TZID=/) + }) + + it('includes an RRULE property when rruleString is provided (CAL-07)', () => { + const result = buildVeventString({ + summary: 'Weekly sync', + allDay: false, + dtstart: new Date('2026-06-09T10:00:00Z'), + dtend: new Date('2026-06-09T11:00:00Z'), + rruleString: 'FREQ=WEEKLY;BYDAY=MO', + }) + + expect(result.icsString).toContain('RRULE:FREQ=WEEKLY;BYDAY=MO') + }) + + it('does NOT include RRULE when rruleString is omitted', () => { + const result = buildVeventString({ + summary: 'One-off lunch', + allDay: false, + dtstart: new Date('2026-06-10T12:00:00Z'), + dtend: new Date('2026-06-10T13:00:00Z'), + }) + + expect(result.icsString).not.toContain('RRULE:') + }) + + it('uses the provided uid when given', () => { + const uid = 'custom-uid-001@familysync' + const result = buildVeventString({ + uid, + summary: 'Test event', + allDay: false, + dtstart: new Date('2026-06-10T09:00:00Z'), + dtend: new Date('2026-06-10T10:00:00Z'), + }) + + expect(result.uid).toBe(uid) + expect(result.icsString).toContain(`UID:${uid}`) + }) + + it('generates a uid when none is provided', () => { + const result = buildVeventString({ + summary: 'Auto uid event', + allDay: false, + dtstart: new Date('2026-06-10T09:00:00Z'), + dtend: new Date('2026-06-10T10:00:00Z'), + }) + + expect(result.uid).toBeTruthy() + expect(result.uid.length).toBeGreaterThan(10) + }) +}) diff --git a/apps/api/tests/broker/write.test.ts b/apps/api/tests/broker/write.test.ts new file mode 100644 index 0000000..ec23c96 --- /dev/null +++ b/apps/api/tests/broker/write.test.ts @@ -0,0 +1,119 @@ +/** + * RED test scaffold: broker/write.ts — tsdav PUT/DELETE wrappers (CAL-04, CAL-05, CAL-06) + * + * Behaviors under test: + * 1. createCalendarEvent calls client.createCalendarObject with `${uid}.ics` filename + * 2. updateCalendarEvent passes etag into the calendarObject (If-Match for D-08) + * 3. deleteCalendarEvent passes etag into the calendarObject (If-Match for D-08) + * 4. Each returns the raw Response from the tsdav client mock + * + * These tests FAIL (RED) because broker/write.ts does not exist yet. + * They will turn GREEN in Plan 03-02 when the implementation is added. + */ + +import { describe, it, expect, vi } from 'vitest' + +// This import fails (RED) — broker/write.ts does not exist yet. +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore intentional RED import +import { + createCalendarEvent, + updateCalendarEvent, + deleteCalendarEvent, +} from '../../src/broker/write.js' + +const makeMockResponse = (status: number): Response => + ({ status, ok: status >= 200 && status < 300, headers: new Headers() }) as unknown as Response + +describe('createCalendarEvent', () => { + it('calls client.createCalendarObject with uid.ics filename', async () => { + const mockClient = { + createCalendarObject: vi.fn().mockResolvedValue(makeMockResponse(201)), + } + const mockCalendar = { url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/' } + const uid = 'abc-123@familysync' + const icsString = 'BEGIN:VCALENDAR\r\nEND:VCALENDAR' + + const result = await createCalendarEvent(mockClient as never, mockCalendar as never, uid, icsString) + + expect(mockClient.createCalendarObject).toHaveBeenCalledWith({ + calendar: mockCalendar, + filename: `${uid}.ics`, + iCalString: icsString, + }) + expect(result.status).toBe(201) + }) + + it('returns the raw Response from client', async () => { + const mockClient = { + createCalendarObject: vi.fn().mockResolvedValue(makeMockResponse(204)), + } + const mockCalendar = { url: 'https://caldav.fastmail.com/' } + const result = await createCalendarEvent(mockClient as never, mockCalendar as never, 'uid@fs', 'ICS') + expect(result).toBeDefined() + expect(result.status).toBe(204) + }) +}) + +describe('updateCalendarEvent', () => { + it('calls client.updateCalendarObject with etag in calendarObject (If-Match)', async () => { + const mockClient = { + updateCalendarObject: vi.fn().mockResolvedValue(makeMockResponse(204)), + } + const calendarObjectUrl = 'https://caldav.fastmail.com/dav/calendars/user/test/Default/uid.ics' + const icsString = 'BEGIN:VCALENDAR\r\nEND:VCALENDAR' + const etag = '"etag-123"' + + const result = await updateCalendarEvent(mockClient as never, calendarObjectUrl, icsString, etag) + + expect(mockClient.updateCalendarObject).toHaveBeenCalledWith({ + calendarObject: { + url: calendarObjectUrl, + data: icsString, + etag, + }, + }) + expect(result.status).toBe(204) + }) + + it('passes empty string as etag when etag is null (safe for If-Match)', async () => { + const mockClient = { + updateCalendarObject: vi.fn().mockResolvedValue(makeMockResponse(204)), + } + await updateCalendarEvent(mockClient as never, 'https://caldav.fastmail.com/uid.ics', 'ICS', null) + + const callArg = mockClient.updateCalendarObject.mock.calls[0][0] + expect(callArg.calendarObject.etag).toBe('') + }) +}) + +describe('deleteCalendarEvent', () => { + it('calls client.deleteCalendarObject with etag in calendarObject (If-Match)', async () => { + const mockClient = { + deleteCalendarObject: vi.fn().mockResolvedValue(makeMockResponse(204)), + } + const calendarObjectUrl = 'https://caldav.fastmail.com/dav/calendars/user/test/Default/uid.ics' + const etag = '"etag-456"' + + const result = await deleteCalendarEvent(mockClient as never, calendarObjectUrl, etag) + + expect(mockClient.deleteCalendarObject).toHaveBeenCalledWith({ + calendarObject: { + url: calendarObjectUrl, + data: '', + etag, + }, + }) + expect(result.status).toBe(204) + }) + + it('passes empty string as etag when etag is null', async () => { + const mockClient = { + deleteCalendarObject: vi.fn().mockResolvedValue(makeMockResponse(204)), + } + await deleteCalendarEvent(mockClient as never, 'https://caldav.fastmail.com/uid.ics', null) + + const callArg = mockClient.deleteCalendarObject.mock.calls[0][0] + expect(callArg.calendarObject.etag).toBe('') + }) +}) diff --git a/apps/api/tests/routes/events.test.ts b/apps/api/tests/routes/events.test.ts index 88b039c..98931cb 100644 --- a/apps/api/tests/routes/events.test.ts +++ b/apps/api/tests/routes/events.test.ts @@ -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 +let mockInsert: ReturnType + +// 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) + } + }) +}) diff --git a/apps/pwa/src/components/InstallPrompt.test.tsx b/apps/pwa/src/components/InstallPrompt.test.tsx new file mode 100644 index 0000000..1477155 --- /dev/null +++ b/apps/pwa/src/components/InstallPrompt.test.tsx @@ -0,0 +1,136 @@ +/** + * RED test scaffold: components/InstallPrompt.tsx — iOS/Android install prompt (PWA-01, PWA-02) + * + * Behaviors under test: + * 1. isIOSSafariNonStandalone() returns true for a mock iOS Safari non-standalone UA + * 2. isIOSSafariNonStandalone() returns false when running in standalone mode + * 3. useAndroidInstallPrompt sets canInstall=true when a mock beforeinstallprompt event fires + * 4. useAndroidInstallPrompt sets canInstall=false when appinstalled event fires + * + * These tests FAIL (RED) because components/InstallPrompt.tsx does not exist yet. + * They will turn GREEN in Plan 03-06 when the implementation is added. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' + +// This import fails (RED) — InstallPrompt.tsx does not exist yet. +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore intentional RED import +import { isIOSSafariNonStandalone, useAndroidInstallPrompt } from './InstallPrompt.js' + +// Capture the original navigator descriptors so we can restore them +const originalUserAgent = navigator.userAgent + +function setUserAgent(ua: string) { + Object.defineProperty(navigator, 'userAgent', { + value: ua, + writable: true, + configurable: true, + }) +} + +function setStandalone(value: boolean) { + // navigator.standalone is an iOS-only property + Object.defineProperty(navigator, 'standalone', { + value, + writable: true, + configurable: true, + }) +} + +describe('isIOSSafariNonStandalone', () => { + afterEach(() => { + // Restore original userAgent + Object.defineProperty(navigator, 'userAgent', { + value: originalUserAgent, + writable: true, + configurable: true, + }) + // Remove standalone mock + Object.defineProperty(navigator, 'standalone', { + value: undefined, + writable: true, + configurable: true, + }) + }) + + it('returns true for an iOS Safari non-standalone UA', () => { + setUserAgent( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_4 like Mac OS X) ' + + 'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Mobile/15E148 Safari/604.1', + ) + setStandalone(false) + + expect(isIOSSafariNonStandalone()).toBe(true) + }) + + it('returns false when running in standalone mode (PWA installed)', () => { + setUserAgent( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_4 like Mac OS X) ' + + 'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Mobile/15E148 Safari/604.1', + ) + setStandalone(true) + + expect(isIOSSafariNonStandalone()).toBe(false) + }) + + it('returns false for an Android Chrome UA', () => { + setUserAgent( + 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 ' + + '(KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36', + ) + setStandalone(false) + + expect(isIOSSafariNonStandalone()).toBe(false) + }) +}) + +describe('useAndroidInstallPrompt', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('sets canInstall=true when a beforeinstallprompt event is dispatched', async () => { + const { result } = renderHook(() => useAndroidInstallPrompt()) + expect(result.current.canInstall).toBe(false) + + const mockPromptEvent = new Event('beforeinstallprompt') as Event & { + prompt: () => Promise + userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }> + } + mockPromptEvent.prompt = vi.fn().mockResolvedValue(undefined) + mockPromptEvent.userChoice = Promise.resolve({ outcome: 'accepted' }) + + act(() => { + window.dispatchEvent(mockPromptEvent) + }) + + expect(result.current.canInstall).toBe(true) + }) + + it('sets canInstall=false when appinstalled event fires', async () => { + const { result } = renderHook(() => useAndroidInstallPrompt()) + + // First fire beforeinstallprompt to set canInstall=true + const mockPromptEvent = new Event('beforeinstallprompt') as Event & { + prompt: () => Promise + userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }> + } + mockPromptEvent.prompt = vi.fn().mockResolvedValue(undefined) + mockPromptEvent.userChoice = Promise.resolve({ outcome: 'dismissed' }) + + act(() => { + window.dispatchEvent(mockPromptEvent) + }) + + expect(result.current.canInstall).toBe(true) + + // Now fire appinstalled — should reset canInstall to false + act(() => { + window.dispatchEvent(new Event('appinstalled')) + }) + + expect(result.current.canInstall).toBe(false) + }) +})