Supply run: a late in-flight read must not overwrite a just-made list (Maestro 4d299127) #199
@@ -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 />)
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user