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)}`)
|
.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
|
// renderPage loads /me (for the CSRF token every write needs) and renders
|
||||||
// the Plan page.
|
// the Plan page.
|
||||||
async function renderPage() {
|
async function renderPage() {
|
||||||
@@ -299,7 +304,7 @@ describe('PlanPage', () => {
|
|||||||
it.each([
|
it.each([
|
||||||
['the week has no plan', null, 'The week has no plan, not draft plan-1.'],
|
['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."],
|
['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 plan: unknown = null
|
||||||
let job = draftJob('running')
|
let job = draftJob('running')
|
||||||
const fetchMock = stubServer({
|
const fetchMock = stubServer({
|
||||||
@@ -317,18 +322,30 @@ describe('PlanPage', () => {
|
|||||||
expect(alert).toHaveTextContent(`Couldn't read the draft. ${message}`)
|
expect(alert).toHaveTextContent(`Couldn't read the draft. ${message}`)
|
||||||
expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument()
|
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' }))
|
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||||
await waitFor(() => expect(posts(fetchMock)).toHaveLength(2))
|
await waitFor(() => expect(planReads(fetchMock)).toBe(reads + 1))
|
||||||
expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument()
|
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.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 () => {
|
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 plan = planBody('draft')
|
||||||
let cached = false
|
let cached = false
|
||||||
let job = draftJob('running')
|
let job = draftJob('running')
|
||||||
stubServer({
|
const fetchMock = stubServer({
|
||||||
[`GET ${THIS_WEEK}`]: () => {
|
[`GET ${THIS_WEEK}`]: () => {
|
||||||
const response = jsonResponse(200, plan)
|
const response = jsonResponse(200, plan)
|
||||||
if (cached) response.headers.set(cacheFallbackHeader, '1')
|
if (cached) response.headers.set(cacheFallbackHeader, '1')
|
||||||
@@ -347,10 +364,18 @@ describe('PlanPage', () => {
|
|||||||
job = draftJob('succeeded')
|
job = draftJob('succeeded')
|
||||||
const alert = await screen.findByRole('alert', {}, { timeout: 3000 })
|
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(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.queryByText('Beef tacos')).not.toBeInTheDocument()
|
||||||
expect(screen.queryByTestId('skeleton-tile')).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.
|
// The server is back: the next read of the plan is the draft.
|
||||||
plan = planBody('draft', ['Lasagna', ...NAMES.slice(1)])
|
plan = planBody('draft', ['Lasagna', ...NAMES.slice(1)])
|
||||||
cached = false
|
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
|
// 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, 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
|
// plan's id) is an error rather than an empty or stale board. Its Retry
|
||||||
// from the server that is the draft still lands it.
|
// reads the plan again, keeping the job: a later read from the server that
|
||||||
|
// is the draft still lands it.
|
||||||
const { draft } = result
|
const { draft } = result
|
||||||
const read = draft !== null && readJobId === jobId && plan.isSuccess
|
const read = draft !== null && readJobId === jobId && plan.isSuccess
|
||||||
const landed = read && plan.data?.id === draft.plan_id && !plan.data.cached
|
const landed = read && plan.data?.id === draft.plan_id && !plan.data.cached
|
||||||
const resultError =
|
const unreadError =
|
||||||
result.error ??
|
read && !landed
|
||||||
(read && !landed
|
|
||||||
? new Error(
|
? new Error(
|
||||||
plan.data === null
|
plan.data === null
|
||||||
? `The week has no plan, not draft ${draft.plan_id}.`
|
? `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 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.`,
|
: `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(() => {
|
useEffect(() => {
|
||||||
if (landed) setJobId(null)
|
if (landed) setJobId(null)
|
||||||
}, [landed])
|
}, [landed])
|
||||||
@@ -161,8 +162,10 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
|
|||||||
|
|
||||||
const failure = startDraft.isError ? (
|
const failure = startDraft.isError ? (
|
||||||
<ErrorBanner onRetry={rebuild}>Couldn't start drafting this week. {startDraft.error.message}</ErrorBanner>
|
<ErrorBanner onRetry={rebuild}>Couldn't start drafting this week. {startDraft.error.message}</ErrorBanner>
|
||||||
) : resultError !== null ? (
|
) : result.error !== null ? (
|
||||||
<ErrorBanner onRetry={rebuild}>Couldn't read the draft. {resultError.message}</ErrorBanner>
|
<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' ? (
|
) : job.error === null ? null : job.status === 'failed' ? (
|
||||||
<ErrorBanner onRetry={rebuild}>
|
<ErrorBanner onRetry={rebuild}>
|
||||||
Couldn't draft this week. {draftFailures[job.error.message] ?? job.error.message}
|
Couldn't draft this week. {draftFailures[job.error.message] ?? job.error.message}
|
||||||
|
|||||||
Reference in New Issue
Block a user