diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 935ce16..f571090 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -320,6 +320,8 @@ jobs: tools/tests/backend_proxy_logout_fallback.test.mjs tools/tests/backend_warmup_rate_limit.test.mjs tools/tests/calorieapp_embed_readiness.test.mjs + tools/tests/food_logging_ui.test.mjs + tools/tests/food_search_deadline.test.mjs tools/tests/identity_locales.test.mjs tools/tests/xaman_logout_request.test.mjs tools/tests/xaman_login_start_retry.test.mjs diff --git a/docs/FOOD_SEARCH_DEADLINES.md b/docs/FOOD_SEARCH_DEADLINES.md new file mode 100644 index 0000000..051e649 --- /dev/null +++ b/docs/FOOD_SEARCH_DEADLINES.md @@ -0,0 +1,30 @@ +# Food search deadline correction + +The mobile screenshots show one failed search for Magnum followed by results +after a second click. They do not contain an HTTP status, trace or provider log. + +The app already waits for backend readiness before searching. In +`backend/services/open_food_facts.py`, the existing primary request has a +10-second timeout and its eligible fallback has a 15-second timeout, with +bounded adapter queueing. Before this change, the frontend proxy stopped at +18 seconds and the browser request helper at 20. A valid slower backend search +could therefore be cut off even though backend readiness had succeeded. + +Only `/search-food` now uses a 45-second proxy timeout and a 50-second browser +timeout. The client gives the proxy time to return a controlled 504. Search +progress explains the possible delay; 429, 504 and other service errors have +specific user-facing messages. Identity, logout, ordinary request and account +import deadlines are unchanged. Existing provider concurrency, attempt limits, +queue limits, rate governor and upstream Retry-After forwarding are unchanged. +No automatic search retry is added. + +`tools/tests/food_search_deadline.test.mjs` runs the real transpiled browser +helper and proxy together under a virtual clock. It checks one successful +26-second response, a bounded 45-second timeout, an unchanged 18-second ordinary +request timeout, and a forwarded 429/Retry-After without an extra provider call. +Existing food logging and logout behavior tests run beside it in CI. + +This demonstrates a concrete deadline mismatch. It does not prove that the +captured failure followed that route or guarantee availability of Open Food +Facts. One live search after inactivity remains the acceptance check after +deployment; the WordPress plugin ZIP alone cannot deliver this app change. diff --git a/frontend/app/api/backend/[...path]/route.ts b/frontend/app/api/backend/[...path]/route.ts index 12cbfb9..17cfbc7 100644 --- a/frontend/app/api/backend/[...path]/route.ts +++ b/frontend/app/api/backend/[...path]/route.ts @@ -13,6 +13,7 @@ import { isTrustedPrivateExportRequest } from "@/lib/privateExportRequest"; export const dynamic = "force-dynamic"; const DEFAULT_UPSTREAM_TIMEOUT_MS = 18_000; +const FOOD_SEARCH_UPSTREAM_TIMEOUT_MS = 45_000; const COLD_START_UPSTREAM_TIMEOUT_MS = 70_000; const ACCOUNT_IMPORT_UPSTREAM_TIMEOUT_MS = 60_000; const LOGOUT_REVOCATION_TIMEOUT_MS = 8_000; @@ -182,20 +183,21 @@ async function proxyRequest(request: NextRequest, context: RouteContext) { } const controller = new AbortController(); - // A sleeping Render backend can need well over the ordinary request timeout - // before its health endpoint answers. Keep only this readiness probe alive - // long enough to wake it; normal application requests retain the tighter - // timeout after readiness has been established. + // Readiness and identity routes retain their accepted deadlines. Search must + // also fit the existing primary/fallback provider attempts and queue time; + // otherwise the proxy returns 504 while the backend is still finding food. const upstreamTimeoutMs = - path === ACCOUNT_IMPORT_PATH - ? ACCOUNT_IMPORT_UPSTREAM_TIMEOUT_MS - : [ - "health", - "api/identity/login/start", - "api/identity/callback", - ].includes(path) - ? COLD_START_UPSTREAM_TIMEOUT_MS - : DEFAULT_UPSTREAM_TIMEOUT_MS; + path === "search-food" + ? FOOD_SEARCH_UPSTREAM_TIMEOUT_MS + : path === ACCOUNT_IMPORT_PATH + ? ACCOUNT_IMPORT_UPSTREAM_TIMEOUT_MS + : [ + "health", + "api/identity/login/start", + "api/identity/callback", + ].includes(path) + ? COLD_START_UPSTREAM_TIMEOUT_MS + : DEFAULT_UPSTREAM_TIMEOUT_MS; const timeoutId = setTimeout(() => controller.abort(), upstreamTimeoutMs); try { diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 455f35c..ba60c31 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -24,7 +24,7 @@ body { font-family: "Segoe UI", "Trebuchet MS", sans-serif; color: var(--ink); background-color: var(--brand-bg); - background-image: url('/background.png'); + background-image: url('/calorieapp-background.png'); background-attachment: scroll; background-position: center; background-repeat: repeat; @@ -35,3 +35,4 @@ body { background-attachment: fixed; } } + diff --git a/frontend/components/FoodCard.tsx b/frontend/components/FoodCard.tsx index 6e00e50..7e22b9e 100644 --- a/frontend/components/FoodCard.tsx +++ b/frontend/components/FoodCard.tsx @@ -2,21 +2,44 @@ import { FoodSearchItem } from "@/components/foodTypes"; import Image from "next/image"; -import { useState } from "react"; +import { ReactNode, useEffect, useId, useRef, useState } from "react"; type FoodCardProps = { item: FoodSearchItem; isLogging: boolean; + isDisabled?: boolean; onLog: () => void; formatNumber: (value: number) => string; + children?: ReactNode; + feedback?: { message: string; isError: boolean } | null; }; -export function FoodCard({ item, isLogging, onLog, formatNumber }: FoodCardProps) { +export function FoodCard({ item, isLogging, isDisabled = false, onLog, formatNumber, children, feedback }: FoodCardProps) { const [imageFailed, setImageFailed] = useState(false); + const portionId = useId(); + const portionRef = useRef(null); + const logButtonRef = useRef(null); + const wasExpandedRef = useRef(false); + const isExpanded = Boolean(children); const showImage = Boolean(item.image_url) && !imageFailed; + useEffect(() => { + if (isExpanded && !wasExpandedRef.current) { + portionRef.current?.focus({ preventScroll: true }); + portionRef.current?.scrollIntoView({ block: "nearest", behavior: "auto" }); + } else if (!isExpanded && wasExpandedRef.current) { + if (isDisabled || isLogging) return; + // Restore keyboard focus after the portion controls are removed, while + // leaving focus alone if the user has moved to another product or search. + if (document.activeElement === document.body) { + logButtonRef.current?.focus({ preventScroll: true }); + } + } + wasExpandedRef.current = isExpanded; + }, [isExpanded, isDisabled, isLogging]); + return ( -
  • +
  • {showImage ? ( @@ -77,15 +100,38 @@ export function FoodCard({ item, isLogging, onLog, formatNumber }: FoodCardProps
    + {isExpanded ? ( +
    + {children} +
    + ) : null} + {feedback ? ( +

    + {feedback.message} +

    + ) : null}
  • ); } diff --git a/frontend/components/FoodSearchPlaceholder.tsx b/frontend/components/FoodSearchPlaceholder.tsx index f0df401..ca50eae 100644 --- a/frontend/components/FoodSearchPlaceholder.tsx +++ b/frontend/components/FoodSearchPlaceholder.tsx @@ -15,6 +15,7 @@ import { import type { AuthStateChangedDetail } from "@/components/authEvents"; import { BACKEND_WAKE_BASE_URL, + FOOD_SEARCH_TIMEOUT_MS, backendRequest, backendUnavailableMessage, waitForBackendReady, @@ -191,6 +192,7 @@ export function FoodSearchPlaceholder() { const searchAbortControllerRef = useRef(null); const logsRequestIdRef = useRef(0); const logMutationInFlightRef = useRef(false); + const logSelectionIdRef = useRef(0); const deleteMutationInFlightRef = useRef(false); const [query, setQuery] = useState(""); const [results, setResults] = useState([]); @@ -202,6 +204,12 @@ export function FoodSearchPlaceholder() { const [pendingLogIndex, setPendingLogIndex] = useState(null); const [portionOption, setPortionOption] = useState("whole"); const [customPortion, setCustomPortion] = useState("30"); + const [portionError, setPortionError] = useState(null); + const [logFeedback, setLogFeedback] = useState<{ + index: number; + message: string; + isError: boolean; + } | null>(null); const [deletingLogId, setDeletingLogId] = useState(null); const [isClearingAll, setIsClearingAll] = useState(false); const [selectedLogId, setSelectedLogId] = useState(null); @@ -281,10 +289,13 @@ export function FoodSearchPlaceholder() { // Invalidate any request that began under the previous authentication // state so a late response cannot repopulate another session's logs. logsRequestIdRef.current += 1; + logSelectionIdRef.current += 1; setLogs([]); setSelectedLogId(null); setPendingLogItem(null); setPendingLogIndex(null); + setPortionError(null); + setLogFeedback(null); setIsLogsLoading(false); setLogError(SIGN_IN_REQUIRED_LOG_MESSAGE); }, []); @@ -366,6 +377,9 @@ export function FoodSearchPlaceholder() { async function onSearch(event: FormEvent) { event.preventDefault(); + if (logMutationInFlightRef.current) return; + cancelPortionLogging(); + setLogFeedback(null); const trimmedQuery = query.trim(); if (!trimmedQuery) { @@ -385,16 +399,17 @@ export function FoodSearchPlaceholder() { setError(null); setDidSearch(true); setSearchStatus( - "Connecting to the food service. After inactivity, startup can take up to 90 seconds." + "Preparing food search. This can take a moment after inactivity." ); try { await waitForBackendReady(BACKEND_WAKE_BASE_URL, controller.signal); - setSearchStatus("Searching foods..."); + setSearchStatus("Searching foods. This can take up to 45 seconds."); const response = await backendRequest( `${BACKEND_BASE_URL}/search-food?q=${encodeURIComponent(trimmedQuery)}`, - { signal: controller.signal } + { signal: controller.signal }, + FOOD_SEARCH_TIMEOUT_MS ); if (requestId !== searchRequestIdRef.current) { @@ -402,7 +417,13 @@ export function FoodSearchPlaceholder() { } if (!response.ok) { - throw new Error("Search request failed."); + setResults([]); + setError(response.status === 429 + ? "Food search is busy. Please wait before searching again." + : response.status === 504 + ? "The food search took longer than expected. Please try again later." + : "Food search is temporarily unavailable. Please try again later."); + return; } const data = (await response.json()) as FoodSearchResponse; @@ -416,7 +437,8 @@ export function FoodSearchPlaceholder() { setError( backendUnavailableMessage( requestError, - "Unable to fetch foods right now. Please try again." + "Unable to fetch foods right now. Please try again.", + "The food search took longer than expected. Please try again later." ) ); } @@ -429,17 +451,23 @@ export function FoodSearchPlaceholder() { } function onLogFood(item: FoodSearchItem, index: number) { + if (logMutationInFlightRef.current || isLoading) return; + logSelectionIdRef.current += 1; setPendingLogItem(item); setPendingLogIndex(index); setPortionOption("whole"); setCustomPortion("30"); + setPortionError(null); + setLogFeedback(null); } function cancelPortionLogging() { + logSelectionIdRef.current += 1; setPendingLogItem(null); setPendingLogIndex(null); setPortionOption("whole"); setCustomPortion("30"); + setPortionError(null); } async function confirmPortionLogging() { @@ -452,15 +480,16 @@ export function FoodSearchPlaceholder() { } if (selectedPortionPercentage === null) { - setLogError("Enter a valid custom percentage between 1 and 100."); + // The field already presents one inline validation message. return; } + const selectionId = logSelectionIdRef.current; const payload = scaleNutrition(pendingLogItem, selectedPortionPercentage); logMutationInFlightRef.current = true; setIsLogging(pendingLogIndex); - setLogError(null); + setPortionError(null); try { const response = await backendRequest(`${BACKEND_BASE_URL}/log-food`, { @@ -471,13 +500,20 @@ export function FoodSearchPlaceholder() { body: JSON.stringify(payload), }); + if (selectionId !== logSelectionIdRef.current) return; + if (response.status === 401) { clearPrivateLogState(); + setLogFeedback({ + index: pendingLogIndex, + message: "Sign in with Xaman to save food to your personal log.", + isError: true, + }); return; } if (response.status === 409) { - setLogError( + setPortionError( "Your private food log has reached its storage limit. Export or delete existing entries before adding more." ); return; @@ -487,15 +523,22 @@ export function FoodSearchPlaceholder() { throw new Error("Log request failed."); } + setLogFeedback({ + index: pendingLogIndex, + message: `Added ${pendingLogItem.product_name} (${selectedPortionPercentage}%) to your food log.`, + isError: false, + }); await fetchLogs(); cancelPortionLogging(); } catch (requestError) { - setLogError( - backendUnavailableMessage( - requestError, - "Unable to log this food right now. Please try again." - ) - ); + if (selectionId === logSelectionIdRef.current) { + setPortionError( + backendUnavailableMessage( + requestError, + "Unable to log this food right now. Please try again." + ) + ); + } } finally { logMutationInFlightRef.current = false; setIsLogging(null); @@ -580,6 +623,133 @@ export function FoodSearchPlaceholder() { } } + const portionControls = pendingLogItem ? ( +
    { event.preventDefault(); void confirmPortionLogging(); }} + aria-busy={isLogging !== null} + > +

    How much did you eat?

    +
    + + + + +
    + + {portionOption === "custom" ? ( +
    + + setCustomPortion(event.target.value)} + disabled={isLogging !== null} + className="min-h-11 w-24 rounded-md border border-brand-secondary/30 bg-white px-2 py-1 text-sm text-brand-primary outline-none focus:border-brand-primary" + /> + % +
    + ) : null} + + {selectedPortionPercentage === null ? ( +

    + Enter a valid custom percentage from 1 to 100. +

    + ) : null} + + {portionPreview ? ( +
    +

    You will log {selectedPortionPercentage}% of

    +

    {pendingLogItem.product_name}

    + {pendingLogItem.brand ?

    {pendingLogItem.brand}

    : null} +
    +

    Calories: {formatNumber(portionPreview.calories)} kcal

    +

    Protein: {formatNumber(portionPreview.protein)} g

    +

    Fat: {formatNumber(portionPreview.fat)} g

    +

    Carbohydrates: {formatNumber(portionPreview.carbohydrates)} g

    +
    +
    + ) : null} + + {portionError ?
    : null} + +
    + + +
    +
    + ) : null; + return (
    {/* Search Section */} @@ -630,125 +800,17 @@ export function FoodSearchPlaceholder() { key={`${item.product_name}-${index}`} item={item} isLogging={isLogging === index} + isDisabled={isLogging !== null || isLoading} + feedback={logFeedback?.index === index ? logFeedback : null} onLog={() => onLogFood(item, index)} formatNumber={formatNumber} - /> + > + {pendingLogIndex === index ? portionControls : null} + ))} ) : null} - {pendingLogItem ? ( -
    -

    How much did you eat?

    -
    - - - - -
    - - {portionOption === "custom" ? ( -
    - - setCustomPortion(event.target.value)} - disabled={isLogging !== null} - className="w-24 rounded-md border border-brand-secondary/30 bg-white px-2 py-1 text-sm text-brand-primary outline-none focus:border-brand-primary" - /> - % -
    - ) : null} - - {selectedPortionPercentage === null ? ( -

    - Enter a valid custom percentage from 1 to 100. -

    - ) : null} - - {portionPreview ? ( -
    -

    You will log

    -
    -

    Calories: {formatNumber(portionPreview.calories)} kcal

    -

    Protein: {formatNumber(portionPreview.protein)} g

    -

    Fat: {formatNumber(portionPreview.fat)} g

    -

    Carbohydrates: {formatNumber(portionPreview.carbohydrates)} g

    -
    -
    - ) : null} - -
    - - -
    -
    - ) : null} {/* Logged Foods Section */} diff --git a/frontend/lib/backendRequest.ts b/frontend/lib/backendRequest.ts index 2bc9310..dd49d51 100644 --- a/frontend/lib/backendRequest.ts +++ b/frontend/lib/backendRequest.ts @@ -1,4 +1,7 @@ export const DEFAULT_BACKEND_TIMEOUT_MS = 20_000; +// Search can include the existing 10s primary + 15s fallback and admission time. +// Leave room for the search proxy's 45s deadline to report an upstream failure. +export const FOOD_SEARCH_TIMEOUT_MS = 50_000; export const DEFAULT_BACKEND_WARMUP_TIMEOUT_MS = 180_000; // Render may not wake one free service from another free service's proxy diff --git a/frontend/public/calorieapp-background.png b/frontend/public/calorieapp-background.png new file mode 100644 index 0000000..7961fe8 Binary files /dev/null and b/frontend/public/calorieapp-background.png differ diff --git a/tools/tests/food_logging_ui.test.mjs b/tools/tests/food_logging_ui.test.mjs new file mode 100644 index 0000000..a4b78be --- /dev/null +++ b/tools/tests/food_logging_ui.test.mjs @@ -0,0 +1,320 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import vm from "node:vm"; + +const requireFromFrontend = createRequire(new URL("../../frontend/package.json", import.meta.url)); +const typescript = requireFromFrontend("typescript"); +const AUTH_EVENT = "test-auth-state-changed"; +const foods = Array.from({ length: 30 }, (_, index) => ({ + product_name: index === 0 ? "Banana" : `Oats ${index}`, + brand: `Brand ${index}`, + barcode: String(10000 + index), + calories: 200, protein: 10, fat: 4, carbohydrates: 32, +})); + +function nodes(tree, predicate) { + if (Array.isArray(tree)) return tree.flatMap((child) => nodes(child, predicate)); + if (!tree || typeof tree !== "object") return []; + return [...(predicate(tree) ? [tree] : []), ...nodes(tree.props?.children, predicate)]; +} + +function text(tree) { + if (Array.isArray(tree)) return tree.map(text).join(" "); + if (tree == null || typeof tree === "boolean") return ""; + return typeof tree === "object" ? text(tree.props?.children) : String(tree); +} + +function button(tree, label) { + const result = nodes(tree, (node) => node.type === "button" && text(node).trim() === label); + assert.equal(result.length, 1, `Expected one ${label} button`); + return result[0]; +} + +async function harness(componentName = "FoodSearchPlaceholder", postResponse, logsResponse) { + const source = await readFile(new URL(`../../frontend/components/${componentName}.tsx`, import.meta.url), "utf8"); + const compiled = typescript.transpileModule(source, { + compilerOptions: { jsx: typescript.JsxEmit.ReactJSX, module: typescript.ModuleKind.CommonJS, target: typescript.ScriptTarget.ES2022 }, + }).outputText; + const hooks = [], listeners = new Map(), requests = [], focusEvents = []; + const document = { body: {}, activeElement: null }; + document.activeElement = document.body; + let cursor = 0, effects = [], tree, currentRefs = new Set(), props = {}, saved = []; + const hook = (initial) => { + const index = cursor++; + if (!(index in hooks)) hooks[index] = initial(); + return index; + }; + const sameDeps = (left, right) => left && right && left.length === right.length && left.every((value, index) => Object.is(value, right[index])); + const react = { + useState(initial) { + const index = hook(() => typeof initial === "function" ? initial() : initial); + return [hooks[index], (value) => { hooks[index] = typeof value === "function" ? value(hooks[index]) : value; }]; + }, + useRef(initial) { return hooks[hook(() => ({ current: initial }))]; }, + useId() { return hooks[hook(() => `test-region-${cursor}`)]; }, + useMemo(factory, deps) { + const index = hook(() => null); + if (!sameDeps(hooks[index]?.deps, deps)) hooks[index] = { deps, value: factory() }; + return hooks[index].value; + }, + useCallback(callback, deps) { return react.useMemo(() => callback, deps); }, + useEffect(effect, deps) { + const index = hook(() => null); + if (!sameDeps(hooks[index]?.deps, deps)) { + effects.push(() => { + hooks[index]?.cleanup?.(); + hooks[index] = { deps, cleanup: effect() }; + }); + } + }, + }; + const module = { exports: {} }; + vm.runInNewContext(compiled, { + module, exports: module.exports, AbortController, console, URL, document, + window: { + addEventListener(name, listener) { listeners.set(name, listener); }, + removeEventListener(name, listener) { if (listeners.get(name) === listener) listeners.delete(name); }, + }, + require(specifier) { + if (specifier === "react") return react; + if (specifier === "react/jsx-runtime") { + const jsx = (type, props, key) => ({ type, props, key }); + return { jsx, jsxs: jsx, Fragment: "Fragment" }; + } + if (specifier === "next/image") return { __esModule: true, default: "Image" }; + if (specifier === "@/components/authEvents") return { AUTH_STATE_CHANGED_EVENT: AUTH_EVENT }; + if (specifier.startsWith("@/components/")) { + const name = specifier.split("/").at(-1); + return { [name]: name }; + } + if (specifier === "@/lib/backendRequest") return { + BACKEND_WAKE_BASE_URL: "https://backend.example", + waitForBackendReady: async () => {}, + backendUnavailableMessage: (_error, fallback) => fallback, + async backendRequest(url, options) { + requests.push({ url, options }); + if (url.includes("/search-food?")) return { ok: true, json: async () => ({ results: foods }) }; + if (url.endsWith("/log-food")) { + const response = postResponse ? await postResponse() : { ok: true, status: 201 }; + if (response.ok) saved.push({ ...JSON.parse(options.body), id: saved.length + 1 }); + return response; + } + if (url.endsWith("/logs")) { + const response = logsResponse ? await logsResponse() : null; + return response ?? { ok: true, json: async () => saved }; + } + throw new Error(`Unexpected request: ${url}`); + }, + }; + throw new Error(`Unexpected import: ${specifier}`); + }, + }); + const render = (nextProps = props) => { + props = nextProps; + cursor = 0; + effects = []; + tree = module.exports[componentName](props); + const refNodes = nodes(tree, (node) => node.props?.ref); + const nextRefs = new Set(refNodes.map((node) => node.props.ref)); + for (const ref of currentRefs) if (!nextRefs.has(ref)) { + if (document.activeElement === ref.current) document.activeElement = document.body; + ref.current = null; + } + for (const node of refNodes) if (!node.props.ref.current) { + node.props.ref.current = { + focus(options) { document.activeElement = this; focusEvents.push({ type: "focus", element: node.type, options }); }, + scrollIntoView(options) { focusEvents.push({ type: "scroll", options }); }, + }; + } + currentRefs = nextRefs; + for (const effect of effects) effect(); + return tree; + }; + if (componentName === "FoodSearchPlaceholder") render(); + return { + render, requests, focusEvents, document, + get tree() { return tree; }, + cards() { return nodes(tree, (node) => node.type === "FoodCard"); }, + controls() { return this.cards().find((card) => card.props.children)?.props.children ?? null; }, + async flush() { await new Promise(setImmediate); return render(); }, + async search(query = "oats") { + nodes(tree, (node) => node.type === "SearchBar")[0].props.onQueryChange(query); + render(); + await nodes(tree, (node) => node.type === "SearchBar")[0].props.onSubmit({ preventDefault() {} }); + render(); + }, + choose(index) { this.cards()[index].props.onLog(); render(); }, + submit() { this.controls().props.onSubmit({ preventDefault() {} }); render(); }, + login() { listeners.get(AUTH_EVENT)?.({ detail: { authenticated: true } }); render(); }, + logout() { listeners.get(AUTH_EVENT)?.({ detail: { authenticated: false } }); render(); }, + }; +} + +test("a portion opens inside the chosen result, with its product identity beside confirmation", async () => { + const h = await harness(); + await h.search(); + h.choose(0); + assert.equal(h.cards().length, 30); + assert.equal(h.cards()[0].props.children.type, "form"); + assert.equal(h.cards().filter((card) => card.props.children).length, 1); + assert.match(text(h.controls()), /Banana/); + assert.match(text(h.controls()), /Brand 0/); + assert.equal(button(h.controls(), "Add to food log").props.disabled, false); + h.choose(15); + assert.equal(h.cards()[0].props.children, null); + assert.match(text(h.controls()), /Oats 15/); + button(h.controls(), "Cancel").props.onClick(); + h.render(); + assert.equal(h.controls(), null); + assert.equal(h.requests.filter((request) => request.url.endsWith("/log-food")).length, 0); +}); + +test("half a selected food posts the same scaled nutrition and confirms beside that result", async () => { + const h = await harness(); + await h.search(); + h.choose(4); + button(h.controls(), "Half - 50%").props.onClick(); + h.render(); + assert.match(text(h.controls()), /50.*Oats 4/); + h.submit(); + await h.flush(); + const payload = JSON.parse(h.requests.find((request) => request.url.endsWith("/log-food")).options.body); + assert.equal(payload.product_name, "Oats 4"); + assert.equal(payload.portion_percentage, 50); + assert.deepEqual([payload.calories, payload.protein, payload.fat, payload.carbohydrates], [100, 5, 2, 16]); + assert.equal(h.controls(), null); + assert.match(h.cards()[4].props.feedback.message, /Added Oats 4 \(50%\)/); + h.logout(); + assert.ok(h.cards().every((card) => !card.props.feedback && !card.props.children)); +}); + +test("invalid custom portions cannot save and a new search clears the old selection", async () => { + const h = await harness(); + await h.search(); + h.choose(0); + button(h.controls(), "Custom").props.onClick(); + h.render(); + nodes(h.controls(), (node) => node.type === "input")[0].props.onChange({ target: { value: "0" } }); + h.render(); + assert.equal(button(h.controls(), "Add to food log").props.disabled, true); + h.submit(); + await h.flush(); + assert.equal(h.requests.filter((request) => request.url.endsWith("/log-food")).length, 0); + assert.equal(nodes(h.controls(), (node) => node.props?.id === "portion-validation").length, 1); + assert.equal(nodes(h.controls(), (node) => node.type === "ErrorBanner").length, 0); + assert.equal((text(h.controls()).match(/Enter a valid custom percentage/g) ?? []).length, 1); + await h.search("banana"); + assert.equal(h.controls(), null); + assert.ok(h.cards().every((card) => !card.props.feedback)); +}); + +test("saving prevents duplicate submissions, changing products and replacing search results", async () => { + let finish; + const h = await harness("FoodSearchPlaceholder", () => new Promise((resolve) => { finish = resolve; })); + await h.search(); + h.choose(1); + h.submit(); + assert.ok(h.cards().every((card) => card.props.isDisabled)); + h.submit(); + h.choose(2); + await h.search("different query"); + assert.match(text(h.controls()), /Oats 1/); + assert.equal(h.requests.filter((request) => request.url.endsWith("/log-food")).length, 1); + assert.equal(h.requests.filter((request) => request.url.includes("/search-food?")).length, 1); + finish({ ok: true, status: 201 }); + await h.flush(); + assert.match(h.cards()[1].props.feedback.message, /Added Oats 1/); +}); + +test("save errors stay with the food, while an expired session gives an inline sign-in message", async () => { + for (const status of [409, 500, 401]) { + const h = await harness("FoodSearchPlaceholder", async () => ({ ok: false, status })); + await h.search(); + h.choose(8); + h.submit(); + await h.flush(); + if (status === 401) { + assert.equal(h.controls(), null); + assert.equal(h.cards()[8].props.feedback.isError, true); + assert.match(h.cards()[8].props.feedback.message, /Sign in with Xaman/); + } else { + const errors = nodes(h.controls(), (node) => node.type === "ErrorBanner"); + assert.equal(errors.length, 1); + assert.match(errors[0].props.message, status === 409 ? /storage limit/ : /Unable to log/); + assert.match(text(h.controls()), /Oats 8/); + assert.equal(button(h.controls(), "Add to food log").props.disabled, false); + } + } +}); + +test("saving preserves the sign-in prompt or log-loading error until the log state actually changes", async () => { + for (const status of [401, 503]) { + let finish, logLoads = 0; + const h = await harness( + "FoodSearchPlaceholder", + () => new Promise((resolve) => { finish = resolve; }), + () => ++logLoads === 1 ? { ok: false, status } : null, + ); + h.login(); + await h.flush(); + await h.search(); + h.choose(1); + const visibleLogState = () => ({ + signIn: /Sign in to manage your food log/.test(text(h.tree)), + loadErrors: nodes(h.tree, (node) => node.type === "ErrorBanner") + .map((node) => node.props.message).filter((message) => /Unable to load logged foods/.test(message)), + }); + const before = visibleLogState(); + assert.deepEqual(before, status === 401 + ? { signIn: true, loadErrors: [] } + : { signIn: false, loadErrors: ["Unable to load logged foods right now."] }); + h.submit(); + assert.deepEqual(visibleLogState(), before, "Saving must preserve the unrelated log section's message."); + assert.doesNotMatch(text(h.tree), /Recent Log Summary/, "Do not briefly show an empty summary during the save."); + assert.equal(h.requests.filter((request) => request.url.endsWith("/log-food")).length, 1); + finish(status === 401 ? { ok: false, status: 401 } : { ok: true, status: 201 }); + await h.flush(); + if (status === 401) { + assert.deepEqual(visibleLogState(), before); + assert.match(h.cards()[1].props.feedback.message, /Sign in with Xaman/); + } else { + assert.deepEqual(visibleLogState(), { signIn: false, loadErrors: [] }); + assert.match(text(h.tree), /Recent Log Summary/); + assert.match(h.cards()[1].props.feedback.message, /Added Oats 1/); + } + } +}); + +test("a response arriving after logout cannot restore the former selection or success message", async () => { + let finish; + const h = await harness("FoodSearchPlaceholder", () => new Promise((resolve) => { finish = resolve; })); + await h.search(); + h.choose(3); + h.submit(); + h.logout(); + finish({ ok: true, status: 201 }); + await h.flush(); + assert.equal(h.controls(), null); + assert.ok(h.cards().every((card) => !card.props.feedback)); + assert.equal(h.requests.filter((request) => request.url.endsWith("/logs")).length, 0); +}); + +test("expanding a card reveals its portion region once and restores keyboard focus on cancel", async () => { + const h = await harness("FoodCard"); + const props = { item: foods[0], isLogging: false, onLog() {}, formatNumber: String }; + h.render(props); + h.render({ ...props, children: { type: "form", props: { children: "Portion controls" } } }); + assert.deepEqual(h.focusEvents.map((event) => event.type), ["focus", "scroll"]); + assert.equal(nodes(h.tree, (node) => node.props?.role === "region")[0].props["aria-label"], "Choose a portion for Banana"); + h.render({ ...props, children: { type: "form", props: { children: "Changed portion" } } }); + assert.equal(h.focusEvents.length, 2, "Changing a portion must not scroll the page again"); + h.render(props); + assert.equal(h.focusEvents.at(-1).element, "button"); + h.render({ ...props, children: { type: "form", props: {} } }); + h.document.activeElement = {}; + const before = h.focusEvents.length; + h.render(props); + assert.equal(h.focusEvents.length, before, "Closing must not take focus from another control"); +}); diff --git a/tools/tests/food_search_deadline.test.mjs b/tools/tests/food_search_deadline.test.mjs new file mode 100644 index 0000000..effa3e8 --- /dev/null +++ b/tools/tests/food_search_deadline.test.mjs @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import vm from "node:vm"; + +const requireFromFrontend = createRequire(new URL("../../frontend/package.json", import.meta.url)); +const ts = requireFromFrontend("typescript"); + +function clock() { + let now = 0, id = 0; + const jobs = new Map(); + const settle = async () => { for (let i = 0; i < 12; i++) await Promise.resolve(); }; + return { + setTimeout(callback, delay) { jobs.set(++id, { at: now + delay, callback }); return id; }, + clearTimeout(key) { jobs.delete(key); }, + async advance(ms) { + await settle(); + const end = now + ms; + for (;;) { + const next = [...jobs].sort((a, b) => a[1].at - b[1].at)[0]; + if (!next || next[1].at > end) break; + now = next[1].at; jobs.delete(next[0]); next[1].callback(); + await settle(); + } + now = end; await settle(); + }, + }; +} + +class NextResponse extends Response { + static json(value, options) { return new NextResponse(JSON.stringify(value), options); } +} + +async function load(relative, fetchImpl, time) { + const source = await readFile(new URL(`../../${relative}`, import.meta.url), "utf8"); + const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText; + const module = { exports: {} }; + vm.runInNewContext(compiled, { + module, exports: module.exports, fetch: fetchImpl, ...time, + AbortController, Headers, Response, URL, Error, console, + process: { env: { BACKEND_URL: "https://backend.example" } }, + require(name) { + if (name === "next/server") return { NextResponse }; + if (name === "@/lib/accountErasureRequest") return { isTrustedAccountErasureRequest: () => true }; + if (name === "@/lib/privateExportRequest") return { isTrustedPrivateExportRequest: () => true }; + if (name === "@/lib/accountImportRequest") return { ACCOUNT_IMPORT_PATH: "api/identity/import", isTrustedAccountImportRequest: () => true }; + throw new Error(`Unexpected module ${name}`); + }, + }); + return module.exports; +} + +async function harness(path, delay, responseStatus = 200) { + const time = clock(), calls = []; + const route = await load("frontend/app/api/backend/[...path]/route.ts", (url, init) => { + calls.push(String(url)); + return new Promise((resolve, reject) => { + const timer = time.setTimeout(() => resolve(new Response(JSON.stringify({ results: [{ product_name: "Magnum" }] }), { + status: responseStatus, headers: { "content-type": "application/json", "retry-after": "60" }, + })), delay); + init.signal.addEventListener("abort", () => { + time.clearTimeout(timer); + const error = new Error("timed out"); error.name = "AbortError"; reject(error); + }); + }); + }, time); + const client = await load("frontend/lib/backendRequest.ts", (_url, init) => new Promise((resolve, reject) => { + init.signal.addEventListener("abort", () => reject(init.signal.reason)); + route.GET({ method: "GET", headers: new Headers(), nextUrl: new URL(`https://app.example/api/backend/${path}?q=Magnum`) }, { params: { path: path.split("/") } }).then(resolve, reject); + }), time); + return { time, calls, client }; +} + +test("one search survives a slow provider fallback through both proxy and browser", async () => { + const h = await harness("search-food", 26_000); + let settled = false; + const result = h.client.backendRequest("/api/backend/search-food?q=Magnum", {}, h.client.FOOD_SEARCH_TIMEOUT_MS).then(response => { settled = true; return response; }); + await h.time.advance(19_000); + assert.equal(settled, false, "the proxy must not turn a still-running search into an 18s error"); + await h.time.advance(7_000); + const response = await result; + assert.equal(response.status, 200); + assert.equal((await response.json()).results[0].product_name, "Magnum"); + assert.deepEqual(h.calls, ["https://backend.example/search-food?q=Magnum"]); +}); + +test("search still has a finite proxy deadline and does not retry on timeout", async () => { + const h = await harness("search-food", 60_000); + const result = h.client.backendRequest("/api/backend/search-food", {}, h.client.FOOD_SEARCH_TIMEOUT_MS); + await h.time.advance(45_000); + assert.equal((await result).status, 504); + assert.equal(h.calls.length, 1); +}); + +test("ordinary requests retain their shorter deadline", async () => { + const h = await harness("logs", 26_000); + const result = h.client.backendRequest("/api/backend/logs"); + await h.time.advance(18_000); + const response = await result; + assert.equal(response.status, 504); + assert.equal(response.headers.get("set-cookie"), null); +}); + +test("rate limits are returned with Retry-After without another request", async () => { + const h = await harness("search-food", 100, 429); + const result = h.client.backendRequest("/api/backend/search-food", {}, h.client.FOOD_SEARCH_TIMEOUT_MS); + await h.time.advance(100); + const response = await result; + assert.equal(response.status, 429); + assert.equal(response.headers.get("retry-after"), "60"); + assert.equal(h.calls.length, 1); +});