import { QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { MemoryRouter, Route, Routes } from 'react-router' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' import { createNetworkStatus } from '../pwa/networkStatus.ts' import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx' import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' import { mockViewport } from '../test/matchMedia.ts' import type { ArchivePost } from './api.ts' import { PAGE, SupplyArchivePage } from './SupplyArchivePage.tsx' import styles from './SupplyArchivePage.module.css' import { SupplyRunPage } from './SupplyRunPage.tsx' const ARCHIVE = (by: string) => `/api/v1/shopping/archive?by=${by}` // post is one Supply archive post as GET /api/v1/shopping/archive sends it. function post(weekStart: string, label: string, overrides: Partial = {}): ArchivePost { return { weekStart, label, title: null, dinnerCount: 7, itemCount: 23, onHandCount: 4, dinners: ['Beef tacos', 'Tikka masala', 'Pizza', 'Pot roast', 'Orzo', 'Chili', 'Salmon'], current: false, frozen: true, ...overrides, } } // The posts of mock 1m: this week's titled post, then the weeks across the // September–October boundary and the 2025–2026 one, the last untitled. const WEEK_41 = post('2026-10-05', 'Week 41 · Oct 5 – 11', { title: 'Tacos, tikka & a salmon finale', current: true, frozen: false }) const WEEK_40 = post('2026-09-28', 'Week 40 · Sep 28 – Oct 4', { title: 'Pizza, pot roast & the great orzo trial', itemCount: 26 }) const WEEK_39 = post('2026-09-21', 'Week 39 · Sep 21 – 27', { title: 'Chili season opens', dinnerCount: 1, itemCount: 1, onHandCount: 0, dinners: ['Chili'] }) const WEEK_1 = post('2025-12-29', 'Week 1 · Dec 29 – Jan 4') // The archive as the server groups it: by week each period is one ISO week // headed by its label; by month and year a week belongs to the period of its // Monday, so week 40 is September's and week 1 is 2025's. const BY = { week: ( [ ['2026-W41', WEEK_41], ['2026-W40', WEEK_40], ['2026-W39', WEEK_39], ['2026-W01', WEEK_1], ] as const ).map(([key, p]) => ({ key, heading: p.label, posts: [p] })), month: [ { key: '2026-10', heading: 'October 2026', posts: [WEEK_41] }, { key: '2026-09', heading: 'September 2026', posts: [WEEK_40, WEEK_39] }, { key: '2025-12', heading: 'December 2025', posts: [WEEK_1] }, ], year: [ { key: '2026', heading: '2026', posts: [WEEK_41, WEEK_40, WEEK_39] }, { key: '2025', heading: '2025', posts: [WEEK_1] }, ], } function archive(by: keyof typeof BY) { return () => jsonResponse(200, { by, periods: BY[by] }) } type Responder = (init: RequestInit | undefined) => Response | Promise // stubServer answers "METHOD path" requests from responders, with /me and // the household settings; any other request fails the test. function stubServer(responders: Record) { const all: Record = { 'GET /api/v1/me': () => jsonResponse(200, ME), 'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS), ...responders, } const fetchMock = vi.fn((input, init) => { const key = `${init?.method ?? 'GET'} ${String(input)}` const respond = Object.hasOwn(all, key) ? all[key] : undefined if (respond === undefined) throw new Error(`unexpected fetch: ${key}`) return Promise.resolve(respond(init)) }) vi.stubGlobal('fetch', fetchMock) return fetchMock } // renderPage loads /me (for the CSRF token any write would need, so a // missing PATCH is not for want of it) and renders the Supply archive at path, // with supplyRun at the Supply run's route. async function renderPage(path = '/plan/shopping/archive', supplyRun =

Supply run screen

) { const api = createApiClient(fakeLocation(path), createNetworkStatus(window), createVerifiedSession(localStorage)) await api.loadMe() return render( } /> , ) } // postCard is the post titled title, a link. function postCard(title: string) { return screen.getByText(title).closest('a')! } beforeEach(() => { mockViewport(390) vi.useFakeTimers({ toFake: ['Date'] }) vi.setSystemTime(new Date('2026-10-07T16:00:00Z')) }) afterEach(() => { vi.useRealTimers() vi.unstubAllGlobals() Reflect.deleteProperty(window, 'matchMedia') }) describe('SupplyArchivePage', () => { it('renders mock 1m by week: one post per week with its kicker, title, counts and dinner chips', async () => { stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') }) await renderPage() expect(screen.getByRole('heading', { level: 1, name: 'Supply archive' })).toBeInTheDocument() expect(screen.getByRole('radio', { name: 'Week' })).toBeChecked() const card = await screen.findByText('Pizza, pot roast & the great orzo trial') const post40 = card.closest('a')! expect(within(post40).getByText('Week 40 · Sep 28 – Oct 4')).toHaveClass(styles.kicker!) expect(post40).toHaveTextContent('7 dinners · 26 items · 4 already on hand') expect(within(post40).getByText('Beef tacos')).toHaveClass(styles.chip!) expect(within(post40).getByText('Tikka masala')).toHaveClass(styles.chip!) expect(within(post40).getByText('+5')).toHaveClass(styles.chip!) expect(within(post40).queryByText('Pizza')).not.toBeInTheDocument() expect(postCard('Chili season opens')).toHaveTextContent('1 dinner · 1 item · 0 already on hand') expect(within(postCard('Chili season opens')).queryByText(/^\+/)).not.toBeInTheDocument() expect(screen.getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual([ '/plan/shopping?week=2026-10-05', '/plan/shopping/archive?week=2026-09-28', '/plan/shopping/archive?week=2026-09-21', '/plan/shopping/archive?week=2025-12-29', ]) }) it('renders by week the periods and headings the server sends, as sent', async () => { stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') }) await renderPage() await screen.findByText('Chili season opens') expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual(BY.week.map((period) => period.heading)) expect(screen.getByRole('heading', { level: 2, name: WEEK_40.label })).toHaveClass(styles.heading!) for (const period of BY.week) { const section = screen.getByRole('region', { name: period.heading }) expect(within(section).getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual( period.posts.map((p) => (p.frozen ? `/plan/shopping/archive?week=${p.weekStart}` : `/plan/shopping?week=${p.weekStart}`)), ) } }) it('capitalises the week label kicker in CSS from the label as the API writes it', async () => { stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') }) await renderPage() await screen.findByText('Chili season opens') expect(within(postCard('Chili season opens')).getByText('Week 39 · Sep 21 – 27')).toHaveClass(styles.kicker!) }) it.each([ ['Month', 'month', ['October 2026', 'September 2026', 'December 2025']], ['Year', 'year', ['2026', '2025']], ] as const)('groups by %s under the headings the server sends, across the month and year boundaries', async (label, by, headings) => { const fetchMock = stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week'), [`GET ${ARCHIVE(by)}`]: archive(by) }) await renderPage() await screen.findByText('Chili season opens') fireEvent.click(screen.getByRole('radio', { name: label })) await waitFor(() => expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual(headings)) expect(screen.getByRole('heading', { level: 2, name: headings[0] })).toHaveClass(styles.heading!) expect(fetchMock.mock.calls.map(([input]) => String(input))).toContain(ARCHIVE(by)) for (const period of BY[by]) { const section = screen.getByRole('region', { name: period.heading }) expect(within(section).getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual( period.posts.map((p) => (p.frozen ? `/plan/shopping/archive?week=${p.weekStart}` : `/plan/shopping?week=${p.weekStart}`)), ) } }) it('highlights the current week’s post, by the API’s current, and no other', async () => { stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') }) await renderPage() const current = (await screen.findByText('Tacos, tikka & a salmon finale')).closest('a')! expect(current).toHaveClass(styles.current!) expect(within(current).getByText('Week 41 · Oct 5 – 11 · Current')).toBeInTheDocument() for (const title of ['Pizza, pot roast & the great orzo trial', 'Chili season opens']) { expect(postCard(title)).not.toHaveClass(styles.current!) expect(postCard(title)).not.toHaveTextContent('Current') } }) it('follows current from the API, not today’s date', async () => { const past = { ...WEEK_40, current: true } stubServer({ [`GET ${ARCHIVE('week')}`]: () => jsonResponse(200, { by: 'week', periods: [{ key: 'w', heading: past.label, posts: [{ ...WEEK_41, current: false }, past] }] }) }) await renderPage() expect(await screen.findByText('Week 40 · Sep 28 – Oct 4 · Current')).toBeInTheDocument() expect(postCard('Tacos, tikka & a salmon finale')).not.toHaveClass(styles.current!) }) it('titles an untitled post with its week label', async () => { stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') }) await renderPage() await screen.findByText('Chili season opens') const untitled = screen.getByRole('link', { name: /Week 1 · Dec 29 – Jan 4/ }) const label = within(untitled).getAllByText('Week 1 · Dec 29 – Jan 4') expect(label.map((el) => el.className)).toEqual([styles.kicker, styles.postTitle]) }) it(`shows ${PAGE} posts and "Load older" shows the next ${PAGE}, none twice and none skipped`, async () => { const weeks = Array.from({ length: 2 * PAGE + 3 }, (_, i) => { const monday = new Date(Date.UTC(2026, 9, 5 - 7 * i)).toISOString().slice(0, 10) return post(monday, `Week of ${monday}`, { title: `Post ${i}` }) }) // By month, so the cut falls inside a period and its heading is shown once. const periods = [ { key: 'a', heading: 'Newer', posts: weeks.slice(0, PAGE + 2) }, { key: 'b', heading: 'Older', posts: weeks.slice(PAGE + 2) }, ] stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week'), [`GET ${ARCHIVE('month')}`]: () => jsonResponse(200, { by: 'month', periods }), }) await renderPage() await screen.findByText('Chili season opens') fireEvent.click(screen.getByRole('radio', { name: 'Month' })) const titles = () => screen.getAllByRole('link').map((link) => within(link).getByText(/^Post /).textContent) await screen.findByText('Post 0') expect(titles()).toEqual(weeks.slice(0, PAGE).map((p) => p.title)) expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual(['Newer']) fireEvent.click(screen.getByRole('button', { name: 'Load older' })) expect(titles()).toEqual(weeks.slice(0, 2 * PAGE).map((p) => p.title)) expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual(['Newer', 'Older']) fireEvent.click(screen.getByRole('button', { name: 'Load older' })) expect(titles()).toEqual(weeks.map((p) => p.title)) expect(screen.queryByRole('button', { name: 'Load older' })).not.toBeInTheDocument() }) it('offers no "Load older" when every post is shown', async () => { stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') }) await renderPage() await screen.findByText('Chili season opens') expect(screen.queryByRole('button', { name: 'Load older' })).not.toBeInTheDocument() }) it('shows skeleton posts while the archive loads', async () => { stubServer({ [`GET ${ARCHIVE('week')}`]: () => new Promise(() => {}) }) await renderPage() expect(screen.getByLabelText('Supply archive loading')).toHaveAttribute('aria-busy', 'true') expect(screen.getAllByTestId('skeleton-post')).toHaveLength(3) }) it('says so when nothing is archived yet', async () => { stubServer({ [`GET ${ARCHIVE('week')}`]: () => jsonResponse(200, { by: 'week', periods: [] }) }) await renderPage() expect(await screen.findByText('Nothing archived yet')).toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Load older' })).not.toBeInTheDocument() }) it('reports a failed load and retries it', async () => { let reads = 0 stubServer({ [`GET ${ARCHIVE('week')}`]: () => (++reads === 1 ? errorResponse(500, 'internal') : archive('week')()), }) await renderPage() const alert = await screen.findByRole('alert') expect(alert).toHaveTextContent("Couldn't load the Supply archive.") fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) expect(await screen.findByText('Chili season opens')).toBeInTheDocument() expect(reads).toBe(2) }) it('reports a response that is not the archive by the period asked for', async () => { stubServer({ [`GET ${ARCHIVE('week')}`]: () => jsonResponse(200, { by: 'month', periods: [] }) }) await renderPage() expect(await screen.findByRole('alert')).toHaveTextContent(`GET ${ARCHIVE('week')}: response is not the Supply archive by week`) }) it('goes back to the Supply run', async () => { stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') }) await renderPage() fireEvent.click(screen.getByRole('button', { name: 'Back to the Supply run' })) expect(await screen.findByText('Supply run screen')).toBeInTheDocument() }) }) // The frozen Supply run of week 40, as GET /api/v1/shopping/{weekStart} // sends it. const FROZEN = { weekStart: '2026-09-28', label: 'Week 40 · Sep 28 – Oct 4', title: 'Pizza, pot roast & the great orzo trial', status: 'archived', sent_to_chat_at: null, chat_send_unconfirmed_at: null, needed_count: 1, total_count: 2, groups: [ { dinner: { id: 'd1', name: 'Friday pizza' }, date: '2026-10-02', items: [ { id: 'i1', name: 'Pizza dough', qty: '2 balls', needed: true }, { id: 'i2', name: 'Mozzarella', qty: '1 lb', needed: false }, ], }, ], } // The current week's Supply run, week 41, still current: the API's archive // includes it, highlighted. const CURRENT = { ...FROZEN, weekStart: '2026-10-05', label: 'Week 41 · Oct 5 – 11', title: 'Tacos, tikka & a salmon finale', status: 'current', groups: [ { dinner: { id: 'd2', name: 'Beef tacos' }, date: '2026-10-05', items: [ { id: 'i3', name: 'Corn tortillas', qty: '12', needed: true }, { id: 'i4', name: 'Limes', qty: '3', needed: false }, ], }, ], } describe('SupplyArchivePage frozen post', () => { it('opens a tapped post’s frozen list read-only, tagged Archived, sending no PATCH', async () => { const fetchMock = stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week'), 'GET /api/v1/shopping/2026-09-28': () => jsonResponse(200, FROZEN), }) await renderPage() fireEvent.click(await screen.findByText('Pizza, pot roast & the great orzo trial')) expect(await screen.findByText('Archived')).toBeInTheDocument() expect(screen.getByRole('heading', { level: 1, name: /Shopping list/ })).toBeInTheDocument() expect(screen.getByText('Pizza dough')).toBeInTheDocument() expect(screen.queryAllByRole('checkbox')).toEqual([]) fireEvent.click(screen.getByText('Pizza dough')) fireEvent.click(screen.getByText('Mozzarella')) expect(fetchMock.mock.calls.filter(([, init]) => init?.method !== undefined && init.method !== 'GET')).toEqual([]) // The list links back to the archive. fireEvent.click(screen.getByRole('link', { name: 'Supply archive' })) expect(await screen.findByRole('heading', { level: 1, name: 'Supply archive' })).toBeInTheDocument() }) it('opens the current week’s post, not yet frozen, in the Supply run, where it can still change', async () => { stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week'), 'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, CURRENT), }) await renderPage('/plan/shopping/archive', ) fireEvent.click(await screen.findByText('Tacos, tikka & a salmon finale')) expect(await screen.findByRole('checkbox', { name: 'Corn tortillas' })).toBeEnabled() expect(screen.queryByText('Archived')).not.toBeInTheDocument() }) it('marks a past week’s post not yet frozen Still open and opens it in the Supply run, where it can still change', async () => { const open = { ...WEEK_40, frozen: false } stubServer({ [`GET ${ARCHIVE('week')}`]: () => jsonResponse(200, { by: 'week', periods: [{ key: '2026-W40', heading: open.label, posts: [open] }] }), 'GET /api/v1/shopping/2026-09-28': () => jsonResponse(200, { ...FROZEN, status: 'current' }), }) await renderPage('/plan/shopping/archive', ) await screen.findByText('Pizza, pot roast & the great orzo trial') const card = postCard('Pizza, pot roast & the great orzo trial') expect(within(card).getByText('Week 40 · Sep 28 – Oct 4 · Still open')).toHaveClass(styles.kicker!) expect(card).not.toHaveClass(styles.current!) expect(card).toHaveAttribute('href', '/plan/shopping?week=2026-09-28') fireEvent.click(card) expect(await screen.findByRole('checkbox', { name: 'Pizza dough' })).toBeEnabled() expect(screen.queryByText('Archived')).not.toBeInTheDocument() }) it('marks no frozen post Still open', async () => { stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') }) await renderPage() await screen.findByText('Chili season opens') expect(screen.queryByText(/Still open/)).not.toBeInTheDocument() }) it('opens a saved link to a past week’s list not yet frozen in the editable Supply run, not read-only', async () => { stubServer({ 'GET /api/v1/shopping/2026-09-28': () => jsonResponse(200, { ...FROZEN, status: 'current' }) }) await renderPage('/plan/shopping/archive?week=2026-09-28', ) expect(await screen.findByRole('checkbox', { name: 'Pizza dough' })).toBeEnabled() expect(screen.queryByText('Archived')).not.toBeInTheDocument() expect(screen.getByText("Uncheck anything you already have — it won't be counted.")).toBeInTheDocument() }) it('on desktop opens a saved link to the current week’s list, not yet frozen, in the editable Supply run, as with no post open', async () => { mockViewport(1440) stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week'), 'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, CURRENT), }) const view = await renderPage('/plan/shopping/archive?week=2026-10-05', ) expect(await screen.findByRole('checkbox', { name: 'Corn tortillas' })).toBeEnabled() expect(screen.queryByText('Archived')).not.toBeInTheDocument() expect(screen.queryByRole('complementary', { name: 'Supply archive' })).not.toBeInTheDocument() view.unmount() await renderPage() expect(await screen.findByRole('checkbox', { name: 'Corn tortillas' })).toBeInTheDocument() expect(screen.queryByText('Archived')).not.toBeInTheDocument() }) it('on desktop (mock 2f) shows the archive as a panel beside the open post’s list', async () => { mockViewport(1440) stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week'), 'GET /api/v1/shopping/2026-09-28': () => jsonResponse(200, FROZEN), }) await renderPage('/plan/shopping/archive?week=2026-09-28') const panel = screen.getByRole('complementary', { name: 'Supply archive' }) expect(await within(panel).findByText('Chili season opens')).toBeInTheDocument() expect(within(panel).queryByRole('button', { name: 'Back to the Supply run' })).not.toBeInTheDocument() expect(await screen.findByText('Archived')).toBeInTheDocument() expect(panel).not.toContainElement(screen.getByText('Pizza dough')) expect(screen.queryAllByRole('checkbox')).toEqual([]) }) })