diff --git a/apps/pwa/src/components/SyncStateToast.test.tsx b/apps/pwa/src/components/SyncStateToast.test.tsx new file mode 100644 index 0000000..71b4347 --- /dev/null +++ b/apps/pwa/src/components/SyncStateToast.test.tsx @@ -0,0 +1,232 @@ +/** + * 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' + +// ── 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) => 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) { + const { SyncStateToast } = require('./SyncStateToast.js') + return render( + + + , + ) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('SyncStateToast', () => { + let queryClient: QueryClient + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + queryClient = makeQueryClient() + mockLastSyncedUid.value = 'test-uid-123' + }) + + afterEach(() => { + vi.useRealTimers() + vi.resetModules() + }) + + it('renders nothing when lastSyncedUid is null', async () => { + 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() + }) + + it('done: renders "Saved" and auto-dismisses after 2s', async () => { + mockFetchSyncStatus.mockResolvedValue({ uid: 'test-uid-123', status: 'done' }) + + renderToast(queryClient) + + await waitFor(() => { + expect(screen.getByText('Saved')).toBeInTheDocument() + }) + + // Auto-dismiss after 2 seconds + act(() => { + vi.advanceTimersByTime(2000) + }) + + await waitFor(() => { + expect(mockSetLastSyncedUid).toHaveBeenCalledWith(null) + }) + }) + + 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() + }) + + expect(invalidateSpy).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: ['events'] }), + ) + }) + + 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() + }) + + 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() + }) + + // Should not auto-dismiss after 2s + act(() => { + vi.advanceTimersByTime(3000) + }) + expect(screen.getByRole('alert')).toBeInTheDocument() + + // 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() + }) + + 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() + }) + + it('refetchInterval is active (3000ms) while pending', async () => { + let callCount = 0 + mockFetchSyncStatus.mockImplementation(() => { + callCount++ + return Promise.resolve({ uid: 'test-uid-123', status: 'pending' }) + }) + + renderToast(queryClient) + + // Wait for first fetch + await waitFor(() => expect(callCount).toBeGreaterThanOrEqual(1)) + + // Advance time and verify refetch happens + act(() => { + vi.advanceTimersByTime(3000) + }) + + await waitFor(() => expect(callCount).toBeGreaterThanOrEqual(2)) + }) + + 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() + }) + + const dismissBtn = screen.getByRole('button', { name: /dismiss/i }) + fireEvent.click(dismissBtn) + + expect(mockSetLastSyncedUid).toHaveBeenCalledWith(null) + }) + +})