diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx index d9ceca8..08bb664 100644 --- a/web/src/plan/PlanPage.test.tsx +++ b/web/src/plan/PlanPage.test.tsx @@ -237,6 +237,21 @@ function supplyRun(needed: number) { } } +// makesSupplyRun answers POST /api/v1/shopping/2026-10-05/generate with +// generate and, as the server does, GET of the week's Supply run with the +// one made once generate has made it: until then the week has none. +function makesSupplyRun(generate: () => Response): Record { + let made = false + return { + 'GET /api/v1/shopping/2026-10-05': () => (made ? jsonResponse(200, supplyRun(28)) : errorResponse(404, 'not_found')), + 'POST /api/v1/shopping/2026-10-05/generate': () => { + const response = generate() + made ||= response.ok + return response + }, + } +} + // Wednesday Oct 7, noon in New York: before Friday's 09:00 plan_draft, so // the page opens on this week. beforeEach(() => { @@ -553,7 +568,7 @@ describe('PlanPage', () => { const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), [`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)), - 'POST /api/v1/shopping/2026-10-05/generate': () => generate(), + ...makesSupplyRun(() => generate()), }) await renderPage() @@ -585,7 +600,7 @@ describe('PlanPage', () => { stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), [`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)), - 'POST /api/v1/shopping/2026-10-05/generate': () => jsonResponse(200, { ...supplyRun(28), skipped: [] }), + ...makesSupplyRun(() => jsonResponse(200, { ...supplyRun(28), skipped: [] })), }) await renderPage() @@ -670,7 +685,7 @@ describe('PlanPage', () => { stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), [`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)), - 'POST /api/v1/shopping/2026-10-05/generate': () => jsonResponse(200, { ...supplyRun(28), skipped: [] }), + ...makesSupplyRun(() => jsonResponse(200, { ...supplyRun(28), skipped: [] })), }) const queryClient = createQueryClient() await renderPage(queryClient, ) diff --git a/web/src/shopping/SupplyRunPage.test.tsx b/web/src/shopping/SupplyRunPage.test.tsx index 64a0f70..ae94561 100644 --- a/web/src/shopping/SupplyRunPage.test.tsx +++ b/web/src/shopping/SupplyRunPage.test.tsx @@ -99,12 +99,12 @@ function writes(fetchMock: Mock) { // renderPage loads /me (for the CSRF token every write needs) and renders // the Supply run at path. -async function renderPage(path = '/plan/shopping') { +async function renderPage(path = '/plan/shopping', queryClient = createQueryClient()) { const api = createApiClient(fakeLocation('/plan/shopping'), createNetworkStatus(window), createVerifiedSession(localStorage)) await api.loadMe() return render( - + @@ -563,9 +563,13 @@ describe('SupplyRunPage', () => { }) it('offers to make the list when the week has none, and shows the list made', async () => { + let made = false const fetchMock = stubServer({ - [`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found'), - [`POST ${GENERATE}`]: () => jsonResponse(200, { ...supplyRun(), skipped: [] }), + [`GET ${THIS_WEEK}`]: () => (made ? jsonResponse(200, supplyRun()) : errorResponse(404, 'not_found')), + [`POST ${GENERATE}`]: () => { + made = true + return jsonResponse(200, { ...supplyRun(), skipped: [] }) + }, }) await renderPage() @@ -575,6 +579,50 @@ describe('SupplyRunPage', () => { expect(writes(fetchMock)).toEqual([`POST ${GENERATE} undefined`]) }) + it('keeps the list made when a read sent before it was made answers after it', async () => { + const queryClient = createQueryClient() + const made = deferred() + const late = deferred() + const again = deferred() + let reads = 0 + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => { + reads++ + if (reads === 1) return errorResponse(404, 'not_found') + return reads === 2 ? late.response : again.response + }, + [`POST ${GENERATE}`]: () => made.response, + }) + await renderPage('/plan/shopping', queryClient) + + fireEvent.click(await screen.findByRole('button', { name: 'Make the shopping list' })) + // A read of the week sent while the list is being made, so from before it. + void queryClient.invalidateQueries({ queryKey: ['shopping', '2026-10-05'] }) + await waitFor(() => expect(reads).toBe(2)) + made.send(jsonResponse(200, { ...supplyRun(), skipped: [] })) + expect(await screen.findByText('4 / 6')).toBeInTheDocument() + // The list made is written, and the week read again from the server. + await waitFor(() => expect(reads).toBe(3)) + + // The read from before the list was made answers only now. + late.send(errorResponse(404, 'not_found')) + await late.response + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(screen.getByText('4 / 6')).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'No shopping list yet' })).not.toBeInTheDocument() + + // The read sent after it was made is the one shown: by then the + // household has the ground beef. + const groups = GROUPS.map((group, g) => ({ + ...group, + items: group.items.map((item, i) => (g === 0 && i === 0 ? { ...item, needed: false } : item)), + })) + again.send(jsonResponse(200, supplyRun({ needed_count: 3, groups }))) + expect(await screen.findByText('3 / 6')).toBeInTheDocument() + expect(reads).toBe(3) + expect(writes(fetchMock)).toEqual([`POST ${GENERATE} undefined`]) + }) + it('says why the list could not be made', async () => { stubServer({ [`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found'), diff --git a/web/src/shopping/api.ts b/web/src/shopping/api.ts index a103fa3..8d3095a 100644 --- a/web/src/shopping/api.ts +++ b/web/src/shopping/api.ts @@ -194,18 +194,26 @@ export function useSupplyRun(weekStart: string) { // /api/v1/shopping/{weekStart}/generate); the Supply run it answers with is // the one shown. Once made, from whichever screen, a push of the week whose // Supply run was not made is settled (settleUnmadePushes). +// +// A read of the week's Supply run still in flight as the answer arrives may +// have been sent before the Supply run was made: it is cancelled before the +// answer is written, so it cannot land over it, and the week is then read +// again from the server. export function useGenerateSupplyRun(weekStart: string) { const { apiFetch } = useApi() const queryClient = useQueryClient() + const key = supplyRunKey(weekStart) return useMutation({ onMutate: () => settleUnmadePushes(queryClient, weekStart), mutationFn: async (): Promise => { const path = `${supplyRunPath(weekStart)}/generate` return parseSupplyRun(`POST ${path}`, weekStart, await apiFetch(path, { method: 'POST' })) }, - onSuccess: (run, _, settle) => { - queryClient.setQueryData(supplyRunKey(weekStart), run) + onSuccess: async (run, _, settle) => { + await queryClient.cancelQueries({ queryKey: key }) + queryClient.setQueryData(key, run) settle() + void queryClient.invalidateQueries({ queryKey: key }) }, }) }