Files
familysync/apps/pwa/src/components/SyncStateToast.test.tsx
T
Lucas Berger 03e953158a fix(13-02): eliminate all ESLint violations — pnpm lint exits 0
- eslint.config.js: disable React Compiler rules (v7 flat.recommended enables
  them; codebase does not use the Compiler); add e2e/ to disableTypeChecked
  block; promote exhaustive-deps to error
- API broker: remove redundant as-casts (outboxWorker, poller, reminderScheduler,
  expand, sync, vevent, spike); add targeted ical.js no-unsafe-assignment/argument
  disables with justifying comments inside try blocks
- API routes/sse.ts: fix no-misused-promises on async writeSSE callback with
  void+IIFE+catch pattern
- API routes/lists.ts: let → const for updateValues
- API tests: remove unused imports (beforeEach, eq, vi); rename unused vars
  with _ prefix; remove unused lastActiveId assignment
- PWA components: void navigate() and void queryClient.invalidateQueries() on
  all fire-and-forget call sites; fix CalendarShell explicit-type-casts;
  Couldn't → HTML entity
- PWA test files: as unknown as Response for partial mock objects; string | null
  type annotation on mockLastSyncedUid; remove async from test callbacks without
  await; act(() => {}) not await act(async () => {}) for sync ops
- sw.ts: restructure Notification.data?.url access as let+if so disable
  comments land on the exact violation lines; void self.skipWaiting()
2026-06-11 20:23:38 -04:00

234 lines
7.5 KiB
TypeScript

