Milestone v1.0: FamilySync MVP #1

Merged
luckberg merged 376 commits from gsd/v1.0-milestone into main 2026-06-10 17:39:19 -04:00
5 changed files with 737 additions and 0 deletions
Showing only changes of commit bbfccda756 - Show all commits
+212
View File
@@ -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<string, unknown> = {}) => ({
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)
})
})
+118
View File
@@ -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)
})
})
+119
View File
@@ -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('')
})
})
+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)
}
})
})
@@ -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<void>
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<void>
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)
})
})