Reconcile the deck after a decision failure that may not leave the card pending
Only an API error that refuses the request itself (a 4xx other than 404 or 409) puts a failed Side quest back on top of the deck. A 409 (decided by another session or no longer suggested), a 404, a 5xx or a failure without an answer now reads the deck again instead, keeping the card off it until that read answers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -588,10 +588,12 @@ describe('DiscoverPage', () => {
|
|||||||
await screen.findByText('Quest log · 7')
|
await screen.findByText('Quest log · 7')
|
||||||
expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala')
|
expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala')
|
||||||
|
|
||||||
accept.resolve(errorResponse(500, 'internal'))
|
// The server refuses the request itself: the Side quest is still pending.
|
||||||
|
accept.resolve(errorResponse(400, 'invalid_request'))
|
||||||
const alert = await screen.findByRole('alert')
|
const alert = await screen.findByRole('alert')
|
||||||
expect(alert).toHaveTextContent("Couldn't save that choice.")
|
expect(alert).toHaveTextContent("Couldn't save that choice.")
|
||||||
expect(topCard()).toHaveAccessibleName('Korean beef bowls')
|
expect(topCard()).toHaveAccessibleName('Korean beef bowls')
|
||||||
|
expect(deckReads(server.fetchMock)).toBe(1)
|
||||||
expect(screen.getAllByRole('article', { hidden: true }).map((card) => card.querySelector('h2')!.textContent)).toEqual([
|
expect(screen.getAllByRole('article', { hidden: true }).map((card) => card.querySelector('h2')!.textContent)).toEqual([
|
||||||
'Korean beef bowls',
|
'Korean beef bowls',
|
||||||
'Crock pot chicken tikka masala',
|
'Crock pot chicken tikka masala',
|
||||||
@@ -771,6 +773,56 @@ describe('DiscoverPage', () => {
|
|||||||
expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala')
|
expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps a card off the deck when another session already decided it (409), and reads the deck again', async () => {
|
||||||
|
const accept = deferred()
|
||||||
|
let read: ReturnType<typeof deferred> | null = null
|
||||||
|
const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], {
|
||||||
|
'POST /api/v1/suggestions/s1/accept': () => accept.promise,
|
||||||
|
[`GET ${DECK}`]: () => read?.promise ?? jsonResponse(200, { suggestions: server.deck }),
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
await screen.findByRole('article', { name: 'Korean beef bowls' })
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Try it' }))
|
||||||
|
await waitFor(() => expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept']))
|
||||||
|
|
||||||
|
// Another session passed on it first; no read of the deck lands
|
||||||
|
// during the decision.
|
||||||
|
read = deferred()
|
||||||
|
const pending = read
|
||||||
|
server.deck = [TIKKA, ORZO, PAELLA]
|
||||||
|
await act(async () => accept.resolve(errorResponse(409, 'suggestion_decided')))
|
||||||
|
await screen.findByRole('alert')
|
||||||
|
expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument()
|
||||||
|
await waitFor(() => expect(deckReads(server.fetchMock)).toBe(2))
|
||||||
|
expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
await act(async () => pending.resolve(jsonResponse(200, { suggestions: server.deck })))
|
||||||
|
expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument()
|
||||||
|
expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads the deck again after a 500 to a decision, rather than putting the card back', async () => {
|
||||||
|
const decline = deferred()
|
||||||
|
const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], {
|
||||||
|
'POST /api/v1/suggestions/s1/decline': () => decline.promise,
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
await screen.findByRole('article', { name: 'Korean beef bowls' })
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Pass' }))
|
||||||
|
await waitFor(() => expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/decline']))
|
||||||
|
|
||||||
|
// The decision was saved, and the answer to it is a 500 all the same.
|
||||||
|
server.deck = [TIKKA, ORZO, PAELLA]
|
||||||
|
await act(async () => decline.resolve(errorResponse(500, 'internal')))
|
||||||
|
await screen.findByRole('alert')
|
||||||
|
expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument()
|
||||||
|
await waitFor(() => expect(deckReads(server.fetchMock)).toBe(2))
|
||||||
|
expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument()
|
||||||
|
expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala')
|
||||||
|
})
|
||||||
|
|
||||||
it('reads the deck again once a saved decision leaves fewer than 3 cards', async () => {
|
it('reads the deck again once a saved decision leaves fewer than 3 cards', async () => {
|
||||||
const server = stubServer([BOWLS, TIKKA, ORZO], {
|
const server = stubServer([BOWLS, TIKKA, ORZO], {
|
||||||
'POST /api/v1/suggestions/s1/decline': () => {
|
'POST /api/v1/suggestions/s1/decline': () => {
|
||||||
|
|||||||
+19
-12
@@ -146,6 +146,14 @@ type Decided = { taken: boolean; adding: symbol | null; reads: DeckReads }
|
|||||||
// flight: gone is whether the latest left the Side quest out.
|
// flight: gone is whether the latest left the Side quest out.
|
||||||
type DeckReads = { gone: boolean; stop: () => void }
|
type DeckReads = { gone: boolean; stop: () => void }
|
||||||
|
|
||||||
|
// stillPending is whether a failed decision's error shows its Side quest
|
||||||
|
// still pending: an API error answer refusing the request itself. A 404 or
|
||||||
|
// 409 says it is gone or decided already, a 5xx answer may have come after
|
||||||
|
// the decision was saved, and a failure without an answer may have too.
|
||||||
|
function stillPending(error: Error): boolean {
|
||||||
|
return error instanceof ApiError && error.status >= 400 && error.status < 500 && error.status !== 404 && error.status !== 409
|
||||||
|
}
|
||||||
|
|
||||||
// useDecide records Try it (POST /api/v1/suggestions/{id}/accept) or Pass
|
// useDecide records Try it (POST /api/v1/suggestions/{id}/accept) or Pass
|
||||||
// (POST /api/v1/suggestions/{id}/decline) on the Side quest the household
|
// (POST /api/v1/suggestions/{id}/decline) on the Side quest the household
|
||||||
// decided on, whether or not a read of the deck has since taken it away.
|
// decided on, whether or not a read of the deck has since taken it away.
|
||||||
@@ -154,14 +162,15 @@ type DeckReads = { gone: boolean; stop: () => void }
|
|||||||
//
|
//
|
||||||
// The decision is applied at once: the Side quest leaves the deck and, on
|
// The decision is applied at once: the Side quest leaves the deck and, on
|
||||||
// Try it, its dinner joins the Quest log unless it is there already. A
|
// Try it, its dinner joins the Quest log unless it is there already. A
|
||||||
// refused decision puts the Side quest back on top of the deck if it took
|
// failed decision takes out of the Quest log only the entry it added and
|
||||||
// it off and no read of the deck since has left it out, takes out of the
|
// reads the Quest log again, since the decision may have been saved all the
|
||||||
// Quest log only the entry it added, and reads the Quest log again, since
|
// same. One the server refused with the Side quest still pending (see
|
||||||
// the decision may have been saved all the same. A failure without an
|
// stillPending) puts it back on top of the deck if it took it off and no
|
||||||
// answer from the server, which may have saved it, reads the deck again
|
// read of the deck since has left it out; any other failure reads the deck
|
||||||
// too. A saved one reads the Quest log again, and the deck too once fewer
|
// again instead, leaving the Side quest off it until that read answers. A
|
||||||
// than REFILL_BELOW Side quests are left in it or a read of the deck
|
// saved one reads the Quest log again, and the deck too once fewer than
|
||||||
// started before the decision was saved.
|
// REFILL_BELOW Side quests are left in it or a read of the deck started
|
||||||
|
// before the decision was saved.
|
||||||
export function useDecide() {
|
export function useDecide() {
|
||||||
const { apiFetch } = useApi()
|
const { apiFetch } = useApi()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -206,10 +215,8 @@ export function useDecide() {
|
|||||||
},
|
},
|
||||||
onError: (error, { quest }, context) => {
|
onError: (error, { quest }, context) => {
|
||||||
const { taken, adding, reads } = context!
|
const { taken, adding, reads } = context!
|
||||||
if (taken && !reads.gone) queryClient.setQueryData<SideQuest[]>(DECK_KEY, (deck) => deck && [quest, ...deck.filter((q) => q.id !== quest.id)])
|
if (!stillPending(error)) void queryClient.invalidateQueries({ queryKey: DECK_KEY })
|
||||||
// An API error answer is the server refusing the decision; anything
|
else if (taken && !reads.gone) queryClient.setQueryData<SideQuest[]>(DECK_KEY, (deck) => deck && [quest, ...deck.filter((q) => q.id !== quest.id)])
|
||||||
// else may have come after it saved it.
|
|
||||||
if (!(error instanceof ApiError)) void queryClient.invalidateQueries({ queryKey: DECK_KEY })
|
|
||||||
if (adding === null) return
|
if (adding === null) return
|
||||||
// A read of the Quest log that landed meanwhile has replaced the entry.
|
// A read of the Quest log that landed meanwhile has replaced the entry.
|
||||||
queryClient.setQueryData<QuestLogDinner[]>(QUEST_LOG_KEY, (dinners) => dinners?.filter((d) => d.adding !== adding))
|
queryClient.setQueryData<QuestLogDinner[]>(QUEST_LOG_KEY, (dinners) => dinners?.filter((d) => d.adding !== adding))
|
||||||
|
|||||||
Reference in New Issue
Block a user