feat(03-06): implement SyncStateToast with polled sync-status feedback (D-05/D-06/D-08/D-09)

- SyncStateToast: pending/done/failed/dead states per UI-SPEC
- refetchInterval 3000ms while pending; disabled on terminal status
- done + conflict (412) invalidate ['events'] cache (D-06/D-08)
- done auto-dismisses after 2s; failed/dead persist with dismiss button
- role=status (pending/done) and role=alert (failed/dead) for a11y
- Mounted in CalendarShell (both phone + tablet/desktop layouts)
- EventForm.onSuccess: setLastSyncedUid(uid) instead of invalidateQueries
This commit is contained in:
Lucas Berger
2026-06-05 18:43:36 -04:00
parent 6874e1a074
commit aa7c4c37d4
5 changed files with 286 additions and 50 deletions
+37 -36
View File
@@ -16,6 +16,7 @@ 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)
@@ -55,7 +56,6 @@ function makeQueryClient() {
}
function renderToast(queryClient: QueryClient) {
const { SyncStateToast } = require('./SyncStateToast.js')
return render(
<QueryClientProvider client={queryClient}>
<SyncStateToast />
@@ -70,14 +70,12 @@ describe('SyncStateToast', () => {
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 () => {
@@ -93,27 +91,18 @@ describe('SyncStateToast', () => {
await waitFor(() => {
expect(screen.getByRole('status')).toBeInTheDocument()
})
expect(screen.getByText('Syncing…')).toBeInTheDocument()
expect(screen.getByText('Syncing…')).toBeInTheDocument()
}, { timeout: 3000 })
})
it('done: renders "Saved" and auto-dismisses after 2s', async () => {
it('done: renders "Saved"', 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)
})
}, { timeout: 3000 })
})
it('done: calls queryClient.invalidateQueries for ["events"]', async () => {
@@ -124,13 +113,27 @@ describe('SyncStateToast', () => {
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' })
@@ -138,8 +141,8 @@ describe('SyncStateToast', () => {
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument()
})
expect(screen.getByText("Didn't save. Try again.")).toBeInTheDocument()
expect(screen.getByText("Didn't save. Try again.")).toBeInTheDocument()
}, { timeout: 3000 })
})
it('failed (generic): persists and has a dismiss button', async () => {
@@ -149,13 +152,7 @@ describe('SyncStateToast', () => {
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument()
})
// Should not auto-dismiss after 2s
act(() => {
vi.advanceTimersByTime(3000)
})
expect(screen.getByRole('alert')).toBeInTheDocument()
}, { timeout: 3000 })
// Should have a dismiss button
const dismissBtn = screen.getByRole('button', { name: /dismiss/i })
@@ -176,7 +173,7 @@ describe('SyncStateToast', () => {
expect(
screen.getByText('This event changed elsewhere — review the latest version'),
).toBeInTheDocument()
})
}, { timeout: 3000 })
expect(invalidateSpy).toHaveBeenCalledWith(
expect.objectContaining({ queryKey: ['events'] }),
@@ -190,8 +187,8 @@ describe('SyncStateToast', () => {
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument()
})
expect(screen.getByText('Not saved. Check your connection.')).toBeInTheDocument()
expect(screen.getByText('Not saved. Check your connection.')).toBeInTheDocument()
}, { timeout: 3000 })
})
it('refetchInterval is active (3000ms) while pending', async () => {
@@ -201,17 +198,22 @@ describe('SyncStateToast', () => {
return Promise.resolve({ uid: 'test-uid-123', status: 'pending' })
})
vi.useFakeTimers({ shouldAdvanceTime: true })
renderToast(queryClient)
// Wait for first fetch
await waitFor(() => expect(callCount).toBeGreaterThanOrEqual(1))
await act(async () => {
await vi.advanceTimersByTimeAsync(100)
})
const countAfterFirst = callCount
expect(countAfterFirst).toBeGreaterThanOrEqual(1)
// Advance time and verify refetch happens
act(() => {
vi.advanceTimersByTime(3000)
// Advance 3s to trigger refetch
await act(async () => {
await vi.advanceTimersByTimeAsync(3100)
})
await waitFor(() => expect(callCount).toBeGreaterThanOrEqual(2))
expect(callCount).toBeGreaterThan(countAfterFirst)
})
it('dismiss button on failed toast calls setLastSyncedUid(null)', async () => {
@@ -221,12 +223,11 @@ describe('SyncStateToast', () => {
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument()
})
}, { timeout: 3000 })
const dismissBtn = screen.getByRole('button', { name: /dismiss/i })
fireEvent.click(dismissBtn)
expect(mockSetLastSyncedUid).toHaveBeenCalledWith(null)
})
})