/** * 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(() => { // Declare with explicit type so later null assignments (line 82+) stay type-safe const mockLastSyncedUid: { value: string | null } = { value: 'test-uid-123' }; return { mockLastSyncedUid, 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) { return render( , ); } // ── 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); }); });