Retry an unread draft by reading the plan again
A succeeded draft whose plan read is missing, another plan, or a cache fallback now retries the plan GET and keeps the job, instead of starting a new draft. Rebuilding stays on "+ Rebuild week". The tests assert Retry sends no draft POST, re-reads the plan, keeps the error while the read is still a cache fallback, and lands the draft on a live matching read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -81,6 +81,11 @@ function posts(fetchMock: Mock<typeof fetch>) {
|
||||
.map(([input, init]) => `${String(input)} ${String(init?.body)}`)
|
||||
}
|
||||
|
||||
// planReads counts the reads of this week's plan.
|
||||
function planReads(fetchMock: Mock<typeof fetch>) {
|
||||
return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === THIS_WEEK).length
|
||||
}
|
||||
|
||||
// renderPage loads /me (for the CSRF token every write needs) and renders
|
||||
// the Plan page.
|
||||
async function renderPage() {
|
||||
@@ -299,7 +304,7 @@ describe('PlanPage', () => {
|
||||
it.each([
|
||||
['the week has no plan', null, 'The week has no plan, not draft plan-1.'],
|
||||
['the week has another plan', { ...planBody('draft'), id: 'plan-9' }, "The week's plan is not draft plan-1."],
|
||||
])('shows a succeeded draft with Retry when %s once read', async (_, landed, message) => {
|
||||
])('shows a succeeded draft with Retry when %s once read, and Retry reads the plan again', async (_, landed, message) => {
|
||||
let plan: unknown = null
|
||||
let job = draftJob('running')
|
||||
const fetchMock = stubServer({
|
||||
@@ -317,18 +322,30 @@ describe('PlanPage', () => {
|
||||
expect(alert).toHaveTextContent(`Couldn't read the draft. ${message}`)
|
||||
expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument()
|
||||
|
||||
job = draftJob('running')
|
||||
// Retry reads the plan again rather than drafting again: the read is
|
||||
// still not the draft.
|
||||
let reads = planReads(fetchMock)
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await waitFor(() => expect(posts(fetchMock)).toHaveLength(2))
|
||||
expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument()
|
||||
await waitFor(() => expect(planReads(fetchMock)).toBe(reads + 1))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(`Couldn't read the draft. ${message}`)
|
||||
expect(posts(fetchMock)).toEqual([`${DRAFT} {}`])
|
||||
|
||||
// The next read is the draft, from the server: it lands.
|
||||
plan = planBody('draft')
|
||||
reads = planReads(fetchMock)
|
||||
fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Retry' }))
|
||||
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
|
||||
expect(planReads(fetchMock)).toBe(reads + 1)
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument()
|
||||
expect(posts(fetchMock)).toEqual([`${DRAFT} {}`])
|
||||
})
|
||||
|
||||
it("doesn't land a succeeded draft on the plan cached before it, and lands it once the server is read", async () => {
|
||||
let plan = planBody('draft')
|
||||
let cached = false
|
||||
let job = draftJob('running')
|
||||
stubServer({
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => {
|
||||
const response = jsonResponse(200, plan)
|
||||
if (cached) response.headers.set(cacheFallbackHeader, '1')
|
||||
@@ -347,10 +364,18 @@ describe('PlanPage', () => {
|
||||
job = draftJob('succeeded')
|
||||
const alert = await screen.findByRole('alert', {}, { timeout: 3000 })
|
||||
expect(alert).toHaveTextContent("Couldn't read the draft. The plan came from this device's cache, not the server")
|
||||
expect(within(alert).getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Beef tacos')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument()
|
||||
|
||||
// Retry reads the plan again and drafts nothing: still the cache, so the
|
||||
// draft stays unread.
|
||||
const reads = planReads(fetchMock)
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await waitFor(() => expect(planReads(fetchMock)).toBe(reads + 1))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't read the draft. The plan came from this device's cache, not the server")
|
||||
expect(screen.queryByText('Beef tacos')).not.toBeInTheDocument()
|
||||
expect(posts(fetchMock)).toEqual([`${DRAFT} {}`])
|
||||
|
||||
// The server is back: the next read of the plan is the draft.
|
||||
plan = planBody('draft', ['Lasagna', ...NAMES.slice(1)])
|
||||
cached = false
|
||||
|
||||
@@ -117,14 +117,14 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
|
||||
|
||||
// The plan read shows once it is the one drafted, read from the server; no
|
||||
// plan, another one, or a copy cached before the draft (a redraft keeps the
|
||||
// plan's id) is an error rather than an empty or stale board. A later read
|
||||
// from the server that is the draft still lands it.
|
||||
// plan's id) is an error rather than an empty or stale board. Its Retry
|
||||
// reads the plan again, keeping the job: a later read from the server that
|
||||
// is the draft still lands it.
|
||||
const { draft } = result
|
||||
const read = draft !== null && readJobId === jobId && plan.isSuccess
|
||||
const landed = read && plan.data?.id === draft.plan_id && !plan.data.cached
|
||||
const resultError =
|
||||
result.error ??
|
||||
(read && !landed
|
||||
const unreadError =
|
||||
read && !landed
|
||||
? new Error(
|
||||
plan.data === null
|
||||
? `The week has no plan, not draft ${draft.plan_id}.`
|
||||
@@ -132,7 +132,8 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
|
||||
? `The week's plan is not draft ${draft.plan_id}.`
|
||||
: `The plan came from this device's cache, not the server, so it may not be draft ${draft.plan_id} yet.`,
|
||||
)
|
||||
: null)
|
||||
: null
|
||||
const resultError = result.error ?? unreadError
|
||||
useEffect(() => {
|
||||
if (landed) setJobId(null)
|
||||
}, [landed])
|
||||
@@ -161,8 +162,10 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
|
||||
|
||||
const failure = startDraft.isError ? (
|
||||
<ErrorBanner onRetry={rebuild}>Couldn't start drafting this week. {startDraft.error.message}</ErrorBanner>
|
||||
) : resultError !== null ? (
|
||||
<ErrorBanner onRetry={rebuild}>Couldn't read the draft. {resultError.message}</ErrorBanner>
|
||||
) : result.error !== null ? (
|
||||
<ErrorBanner onRetry={rebuild}>Couldn't read the draft. {result.error.message}</ErrorBanner>
|
||||
) : unreadError !== null ? (
|
||||
<ErrorBanner onRetry={() => void plan.refetch()}>Couldn't read the draft. {unreadError.message}</ErrorBanner>
|
||||
) : job.error === null ? null : job.status === 'failed' ? (
|
||||
<ErrorBanner onRetry={rebuild}>
|
||||
Couldn't draft this week. {draftFailures[job.error.message] ?? job.error.message}
|
||||
|
||||
Reference in New Issue
Block a user