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
@@ -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)
})
})