/** * ListDetail — D-07 optimistic update + rollback tests. * * Tests the React Query optimistic update pattern for: * - Add item: appears immediately before server response * - Check/uncheck: moves between sections immediately * - Delete: removes immediately with no rollback (D-09 delete-wins) * - Rollback: checked state restores if PATCH fails * - Completed items sink to bottom section (D-05) * * Uses React Query's QueryClient directly (no mocked server). */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { renderHook, act } from '@testing-library/react' import { QueryClient } from '@tanstack/react-query' import type { ListItem, ListItemsResponse } from '../api/listsClient.js' // ── Helpers ────────────────────────────────────────────────────────────────── function makeItem(overrides: Partial = {}): ListItem { return { id: 1, listId: 10, text: 'bread', checked: false, rank: 'a0', ...overrides, } } function makeItemsResponse(items: ListItem[]): ListItemsResponse { return { items } } // ── Tests ────────────────────────────────────────────────────────────────── describe('ListDetail — D-07 optimistic update + rollback', () => { let queryClient: QueryClient const LIST_ID = 10 beforeEach(() => { queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }) }) it('checking an item immediately updates the UI before server responds', async () => { const item = makeItem({ checked: false }) queryClient.setQueryData(['list', LIST_ID], makeItemsResponse([item])) // Simulate the onMutate optimistic update (D-07 pattern) await act(async () => { await queryClient.cancelQueries({ queryKey: ['list', LIST_ID] }) queryClient.setQueryData(['list', LIST_ID], (old) => ({ items: (old?.items ?? []).map((i) => i.id === item.id ? { ...i, checked: true } : i, ), })) }) const updated = queryClient.getQueryData(['list', LIST_ID]) expect(updated?.items[0].checked).toBe(true) }) it('unchecking an item immediately updates the UI before server responds', async () => { const item = makeItem({ checked: true }) queryClient.setQueryData(['list', LIST_ID], makeItemsResponse([item])) await act(async () => { await queryClient.cancelQueries({ queryKey: ['list', LIST_ID] }) queryClient.setQueryData(['list', LIST_ID], (old) => ({ items: (old?.items ?? []).map((i) => i.id === item.id ? { ...i, checked: false } : i, ), })) }) const updated = queryClient.getQueryData(['list', LIST_ID]) expect(updated?.items[0].checked).toBe(false) }) it('rolls back the checked state if the server PATCH returns an error', async () => { const item = makeItem({ checked: false }) queryClient.setQueryData(['list', LIST_ID], makeItemsResponse([item])) // Step 1: capture previous state (as onMutate would) const previous = queryClient.getQueryData(['list', LIST_ID]) // Step 2: apply optimistic update await act(async () => { queryClient.setQueryData(['list', LIST_ID], (old) => ({ items: (old?.items ?? []).map((i) => i.id === item.id ? { ...i, checked: true } : i, ), })) }) // Verify it was applied const after = queryClient.getQueryData(['list', LIST_ID]) expect(after?.items[0].checked).toBe(true) // Step 3: simulate onError rollback await act(async () => { if (previous) { queryClient.setQueryData(['list', LIST_ID], previous) } }) // Should be rolled back const rolledBack = queryClient.getQueryData(['list', LIST_ID]) expect(rolledBack?.items[0].checked).toBe(false) }) it('adding an item shows it in the list immediately (optimistic insert)', async () => { queryClient.setQueryData(['list', LIST_ID], makeItemsResponse([])) const optimisticItem: ListItem = { id: -Date.now(), listId: LIST_ID, text: 'milk', checked: false, rank: 'a0', } await act(async () => { queryClient.setQueryData(['list', LIST_ID], (old) => ({ items: [...(old?.items ?? []), optimisticItem], })) }) const data = queryClient.getQueryData(['list', LIST_ID]) expect(data?.items).toHaveLength(1) expect(data?.items[0].text).toBe('milk') // Optimistic item has negative id expect(data?.items[0].id).toBeLessThan(0) }) it('removes the optimistically-added item if the server POST returns an error', async () => { const existingItem = makeItem({ id: 1 }) queryClient.setQueryData( ['list', LIST_ID], makeItemsResponse([existingItem]), ) // Capture previous before optimistic add const previous = queryClient.getQueryData(['list', LIST_ID]) // Apply optimistic add const optimisticItem: ListItem = { id: -999, listId: LIST_ID, text: 'optimistic', checked: false, rank: 'a1', } await act(async () => { queryClient.setQueryData(['list', LIST_ID], (old) => ({ items: [...(old?.items ?? []), optimisticItem], })) }) expect( queryClient.getQueryData(['list', LIST_ID])?.items, ).toHaveLength(2) // Simulate rollback on error await act(async () => { if (previous) queryClient.setQueryData(['list', LIST_ID], previous) }) const rolledBack = queryClient.getQueryData(['list', LIST_ID]) expect(rolledBack?.items).toHaveLength(1) expect(rolledBack?.items[0].id).toBe(1) // original item }) it('completed items sink to the "completed" section at the bottom (D-05)', () => { const activeItem = makeItem({ id: 1, checked: false, rank: 'a0', text: 'active' }) const completedItem = makeItem({ id: 2, checked: true, rank: 'a1', text: 'done' }) const items = [activeItem, completedItem] // The ListDetail splits items into active and completed sections const activeItems = items.filter((i) => !i.checked).sort((a, b) => a.rank < b.rank ? -1 : a.rank > b.rank ? 1 : 0, ) const completedItems = items.filter((i) => i.checked) expect(activeItems).toHaveLength(1) expect(activeItems[0].text).toBe('active') expect(completedItems).toHaveLength(1) expect(completedItems[0].text).toBe('done') // Active comes before completed by data structure (rendered above completed section) }) it('delete removes item immediately with NO rollback (delete-wins D-09)', async () => { const item1 = makeItem({ id: 1, text: 'keep', rank: 'a0' }) const item2 = makeItem({ id: 2, text: 'delete-me', rank: 'a1' }) queryClient.setQueryData( ['list', LIST_ID], makeItemsResponse([item1, item2]), ) // onMutate for delete: remove immediately, no previous state saved (D-09) await act(async () => { await queryClient.cancelQueries({ queryKey: ['list', LIST_ID] }) queryClient.setQueryData(['list', LIST_ID], (old) => ({ items: (old?.items ?? []).filter((i) => i.id !== 2), })) }) const data = queryClient.getQueryData(['list', LIST_ID]) expect(data?.items).toHaveLength(1) expect(data?.items[0].id).toBe(1) // item2 is gone, no rollback mechanism exists (delete-wins) }) })