Files
familysync/apps/pwa/src/routes/ListDetail.test.tsx
T
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
2026-06-11 20:35:18 -04:00

210 lines
7.7 KiB
TypeScript

/**
* 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> = {}): 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<ListItemsResponse>(['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<ListItemsResponse>(['list', LIST_ID], (old) => ({
items: (old?.items ?? []).map((i) => (i.id === item.id ? { ...i, checked: true } : i)),
}));
});
const updated = queryClient.getQueryData<ListItemsResponse>(['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<ListItemsResponse>(['list', LIST_ID], makeItemsResponse([item]));
await act(async () => {
await queryClient.cancelQueries({ queryKey: ['list', LIST_ID] });
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
items: (old?.items ?? []).map((i) => (i.id === item.id ? { ...i, checked: false } : i)),
}));
});
const updated = queryClient.getQueryData<ListItemsResponse>(['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<ListItemsResponse>(['list', LIST_ID], makeItemsResponse([item]));
// Step 1: capture previous state (as onMutate would)
const previous = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID]);
// Step 2: apply optimistic update
act(() => {
queryClient.setQueryData<ListItemsResponse>(['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<ListItemsResponse>(['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<ListItemsResponse>(['list', LIST_ID]);
expect(rolledBack?.items[0].checked).toBe(false);
});
it('adding an item shows it in the list immediately (optimistic insert)', () => {
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], makeItemsResponse([]));
const optimisticItem: ListItem = {
id: -Date.now(),
listId: LIST_ID,
text: 'milk',
checked: false,
rank: 'a0',
};
act(() => {
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
items: [...(old?.items ?? []), optimisticItem],
}));
});
const data = queryClient.getQueryData<ListItemsResponse>(['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<ListItemsResponse>(
['list', LIST_ID],
makeItemsResponse([existingItem]),
);
// Capture previous before optimistic add
const previous = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID]);
// Apply optimistic add
const optimisticItem: ListItem = {
id: -999,
listId: LIST_ID,
text: 'optimistic',
checked: false,
rank: 'a1',
};
act(() => {
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
items: [...(old?.items ?? []), optimisticItem],
}));
});
expect(queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])?.items).toHaveLength(2);
// Simulate rollback on error
act(() => {
if (previous) queryClient.setQueryData(['list', LIST_ID], previous);
});
const rolledBack = queryClient.getQueryData<ListItemsResponse>(['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<ListItemsResponse>(
['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<ListItemsResponse>(['list', LIST_ID], (old) => ({
items: (old?.items ?? []).filter((i) => i.id !== 2),
}));
});
const data = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID]);
expect(data?.items).toHaveLength(1);
expect(data?.items[0].id).toBe(1);
// item2 is gone, no rollback mechanism exists (delete-wins)
});
});