Supply run: a late in-flight read must not overwrite a just-made list (Maestro 4d299127) #199

3 changed files with 80 additions and 9 deletions
+18 -3
View File
@@ -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<string, Responder> {
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, <SupplyRunScreen />)
+52 -4
View File
@@ -99,12 +99,12 @@ function writes(fetchMock: Mock<typeof fetch>) {
// 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(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[path]}>
<SupplyRunPage />
</MemoryRouter>
@@ -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'),
+10 -2
View File
@@ -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<SupplyRun> => {
const path = `${supplyRunPath(weekStart)}/generate`
return parseSupplyRun(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }))
},
onSuccess: (run, _, settle) => {
queryClient.setQueryData<SupplyRun | null>(supplyRunKey(weekStart), run)
onSuccess: async (run, _, settle) => {
await queryClient.cancelQueries({ queryKey: key })
queryClient.setQueryData<SupplyRun | null>(key, run)
settle()
void queryClient.invalidateQueries({ queryKey: key })
},
})
}