/**
* SyncStateToast tests — Task 2 (TDD RED → GREEN)
*
* Covers all states from UI-SPEC §SyncStateToast:
* - pending: "Syncing…" + spinner role="status"
* - done: "Saved" + Check icon, auto-dismiss after 2s, invalidates ['events']
* - failed (generic): "Didn't save. Try again." role="alert", persists with dismiss button
* - failed (conflict/412): conflict copy, invalidates ['events']
* - dead: "Not saved. Check your connection." role="alert"
* - refetchInterval active only while pending
* - renders nothing when lastSyncedUid is null
*/
import 'temporal-polyfill/global'
import React from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, act, fireEvent } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { SyncStateToast } from './SyncStateToast.js'
// ── Module mocks ──────────────────────────────────────────────────────────────
// vi.hoisted() required for variables referenced inside vi.mock() factories (D-03-04-hoisting)
const {
mockLastSyncedUid,
mockSetLastSyncedUid,
mockFetchSyncStatus,
} = vi.hoisted(() => ({
mockLastSyncedUid: { value: 'test-uid-123' as string | null },
mockSetLastSyncedUid: vi.fn(),
mockFetchSyncStatus: vi.fn(),
}))
vi.mock('../store/calendarStore.js', () => ({
useCalendarStore: (selector: (s: Record<string, unknown>) => unknown) => {
const state = {
lastSyncedUid: mockLastSyncedUid.value,
setLastSyncedUid: mockSetLastSyncedUid,
}
return selector(state)
},
}))
vi.mock('../api/client.js', () => ({
fetchSyncStatus: mockFetchSyncStatus,
}))
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
},
})
}
function renderToast(queryClient: QueryClient) {
return render(
<QueryClientProvider client={queryClient}>
<SyncStateToast />
</QueryClientProvider>,
)
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('SyncStateToast', () => {
let queryClient: QueryClient
beforeEach(() => {
vi.clearAllMocks()
queryClient = makeQueryClient()
mockLastSyncedUid.value = 'test-uid-123'
})
afterEach(() => {
vi.useRealTimers()
})
it('renders nothing when lastSyncedUid is null', () => {
mockLastSyncedUid.value = null
const { container } = renderToast(queryClient)
expect(container.firstChild).toBeNull()
})
it('pending: renders "Syncing…" with role="status"', async () => {
mockFetchSyncStatus.mockResolvedValue({ uid: 'test-uid-123', status: 'pending' })
renderToast(queryClient)
await waitFor(() => {
expect(screen.getByRole('status')).toBeInTheDocument()
expect(screen.getByText('Syncing…')).toBeInTheDocument()
}, { timeout: 3000 })
})
it('done: renders "Saved"', async () => {
mockFetchSyncStatus.mockResolvedValue({ uid: 'test-uid-123', status: 'done' })
renderToast(queryClient)
await waitFor(() => {
expect(screen.getByText('Saved')).toBeInTheDocument()
}, { timeout: 3000 })
})
it('done: calls queryClient.invalidateQueries for ["events"]', async () => {
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
mockFetchSyncStatus.mockResolvedValue({ uid: 'test-uid-123', status: 'done' })
renderToast(queryClient)
await waitFor(() => {
expect(screen.getByText('Saved')).toBeInTheDocument()
}, { timeout: 3000 })
expect(invalidateSpy).toHaveBeenCalledWith(
expect.objectContaining({ queryKey: ['events'] }),
)
})
it('done: auto-dismisses after 2s via setLastSyncedUid(null)', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
mockFetchSyncStatus.mockResolvedValue({ uid: 'test-uid-123', status: 'done' })
renderToast(queryClient)
// Wait for the "Saved" state (real timer resolution happens via shouldAdvanceTime)
await act(async () => {
await vi.runAllTimersAsync()
})
expect(mockSetLastSyncedUid).toHaveBeenCalledWith(null)
})
it('failed (generic): renders "Didn\'t save. Try again." with role="alert"', async () => {
mockFetchSyncStatus.mockResolvedValue({ uid: 'test-uid-123', status: 'failed' })
renderToast(queryClient)
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument()
expect(screen.getByText("Didn't save. Try again.")).toBeInTheDocument()
}, { timeout: 3000 })
})
it('failed (generic): persists and has a dismiss button', async () => {
mockFetchSyncStatus.mockResolvedValue({ uid: 'test-uid-123', status: 'failed' })
renderToast(queryClient)
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument()
}, { timeout: 3000 })
// Should have a dismiss button
const dismissBtn = screen.getByRole('button', { name: /dismiss/i })
expect(dismissBtn).toBeInTheDocument()
})
it('failed (conflict/412): renders conflict copy and invalidates ["events"]', async () => {
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
mockFetchSyncStatus.mockResolvedValue({
uid: 'test-uid-123',
status: 'failed',
error: '412 Conflict',
})
renderToast(queryClient)
await waitFor(() => {
expect(
screen.getByText('This event changed elsewhere — review the latest version'),
).toBeInTheDocument()
}, { timeout: 3000 })
expect(invalidateSpy).toHaveBeenCalledWith(
expect.objectContaining({ queryKey: ['events'] }),
)
})
it('dead: renders "Not saved. Check your connection." with role="alert"', async () => {
mockFetchSyncStatus.mockResolvedValue({ uid: 'test-uid-123', status: 'dead' })
renderToast(queryClient)
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument()
expect(screen.getByText('Not saved. Check your connection.')).toBeInTheDocument()
}, { timeout: 3000 })
})
it('refetchInterval is active (3000ms) while pending', async () => {
let callCount = 0
mockFetchSyncStatus.mockImplementation(() => {
callCount++
return Promise.resolve({ uid: 'test-uid-123', status: 'pending' })
})
vi.useFakeTimers({ shouldAdvanceTime: true })
renderToast(queryClient)
// Wait for first fetch
await act(async () => {
await vi.advanceTimersByTimeAsync(100)
})
const countAfterFirst = callCount
expect(countAfterFirst).toBeGreaterThanOrEqual(1)
// Advance 3s to trigger refetch
await act(async () => {
await vi.advanceTimersByTimeAsync(3100)
})
expect(callCount).toBeGreaterThan(countAfterFirst)
})
it('dismiss button on failed toast calls setLastSyncedUid(null)', async () => {
mockFetchSyncStatus.mockResolvedValue({ uid: 'test-uid-123', status: 'failed' })
renderToast(queryClient)
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument()
}, { timeout: 3000 })
const dismissBtn = screen.getByRole('button', { name: /dismiss/i })
fireEvent.click(dismissBtn)
expect(mockSetLastSyncedUid).toHaveBeenCalledWith(null)
})
})