/** * 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, beforeEach } from 'vitest'; import { 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', () => { 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 act(() => { 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 act(() => { 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)', () => { queryClient.setQueryData(['list', LIST_ID], makeItemsResponse([])); const optimisticItem: ListItem = { id: -Date.now(), listId: LIST_ID, text: 'milk', checked: false, rank: 'a0', }; act(() => { 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', () => { 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', }; act(() => { queryClient.setQueryData(['list', LIST_ID], (old) => ({ items: [...(old?.items ?? []), optimisticItem], })); }); expect(queryClient.getQueryData(['list', LIST_ID])?.items).toHaveLength(2); // Simulate rollback on error act(() => { 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) }); });