diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index b05e13c..ed77cf6 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -1,19 +1,23 @@ import { render, screen } from '@testing-library/react' import { MemoryRouter } from 'react-router' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createApiClient } from './api/index.ts' import { mockViewport } from './test/matchMedia.ts' +import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from './test/fetchStub.ts' import App, { routes } from './App.tsx' afterEach(() => { Reflect.deleteProperty(window, 'matchMedia') + vi.unstubAllGlobals() }) -function renderAt(path: string) { +function renderAt(path: string, location = fakeLocation(path)) { render( - + , ) + return location } describe('App routes', () => { @@ -39,11 +43,59 @@ describe('App routes', () => { it.each(routes.map(({ path, title }) => [path.replace(':id', 'abc123'), title]))( '%s renders its placeholder page inside the shell', - (path, title) => { + async (path, title) => { mockViewport(390) + stubFetch(jsonResponse(200, ME)) renderAt(path) - expect(screen.getByRole('main')).toContainElement(screen.getByRole('heading', { level: 1, name: title })) + const heading = await screen.findByRole('heading', { level: 1, name: title }) + expect(screen.getByRole('main')).toContainElement(heading) expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument() }, ) }) + +describe('App bootstrap', () => { + it('shows the skeleton until /me loads, then the routes', async () => { + mockViewport(390) + const fetchMock = stubFetch(jsonResponse(200, ME)) + renderAt('/plan') + + expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true') + expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument() + + expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument() + expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument() + expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET', credentials: 'same-origin' }) + }) + + it('sends a signed-out visitor to sign-in and never renders the routes', async () => { + const fetchMock = stubFetch(errorResponse(401, 'unauthenticated')) + const location = renderAt('/discover', fakeLocation('/discover', '?swipe=1')) + + await vi.waitFor(() => expect(location.assign).toHaveBeenCalledWith('/auth/login?return=%2Fdiscover%3Fswipe%3D1')) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toBeInTheDocument() + expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument() + }) + + it('shows the not-in-this-household screen on a 403', async () => { + const fetchMock = stubFetch(errorResponse(403, 'forbidden')) + const location = renderAt('/') + + expect(await screen.findByText(/not in this household/)).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Not in this household' })).toBeInTheDocument() + expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument() + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(location.assign).not.toHaveBeenCalled() + }) + + it('reports a /me failure with a retry instead of rendering the routes', async () => { + mockViewport(390) + stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME)) + renderAt('/') + + expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message') + screen.getByRole('button', { name: 'Retry' }).click() + expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument() + }) +}) diff --git a/web/src/App.tsx b/web/src/App.tsx index 5e820e5..13cdf10 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,6 +1,8 @@ import { useState } from 'react' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { QueryClientProvider } from '@tanstack/react-query' import { Route, Routes } from 'react-router' +import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts' +import { AuthBootstrap } from './AuthBootstrap.tsx' import { PlaceholderPage } from './pages/PlaceholderPage.tsx' import { AppShell } from './shell/AppShell.tsx' @@ -23,19 +25,34 @@ export const routes = [ { path: '/settings/calendar', title: 'Calendar' }, ] as const -// App expects to be rendered inside a router (BrowserRouter in main.tsx, -// MemoryRouter in tests). -export default function App() { - const [queryClient] = useState(() => new QueryClient()) +// AppRoutes is the shell and its pages, shown once /api/v1/me has loaded. +export function AppRoutes() { return ( - - - }> - {routes.map(({ path, title }) => ( - } /> - ))} - - - + + }> + {routes.map(({ path, title }) => ( + } /> + ))} + + + ) +} + +export type AppProps = { + api: ApiClient +} + +// App expects to be rendered inside a router (BrowserRouter in main.tsx, +// MemoryRouter in tests). +export default function App({ api }: AppProps) { + const [queryClient] = useState(createQueryClient) + return ( + + + + + + + ) } diff --git a/web/src/AuthBootstrap.tsx b/web/src/AuthBootstrap.tsx new file mode 100644 index 0000000..53f8661 --- /dev/null +++ b/web/src/AuthBootstrap.tsx @@ -0,0 +1,38 @@ +import { useSyncExternalStore, type ReactNode } from 'react' +import { useQuery } from '@tanstack/react-query' +import { ApiError, ME_PATH, useApi } from './api/index.ts' +import { NotInHouseholdPage } from './pages/NotInHouseholdPage.tsx' +import { ErrorBanner } from './ui/ErrorBanner.tsx' +import { Skeleton } from './ui/Skeleton.tsx' +import styles from './pages/FullScreen.module.css' + +export const meQueryKey = [ME_PATH] as const + +// AuthBootstrap loads GET /api/v1/me before rendering children. A 401 is +// already navigating to sign-in, so the skeleton stays up; an account outside +// the household sees only the "not in this household" screen. +export function AuthBootstrap({ children }: { children: ReactNode }) { + const api = useApi() + const forbidden = useSyncExternalStore(api.subscribe, api.forbidden) + const me = useQuery({ queryKey: meQueryKey, queryFn: api.loadMe, staleTime: Infinity }) + + if (forbidden) return + if (me.isSuccess) return children + if (me.isError && !(me.error instanceof ApiError && me.error.status === 401)) { + return ( +
+ void me.refetch()}>MealQuest could not load: {me.error.message} +
+ ) + } + return ( +
+
+ + + + +
+
+ ) +} diff --git a/web/src/api/ApiContext.tsx b/web/src/api/ApiContext.tsx new file mode 100644 index 0000000..b12ae8a --- /dev/null +++ b/web/src/api/ApiContext.tsx @@ -0,0 +1,15 @@ +import { createContext, useContext, type ReactNode } from 'react' +import type { ApiClient } from './client.ts' + +const ApiContext = createContext(null) + +export function ApiProvider({ client, children }: { client: ApiClient; children: ReactNode }) { + return {children} +} + +// useApi returns the app's API client. It must be rendered inside ApiProvider. +export function useApi(): ApiClient { + const client = useContext(ApiContext) + if (client === null) throw new Error('useApi: no ApiProvider above this component') + return client +} diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts new file mode 100644 index 0000000..9a2d2c8 --- /dev/null +++ b/web/src/api/client.test.ts @@ -0,0 +1,134 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from '../test/fetchStub.ts' +import { ApiError, createApiClient, CSRF_HEADER } from './index.ts' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('apiFetch', () => { + it('sends same-origin JSON and the cached CSRF token on non-GET requests only', async () => { + const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, { ok: 1 }), jsonResponse(200, { ok: 2 })) + const api = createApiClient(fakeLocation()) + await api.loadMe() + + await expect(api.apiFetch('/api/v1/settings/household')).resolves.toEqual({ ok: 1 }) + await expect( + api.apiFetch('/api/v1/settings/household', { method: 'PATCH', body: { dinnerTime: '18:00' } }), + ).resolves.toEqual({ ok: 2 }) + + const get = request(fetchMock, 1) + expect(get).toMatchObject({ path: '/api/v1/settings/household', method: 'GET', credentials: 'same-origin' }) + expect(get.headers.has(CSRF_HEADER)).toBe(false) + + const patch = request(fetchMock, 2) + expect(patch).toMatchObject({ method: 'PATCH', credentials: 'same-origin', body: '{"dinnerTime":"18:00"}' }) + expect(patch.headers.get(CSRF_HEADER)).toBe('token-1') + expect(patch.headers.get('Content-Type')).toBe('application/json') + }) + + it('refuses a non-GET request before /me has loaded', async () => { + const fetchMock = stubFetch() + const api = createApiClient(fakeLocation()) + await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('rejects with a typed ApiError from the error body', async () => { + stubFetch(errorResponse(503, 'unavailable', true)) + const api = createApiClient(fakeLocation()) + const error = await api.apiFetch('/api/v1/jobs/j1').catch((e: unknown) => e) + expect(error).toBeInstanceOf(ApiError) + expect(error).toMatchObject({ status: 503, code: 'unavailable', message: 'unavailable message', retryable: true }) + }) + + it('rejects a response without the API error body', async () => { + stubFetch(new Response('bad gateway', { status: 502 })) + const api = createApiClient(fakeLocation()) + await expect(api.apiFetch('/api/v1/jobs/j1')).rejects.toThrow('GET /api/v1/jobs/j1: 502 response is not JSON') + }) + + it('on 401 navigates to sign-in with the current path and search to return to', async () => { + stubFetch(errorResponse(401, 'unauthenticated')) + const location = fakeLocation('/plan/shopping', '?week=2026-10-05') + const api = createApiClient(location) + + await expect(api.apiFetch('/api/v1/settings/household')).rejects.toMatchObject({ status: 401 }) + expect(location.assign).toHaveBeenCalledExactlyOnceWith( + '/auth/login?return=%2Fplan%2Fshopping%3Fweek%3D2026-10-05', + ) + }) + + it('on a csrf 403 refreshes /me once and retries with the new token', async () => { + const fetchMock = stubFetch( + jsonResponse(200, ME), + errorResponse(403, 'csrf'), + jsonResponse(200, { ...ME, csrfToken: 'token-2' }), + jsonResponse(201, { id: 'j1' }), + ) + const api = createApiClient(fakeLocation()) + await api.loadMe() + + await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: { kind: 'noop' } })).resolves.toEqual({ + id: 'j1', + }) + expect(fetchMock).toHaveBeenCalledTimes(4) + expect(request(fetchMock, 1).headers.get(CSRF_HEADER)).toBe('token-1') + expect(request(fetchMock, 2)).toMatchObject({ path: '/api/v1/me', method: 'GET' }) + expect(request(fetchMock, 3)).toMatchObject({ path: '/api/v1/jobs', method: 'POST', body: '{"kind":"noop"}' }) + expect(request(fetchMock, 3).headers.get(CSRF_HEADER)).toBe('token-2') + expect(api.forbidden()).toBe(false) + }) + + it('does not loop when the retry gets a csrf 403 too', async () => { + const fetchMock = stubFetch( + jsonResponse(200, ME), + errorResponse(403, 'csrf'), + jsonResponse(200, { ...ME, csrfToken: 'token-2' }), + errorResponse(403, 'csrf'), + ) + const api = createApiClient(fakeLocation()) + await api.loadMe() + + await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })).rejects.toMatchObject({ + status: 403, + code: 'csrf', + }) + expect(fetchMock).toHaveBeenCalledTimes(4) + expect(api.forbidden()).toBe(false) + }) + + it('marks any other 403 as not in this household and notifies subscribers', async () => { + const fetchMock = stubFetch(errorResponse(403, 'forbidden')) + const api = createApiClient(fakeLocation()) + const listener = vi.fn() + api.subscribe(listener) + + await expect(api.apiFetch('/api/v1/me')).rejects.toMatchObject({ status: 403, code: 'forbidden' }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(api.forbidden()).toBe(true) + expect(listener).toHaveBeenCalledTimes(1) + }) + + it('does not take a 403 without the API error body as not in this household', async () => { + stubFetch(new Response('denied by proxy', { status: 403 })) + const api = createApiClient(fakeLocation()) + await expect(api.apiFetch('/api/v1/me')).rejects.toThrow('GET /api/v1/me: 403 response is not JSON') + expect(api.forbidden()).toBe(false) + }) + + it('does not refresh /me for a csrf 403 on a GET, which carries no token', async () => { + const fetchMock = stubFetch(errorResponse(403, 'csrf')) + const api = createApiClient(fakeLocation()) + await expect(api.loadMe()).rejects.toMatchObject({ status: 403, code: 'csrf' }) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) +}) + +describe('loadMe', () => { + it('rejects a /me response without a CSRF token', async () => { + stubFetch(jsonResponse(200, { ...ME, csrfToken: '' })) + const api = createApiClient(fakeLocation()) + await expect(api.loadMe()).rejects.toThrow('response is not a member with a CSRF token') + }) +}) diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..ac5cc2c --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,136 @@ +import { ApiError, isRecord, parseApiError } from './errors.ts' + +// CSRF_HEADER carries the session's CSRF token on every non-GET request. +export const CSRF_HEADER = 'X-MealQuest-CSRF' + +export const ME_PATH = '/api/v1/me' + +// Me is the GET /api/v1/me response: the signed-in member, the household and +// the session's CSRF token. +export type Me = { + name: string + email: string + household: { label: string } + csrfToken: string +} + +// ApiLocation is the part of window.location the client reads and navigates. +export type ApiLocation = Pick + +export type ApiRequest = { + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + // Sent as JSON. + body?: unknown +} + +export type ApiClient = { + // apiFetch sends a same-origin JSON request to path and resolves with the + // decoded response body, or rejects with an ApiError. + apiFetch(path: string, request?: ApiRequest): Promise + // loadMe fetches GET /api/v1/me and caches its CSRF token. + loadMe(): Promise + // forbidden reports whether a request was refused because the signed-in + // account is not in this household; subscribe is notified when it changes. + forbidden(): boolean + subscribe(listener: () => void): () => void +} + +// createApiClient builds the app's API client. location is window.location in +// the app: a 401 sends it to /auth/login with the current path to return to. +export function createApiClient(location: ApiLocation): ApiClient { + let csrfToken: string | null = null + let refreshing: Promise | null = null + let forbidden = false + const listeners = new Set<() => void>() + + async function send(method: string, path: string, body: unknown): Promise { + const headers: Record = { Accept: 'application/json' } + if (body !== undefined) headers['Content-Type'] = 'application/json' + if (method !== 'GET') { + if (csrfToken === null) { + throw new Error(`${method} ${path}: no CSRF token; ${ME_PATH} has not loaded`) + } + headers[CSRF_HEADER] = csrfToken + } + return fetch(path, { + method, + headers, + credentials: 'same-origin', + body: body === undefined ? undefined : JSON.stringify(body), + }) + } + + // refreshMe shares one in-flight /me request between every request that + // got a csrf 403 at the same time. + function refreshMe(): Promise { + refreshing ??= loadMe().finally(() => { + refreshing = null + }) + return refreshing + } + + async function apiFetch(path: string, request: ApiRequest = {}): Promise { + const method = request.method ?? 'GET' + let response = await send(method, path, request.body) + if (response.status === 403) { + const error = await parseForbidden(method, path, response) + // Only a non-GET carries the token, so only it can be refused for it. + if (error.code !== 'csrf' || method === 'GET') throw error + // The session's token changed: refresh it once and retry once. + await refreshMe() + response = await send(method, path, request.body) + if (response.status === 403) throw await parseForbidden(method, path, response) + } + return decode(method, path, response) + } + + // parseForbidden reads a 403. Any API error other than csrf means the + // account is not in this household. + async function parseForbidden(method: string, path: string, response: Response): Promise { + const error = await parseApiError(method, path, response) + if (error.code !== 'csrf') markForbidden() + return error + } + + function markForbidden() { + if (forbidden) return + forbidden = true + listeners.forEach((listener) => listener()) + } + + async function decode(method: string, path: string, response: Response): Promise { + if (response.status === 401) { + location.assign(`/auth/login?return=${encodeURIComponent(location.pathname + location.search)}`) + } + if (!response.ok) throw await parseApiError(method, path, response) + if (response.status === 204) return undefined as T + return (await response.json()) as T + } + + async function loadMe(): Promise { + const me = await apiFetch(ME_PATH) + if ( + !isRecord(me) || + typeof me.name !== 'string' || + typeof me.email !== 'string' || + !isRecord(me.household) || + typeof me.household.label !== 'string' || + typeof me.csrfToken !== 'string' || + me.csrfToken === '' + ) { + throw new Error(`GET ${ME_PATH}: response is not a member with a CSRF token`) + } + csrfToken = me.csrfToken + return me as Me + } + + return { + apiFetch, + loadMe, + forbidden: () => forbidden, + subscribe(listener) { + listeners.add(listener) + return () => listeners.delete(listener) + }, + } +} diff --git a/web/src/api/errors.ts b/web/src/api/errors.ts new file mode 100644 index 0000000..94b81e2 --- /dev/null +++ b/web/src/api/errors.ts @@ -0,0 +1,41 @@ +// ApiError is a failed API response carrying the server's +// {"error":{"code","message","retryable"}} body. +export class ApiError extends Error { + readonly status: number + readonly code: string + readonly retryable: boolean + + constructor(status: number, code: string, message: string, retryable: boolean) { + super(message) + this.name = 'ApiError' + this.status = status + this.code = code + this.retryable = retryable + } +} + +// parseApiError reads an error response body. A body that is not the API +// error shape is itself an error: the server always sends one. +export async function parseApiError(method: string, path: string, response: Response): Promise { + const text = await response.text() + let body: unknown + try { + body = JSON.parse(text) + } catch { + throw new Error(`${method} ${path}: ${response.status} response is not JSON: ${text.slice(0, 200)}`) + } + const error = isRecord(body) ? body.error : undefined + if ( + !isRecord(error) || + typeof error.code !== 'string' || + typeof error.message !== 'string' || + typeof error.retryable !== 'boolean' + ) { + throw new Error(`${method} ${path}: ${response.status} response has no API error body`) + } + return new ApiError(response.status, error.code, error.message, error.retryable) +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/web/src/api/index.ts b/web/src/api/index.ts new file mode 100644 index 0000000..ea7a989 --- /dev/null +++ b/web/src/api/index.ts @@ -0,0 +1,5 @@ +export { ApiProvider, useApi } from './ApiContext.tsx' +export { createApiClient, CSRF_HEADER, ME_PATH, type ApiClient, type ApiLocation, type ApiRequest, type Me } from './client.ts' +export { ApiError } from './errors.ts' +export { createQueryClient, shouldRetry } from './queryClient.ts' +export { useJob, type Job, type JobState, type JobStatus } from './useJob.ts' diff --git a/web/src/api/queryClient.test.ts b/web/src/api/queryClient.test.ts new file mode 100644 index 0000000..81a1378 --- /dev/null +++ b/web/src/api/queryClient.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { ApiError, createQueryClient, shouldRetry } from './index.ts' + +describe('query retries', () => { + it('retry only an ApiError the server marked retryable, a bounded number of times', () => { + expect(shouldRetry(0, new ApiError(503, 'unavailable', 'try later', true))).toBe(true) + expect(shouldRetry(3, new ApiError(503, 'unavailable', 'try later', true))).toBe(false) + expect(shouldRetry(0, new ApiError(401, 'unauthenticated', 'sign in required', false))).toBe(false) + expect(shouldRetry(0, new ApiError(403, 'csrf', 'bad token', false))).toBe(false) + expect(shouldRetry(0, new Error('network down'))).toBe(false) + }) + + it('are the QueryClient default for queries and mutations', () => { + const options = createQueryClient().getDefaultOptions() + expect(options.queries?.retry).toBe(shouldRetry) + expect(options.mutations?.retry).toBe(shouldRetry) + }) +}) diff --git a/web/src/api/queryClient.ts b/web/src/api/queryClient.ts new file mode 100644 index 0000000..b703055 --- /dev/null +++ b/web/src/api/queryClient.ts @@ -0,0 +1,20 @@ +import { QueryClient } from '@tanstack/react-query' +import { ApiError } from './errors.ts' + +// MAX_RETRIES bounds retries of a failure the server marked retryable. +export const MAX_RETRIES = 3 + +// shouldRetry retries only an ApiError the server marked retryable — never a +// 401, a 403 or a malformed response. +export function shouldRetry(failureCount: number, error: unknown): boolean { + return error instanceof ApiError && error.retryable && failureCount < MAX_RETRIES +} + +export function createQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { retry: shouldRetry, staleTime: 30_000 }, + mutations: { retry: shouldRetry }, + }, + }) +} diff --git a/web/src/api/useJob.test.tsx b/web/src/api/useJob.test.tsx new file mode 100644 index 0000000..f93ce20 --- /dev/null +++ b/web/src/api/useJob.test.tsx @@ -0,0 +1,169 @@ +import { act, renderHook } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { errorResponse, fakeLocation, jsonResponse, request, stubFetch } from '../test/fetchStub.ts' +import { ApiProvider, createApiClient, useJob } from './index.ts' + +type Draft = { dinners: string[] } + +function job(status: string, result: Draft | null, error: string | null = null) { + return jsonResponse(200, { id: 'j1', kind: 'plan.draft', status, result, error }) +} + +function renderJob(jobId: string | null) { + const api = createApiClient(fakeLocation()) + const wrapper = ({ children }: { children: ReactNode }) => {children} + return renderHook(({ id }) => useJob(id), { wrapper, initialProps: { id: jobId } }) +} + +// advance runs the timers due in ms and lets the polls they start answer. +async function advance(ms: number) { + await act(() => vi.advanceTimersByTimeAsync(ms)) +} + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +describe('useJob', () => { + it('exposes the partial result while running, then the terminal result, backing off 500 ms to 2 s', async () => { + const fetchMock = stubFetch( + job('queued', null), + job('running', { dinners: ['Tacos'] }), + job('running', { dinners: ['Tacos', 'Chili'] }), + job('running', { dinners: ['Tacos', 'Chili'] }), + job('succeeded', { dinners: ['Tacos', 'Chili', 'Soup'] }), + ) + const { result } = renderJob('j1') + expect(result.current).toEqual({ status: undefined, result: null, error: null }) + + await advance(0) + expect(result.current.status).toBe('queued') + expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/jobs/j1', method: 'GET' }) + + await advance(499) + expect(fetchMock).toHaveBeenCalledTimes(1) + await advance(1) + expect(result.current).toEqual({ status: 'running', result: { dinners: ['Tacos'] }, error: null }) + + await advance(999) + expect(fetchMock).toHaveBeenCalledTimes(2) + await advance(1) + expect(result.current.result).toEqual({ dinners: ['Tacos', 'Chili'] }) + + await advance(2000) + expect(fetchMock).toHaveBeenCalledTimes(4) + await advance(2000) + expect(fetchMock).toHaveBeenCalledTimes(5) + expect(result.current).toEqual({ + status: 'succeeded', + result: { dinners: ['Tacos', 'Chili', 'Soup'] }, + error: null, + }) + + // Terminal: no timer left, no further polls. + expect(vi.getTimerCount()).toBe(0) + await advance(10_000) + expect(fetchMock).toHaveBeenCalledTimes(5) + }) + + it('exposes a failed job as its error and stops', async () => { + const fetchMock = stubFetch(job('running', { dinners: ['Tacos'] }), job('failed', null, 'model refused')) + const { result } = renderJob('j1') + + await advance(0) + await advance(500) + expect(result.current.status).toBe('failed') + expect(result.current.error?.message).toBe('model refused') + expect(vi.getTimerCount()).toBe(0) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('exposes a failed poll as the error and keeps polling to the terminal status', async () => { + const fetchMock = stubFetch( + job('running', { dinners: ['Tacos'] }), + errorResponse(503, 'unavailable', true), + job('succeeded', { dinners: ['Tacos', 'Chili'] }), + ) + const { result } = renderJob('j1') + + await advance(0) + await advance(500) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] } }) + expect(result.current.error).toMatchObject({ status: 503, code: 'unavailable' }) + expect(vi.getTimerCount()).toBe(1) + + // The backoff carries on from the failed poll: the next one is 1 s later. + await advance(999) + expect(fetchMock).toHaveBeenCalledTimes(2) + await advance(1) + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(result.current).toEqual({ status: 'succeeded', result: { dinners: ['Tacos', 'Chili'] }, error: null }) + expect(vi.getTimerCount()).toBe(0) + }) + + it('stops polling on unmount after a failed poll', async () => { + const fetchMock = stubFetch(errorResponse(503, 'unavailable', true)) + const { result, unmount } = renderJob('j1') + + await advance(0) + expect(result.current).toMatchObject({ status: undefined, result: null }) + expect(result.current.error).toMatchObject({ status: 503 }) + expect(vi.getTimerCount()).toBe(1) + unmount() + expect(vi.getTimerCount()).toBe(0) + await advance(10_000) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('stops polling the old job when the job id changes', async () => { + const fetchMock = stubFetch(job('running', null), job('running', null)) + const { result, rerender } = renderJob('j1') + + await advance(0) + rerender({ id: 'j2' }) + expect(result.current).toEqual({ status: undefined, result: null, error: null }) + await advance(0) + expect(request(fetchMock, 1).path).toBe('/api/v1/jobs/j2') + expect(vi.getTimerCount()).toBe(1) + }) + + it('stops polling on unmount', async () => { + const fetchMock = stubFetch(job('running', null)) + const { unmount } = renderJob('j1') + + await advance(0) + expect(vi.getTimerCount()).toBe(1) + unmount() + expect(vi.getTimerCount()).toBe(0) + await advance(10_000) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('ignores a poll that answers after unmount', async () => { + let answer!: (response: Response) => void + const fetchMock = stubFetch() + fetchMock.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve))) + const { result, unmount } = renderJob('j1') + + unmount() + await act(async () => answer(await Promise.resolve(job('running', null)))) + expect(vi.getTimerCount()).toBe(0) + expect(result.current.status).toBeUndefined() + }) + + it('polls nothing without a job id', async () => { + const fetchMock = stubFetch() + const { result } = renderJob(null) + await advance(5000) + expect(fetchMock).not.toHaveBeenCalled() + expect(result.current).toEqual({ status: undefined, result: null, error: null }) + }) +}) diff --git a/web/src/api/useJob.ts b/web/src/api/useJob.ts new file mode 100644 index 0000000..2a26c2c --- /dev/null +++ b/web/src/api/useJob.ts @@ -0,0 +1,83 @@ +import { useEffect, useState } from 'react' +import { useApi } from './ApiContext.tsx' + +export type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed' + +// Job is the GET /api/v1/jobs/{id} response. result may be set while the job +// is running (a partial result); error is set only when it failed. +export type Job = { + id: string + kind: string + status: JobStatus + result: R | null + error: string | null +} + +export type JobState = { + // undefined until the first poll answers. + status: JobStatus | undefined + // The latest result, partial while the job runs. + result: R | null + // The job's failure, or the latest polling request's own failure (cleared + // by the next poll that answers). + error: Error | null +} + +// Polling starts at FIRST_DELAY_MS and doubles up to MAX_DELAY_MS. +export const FIRST_DELAY_MS = 500 +export const MAX_DELAY_MS = 2000 + +const idle = { status: undefined, result: null, error: null } + +function isTerminal(status: JobStatus): boolean { + return status === 'succeeded' || status === 'failed' +} + +// useJob polls GET /api/v1/jobs/{jobId} until the job succeeds or fails, +// backing off from 500 ms to 2 s. A failed poll is exposed as the error and +// polling continues. Polling stops only at a terminal status, when jobId +// changes and on unmount. A null jobId polls nothing. +export function useJob(jobId: string | null): JobState { + const { apiFetch } = useApi() + const [state, setState] = useState<{ jobId: string | null; job: JobState }>({ jobId, job: idle }) + + useEffect(() => { + if (jobId === null) return + let active = true + let timer: ReturnType | undefined + let delay = FIRST_DELAY_MS + + function next() { + timer = setTimeout(poll, delay) + delay = Math.min(delay * 2, MAX_DELAY_MS) + } + + async function poll() { + try { + const job = await apiFetch>(`/api/v1/jobs/${encodeURIComponent(jobId!)}`) + if (!active) return + const error = job.status === 'failed' ? new Error(job.error ?? `job ${job.id} failed without an error message`) : null + setState({ jobId, job: { status: job.status, result: job.result, error } }) + if (isTerminal(job.status)) return + } catch (error) { + if (!active) return + // A failed poll is not the job's end: keep the last known status and + // result, expose the request's error, and poll again. + setState((prev) => ({ + jobId, + job: { ...(prev.jobId === jobId ? prev.job : idle), error: error instanceof Error ? error : new Error(String(error)) }, + })) + } + next() + } + + void poll() + return () => { + active = false + clearTimeout(timer) + } + }, [apiFetch, jobId]) + + // State left from a previous jobId is not this job's. + return state.jobId === jobId ? state.job : idle +} diff --git a/web/src/main.tsx b/web/src/main.tsx index a2dcd7b..fe00a99 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -4,16 +4,19 @@ import { BrowserRouter } from 'react-router' import './styles/tokens.css' import './styles/base.css' import App from './App.tsx' +import { createApiClient } from './api/index.ts' const root = document.getElementById('root') if (!root) { throw new Error('MealQuest: #root element missing from index.html') } +const api = createApiClient(window.location) + createRoot(root).render( - + , ) diff --git a/web/src/pages/FullScreen.module.css b/web/src/pages/FullScreen.module.css new file mode 100644 index 0000000..12c55ec --- /dev/null +++ b/web/src/pages/FullScreen.module.css @@ -0,0 +1,23 @@ +/* A full-viewport screen shown instead of the app shell. */ +.screen { + min-height: 100dvh; + display: flex; + flex-direction: column; + justify-content: center; + gap: var(--space-3); + padding: var(--space-8) var(--space-4); + background: var(--color-bg); +} + +.loading { + width: min(100%, 480px); + margin: 0 auto; + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.title { + height: 32px; + width: 60%; +} diff --git a/web/src/pages/NotInHouseholdPage.tsx b/web/src/pages/NotInHouseholdPage.tsx new file mode 100644 index 0000000..f1bb3db --- /dev/null +++ b/web/src/pages/NotInHouseholdPage.tsx @@ -0,0 +1,14 @@ +import { EmptyState } from '../ui/EmptyState.tsx' +import styles from './FullScreen.module.css' + +// Shown when the server refuses a signed-in account that is not in this +// household. Nothing else renders: the account can do nothing here. +export function NotInHouseholdPage() { + return ( +
+ + You are signed in, but this account is not in this household. Ask someone in Our kitchen to add it. + +
+ ) +} diff --git a/web/src/shell/AppShell.test.tsx b/web/src/shell/AppShell.test.tsx index 48e0bc8..03d7bfd 100644 --- a/web/src/shell/AppShell.test.tsx +++ b/web/src/shell/AppShell.test.tsx @@ -1,7 +1,7 @@ import { render, screen, within } from '@testing-library/react' import { MemoryRouter } from 'react-router' import { afterEach, describe, expect, it } from 'vitest' -import App from '../App.tsx' +import { AppRoutes } from '../App.tsx' import { declaredStyle, outerSize } from '../test/cssRules.ts' import { expectMinHitArea } from '../test/hitArea.ts' import { mockViewport } from '../test/matchMedia.ts' @@ -22,7 +22,7 @@ function renderAt(path: string, width: number) { const resize = mockViewport(width) render( - + , ) return resize diff --git a/web/src/shell/BottomBar.test.tsx b/web/src/shell/BottomBar.test.tsx index 6f93884..3ff7db6 100644 --- a/web/src/shell/BottomBar.test.tsx +++ b/web/src/shell/BottomBar.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen, within } from '@testing-library/react' import { MemoryRouter } from 'react-router' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import App from '../App.tsx' +import { AppRoutes } from '../App.tsx' import { declaredStyle } from '../test/cssRules.ts' import { mockViewport } from '../test/matchMedia.ts' import styles from './BottomBar.module.css' @@ -71,7 +71,7 @@ function renderAt(path: string, reducedMotion = false) { mockViewport(MOBILE, { reducedMotion }) render( - + , ) } diff --git a/web/src/test/fetchStub.ts b/web/src/test/fetchStub.ts new file mode 100644 index 0000000..9e84d14 --- /dev/null +++ b/web/src/test/fetchStub.ts @@ -0,0 +1,48 @@ +import { vi, type Mock } from 'vitest' +import type { ApiLocation, Me } from '../api/index.ts' + +export const ME: Me = { + name: 'Sam', + email: 'sam@example.com', + household: { label: 'Our kitchen' }, + csrfToken: 'token-1', +} + +export function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) +} + +export function errorResponse(status: number, code: string, retryable = false): Response { + return jsonResponse(status, { error: { code, message: `${code} message`, retryable } }) +} + +// stubFetch replaces the global fetch with a mock answered from responses in +// order; a request beyond them fails the test. Undo with vi.unstubAllGlobals. +export function stubFetch(...responses: Response[]): Mock { + const fetchMock = vi.fn() + for (const response of responses) fetchMock.mockResolvedValueOnce(response) + fetchMock.mockImplementation((input, init) => { + throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${String(input)}`) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +// request returns the method, path and headers of the fetch mock's nth call. +export function request(fetchMock: Mock, n: number) { + const call = fetchMock.mock.calls[n] + if (call === undefined) throw new Error(`fetch was called ${fetchMock.mock.calls.length} times, not ${n + 1}`) + const [input, init] = call + return { + path: String(input), + method: init?.method, + credentials: init?.credentials, + headers: new Headers(init?.headers), + body: init?.body, + } +} + +// fakeLocation stands in for window.location, recording navigations. +export function fakeLocation(pathname = '/', search = ''): ApiLocation & { assign: Mock<(url: string | URL) => void> } { + return { pathname, search, assign: vi.fn<(url: string | URL) => void>() } +}