Supply run: a late in-flight read must not overwrite a just-made list (Maestro 4d299127) (#199)
CI / ci (push) Successful in 8m30s
CI / ci (push) Successful in 8m30s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #199.
This commit is contained in:
@@ -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
|
// Wednesday Oct 7, noon in New York: before Friday's 09:00 plan_draft, so
|
||||||
// the page opens on this week.
|
// the page opens on this week.
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -553,7 +568,7 @@ describe('PlanPage', () => {
|
|||||||
const fetchMock = stubServer({
|
const fetchMock = stubServer({
|
||||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||||
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
|
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
|
||||||
'POST /api/v1/shopping/2026-10-05/generate': () => generate(),
|
...makesSupplyRun(() => generate()),
|
||||||
})
|
})
|
||||||
await renderPage()
|
await renderPage()
|
||||||
|
|
||||||
@@ -585,7 +600,7 @@ describe('PlanPage', () => {
|
|||||||
stubServer({
|
stubServer({
|
||||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||||
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
|
[`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()
|
await renderPage()
|
||||||
|
|
||||||
@@ -670,7 +685,7 @@ describe('PlanPage', () => {
|
|||||||
stubServer({
|
stubServer({
|
||||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||||
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
|
[`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()
|
const queryClient = createQueryClient()
|
||||||
await renderPage(queryClient, <SupplyRunScreen />)
|
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
|
// renderPage loads /me (for the CSRF token every write needs) and renders
|
||||||
// the Supply run at path.
|
// 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))
|
const api = createApiClient(fakeLocation('/plan/shopping'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||||
await api.loadMe()
|
await api.loadMe()
|
||||||
return render(
|
return render(
|
||||||
<ApiProvider client={api}>
|
<ApiProvider client={api}>
|
||||||
<QueryClientProvider client={createQueryClient()}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<MemoryRouter initialEntries={[path]}>
|
<MemoryRouter initialEntries={[path]}>
|
||||||
<SupplyRunPage />
|
<SupplyRunPage />
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
@@ -563,9 +563,13 @@ describe('SupplyRunPage', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('offers to make the list when the week has none, and shows the list made', async () => {
|
it('offers to make the list when the week has none, and shows the list made', async () => {
|
||||||
|
let made = false
|
||||||
const fetchMock = stubServer({
|
const fetchMock = stubServer({
|
||||||
[`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found'),
|
[`GET ${THIS_WEEK}`]: () => (made ? jsonResponse(200, supplyRun()) : errorResponse(404, 'not_found')),
|
||||||
[`POST ${GENERATE}`]: () => jsonResponse(200, { ...supplyRun(), skipped: [] }),
|
[`POST ${GENERATE}`]: () => {
|
||||||
|
made = true
|
||||||
|
return jsonResponse(200, { ...supplyRun(), skipped: [] })
|
||||||
|
},
|
||||||
})
|
})
|
||||||
await renderPage()
|
await renderPage()
|
||||||
|
|
||||||
@@ -575,6 +579,50 @@ describe('SupplyRunPage', () => {
|
|||||||
expect(writes(fetchMock)).toEqual([`POST ${GENERATE} undefined`])
|
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 () => {
|
it('says why the list could not be made', async () => {
|
||||||
stubServer({
|
stubServer({
|
||||||
[`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found'),
|
[`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
|
// /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
|
// the one shown. Once made, from whichever screen, a push of the week whose
|
||||||
// Supply run was not made is settled (settleUnmadePushes).
|
// 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) {
|
export function useGenerateSupplyRun(weekStart: string) {
|
||||||
const { apiFetch } = useApi()
|
const { apiFetch } = useApi()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const key = supplyRunKey(weekStart)
|
||||||
return useMutation({
|
return useMutation({
|
||||||
onMutate: () => settleUnmadePushes(queryClient, weekStart),
|
onMutate: () => settleUnmadePushes(queryClient, weekStart),
|
||||||
mutationFn: async (): Promise<SupplyRun> => {
|
mutationFn: async (): Promise<SupplyRun> => {
|
||||||
const path = `${supplyRunPath(weekStart)}/generate`
|
const path = `${supplyRunPath(weekStart)}/generate`
|
||||||
return parseSupplyRun(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }))
|
return parseSupplyRun(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }))
|
||||||
},
|
},
|
||||||
onSuccess: (run, _, settle) => {
|
onSuccess: async (run, _, settle) => {
|
||||||
queryClient.setQueryData<SupplyRun | null>(supplyRunKey(weekStart), run)
|
await queryClient.cancelQueries({ queryKey: key })
|
||||||
|
queryClient.setQueryData<SupplyRun | null>(key, run)
|
||||||
settle()
|
settle()
|
||||||
|
void queryClient.invalidateQueries({ queryKey: key })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user