test(03-06): add failing tests for SyncStateToast all states and polling
This commit is contained in:
@@ -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<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) {
|
||||||
|
const { SyncStateToast } = require('./SyncStateToast.js')
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<SyncStateToast />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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)
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user