Files
MealQuest/web/src/settings/photoBackfill.test.tsx
T
dev_agentandClaude Opus 5.5 3882f670ca
CI / ci (push) Successful in 9m38s
CI / ci (pull_request) Successful in 9m30s
Photo backfill UI: fix-recipe failures and Find missing photos (Maestro 7a468d2d, Feature 23 task 2)
Settings → Regular dinners gains a "Find missing photos" trigger that
calls POST /api/v1/settings/photo-backfill (202 {id}) and follows the
backfill_dinner_photos job with useJob, showing progress, a summary of
the outcomes, or why it stopped. When the job ends, the dinners, plans
and home queries are read again so found photos show everywhere.

The fix-recipe sheet now shows the dinner's grounding_error as a typed
reason and offers "Search again" (the same backfill), reporting this
dinner's outcome; it is not offered for recipe_unusable, since the
backfill never replaces a pasted link. Regular now parses
grounding_error.

Task: 7a468d2d-4a9b-45f2-b626-a7bed5f671df

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:58:52 -04:00

64 lines
2.8 KiB
TypeScript

import { QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { HOME_KEY } from '../home/api.ts'
import { planKey } from '../plan/api.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { fakeLocation, jsonResponse, ME, stubFetch } from '../test/fetchStub.ts'
import { parseBackfillResult, usePhotoBackfill } from './photoBackfill.ts'
afterEach(() => {
vi.unstubAllGlobals()
})
const RESULT = { dinners: [{ dinner_id: 'd3', outcome: 'grounded', reason: null }] }
function backfillJob(status: string, result: unknown) {
return jsonResponse(200, { id: 'j9', kind: 'backfill_dinner_photos', status, result, error: null })
}
describe('usePhotoBackfill', () => {
it('reads the plans, home and dinners again once the backfill ends, so a found photo shows everywhere', async () => {
const fetchMock = stubFetch(
jsonResponse(200, ME),
jsonResponse(202, { id: 'j9' }),
backfillJob('running', null),
backfillJob('succeeded', RESULT),
)
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const queryClient = createQueryClient()
const plan = planKey('2026-10-05')
for (const key of [plan, HOME_KEY, ['dinners', 'quest_log']]) queryClient.setQueryData(key, {})
const wrapper = ({ children }: { children: ReactNode }) => (
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</ApiProvider>
)
const { result } = renderHook(() => usePhotoBackfill(), { wrapper })
act(() => result.current.start())
await waitFor(() => expect(result.current.status).toBe('running'))
expect(result.current.active).toBe(true)
expect(queryClient.getQueryState(plan)?.isInvalidated).toBe(false)
await waitFor(() => expect(result.current.status).toBe('succeeded'), { timeout: 3000 })
expect(result.current).toMatchObject({ active: false, error: null, result: RESULT })
expect(fetchMock).toHaveBeenCalledTimes(4)
for (const key of [plan, HOME_KEY, ['dinners', 'quest_log']]) {
expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true)
}
})
})
describe('parseBackfillResult', () => {
it('refuses a result that is not a photo backfill', () => {
expect(() => parseBackfillResult('job', { dinners: [{ dinner_id: 'd3', outcome: 'lost', reason: null }] })).toThrow(
'job: outcome "lost" is not one of grounded, failed, skipped',
)
expect(() => parseBackfillResult('job', { stage: 'x' })).toThrow('job: result is not a photo backfill')
})
})