From 22d5c01a687db6c8bd231fb0ebe197e6cf9e9fd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 04:11:08 +0200 Subject: [PATCH] fix(settings): surface AI settings-read failures instead of a default form The three AI configuration reads each returned null on a non-OK response, so a failed read painted the default provider form as if the account were configured that way. Throw on a non-OK response so the query reports its error, and render a QueryErrorCard with retry when any read fails. Test watched the misleading form render first, then pass on the honest error. --- .../ai-insights-card-error-state.test.tsx | 46 ++++++ .../settings/ai/ai-insights-card.tsx | 132 +++++++++++------- 2 files changed, 127 insertions(+), 51 deletions(-) create mode 100644 src/components/settings/ai/__tests__/ai-insights-card-error-state.test.tsx diff --git a/src/components/settings/ai/__tests__/ai-insights-card-error-state.test.tsx b/src/components/settings/ai/__tests__/ai-insights-card-error-state.test.tsx new file mode 100644 index 000000000..506b19b8e --- /dev/null +++ b/src/components/settings/ai/__tests__/ai-insights-card-error-state.test.tsx @@ -0,0 +1,46 @@ +/** + * When the AI settings reads fail, the card must not render the provider form + * as if it were configured a certain way — a swallowed error there is a lie + * about the account's provider state. Every read is forced into its error + * state; the card has to render a `query-error-card` with retry and must not + * paint the active-provider select. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/settings/ai", + useSearchParams: () => new URLSearchParams(), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: undefined, + isError: true, + isLoading: false, + refetch: vi.fn(), + }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { AiInsightsCard } from "../ai-insights-card"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe(" — a settings-read failure is honest, not a default form", () => { + it("renders the query-error card with retry and never the provider form", () => { + const html = render(); + expect(html).toContain('data-slot="query-error-card"'); + expect(html).toContain('data-slot="query-error-retry"'); + expect(html).not.toContain('data-testid="ai-active-provider-select"'); + }); +}); diff --git a/src/components/settings/ai/ai-insights-card.tsx b/src/components/settings/ai/ai-insights-card.tsx index 0a75df2a3..7fdc3ccf8 100644 --- a/src/components/settings/ai/ai-insights-card.tsx +++ b/src/components/settings/ai/ai-insights-card.tsx @@ -22,6 +22,7 @@ import { Badge } from "@/components/ui/badge"; import { MedicalDisclaimer } from "@/components/common/medical-disclaimer"; import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { apiFetchRaw } from "@/lib/api/api-fetch"; @@ -61,39 +62,62 @@ export function AiInsightsCard({ const pathname = usePathname(); const searchParams = useSearchParams(); - const { data: insightsSettings } = useQuery({ + // A read failure must surface, not be swallowed into a null that renders the + // default provider form as if the account were configured that way (§6). The + // queryFns throw on a non-OK response so the query reports `isError`, and the + // card paints an honest error instead of a misleading form. + const { + data: insightsSettings, + isError: settingsError, + refetch: refetchSettings, + } = useQuery({ queryKey: queryKeys.insightsSettings(), queryFn: async () => { const res = await apiFetchRaw("/api/insights/settings"); - if (!res.ok) return null; + if (!res.ok) throw new Error("insights settings read failed"); const json = await res.json(); return json.data as InsightsSettings; }, enabled: isAuthenticated, }); - const { data: userProvider } = useQuery({ + const { + data: userProvider, + isError: providerError, + refetch: refetchProvider, + } = useQuery({ queryKey: queryKeys.userAiProvider(), queryFn: async () => { const res = await apiFetchRaw("/api/user/ai-provider"); - if (!res.ok) return null; + if (!res.ok) throw new Error("user AI provider read failed"); const json = await res.json(); return json.data as UserAIProvider; }, enabled: isAuthenticated, }); - const { data: chainData } = useQuery({ + const { + data: chainData, + isError: chainError, + refetch: refetchChain, + } = useQuery({ queryKey: queryKeys.insightsProviderChain(), queryFn: async () => { const res = await apiFetchRaw("/api/insights/provider-chain"); - if (!res.ok) return null; + if (!res.ok) throw new Error("insights provider chain read failed"); const json = await res.json(); return json.data as ProviderChainData; }, enabled: isAuthenticated, }); + const hasReadError = settingsError || providerError || chainError; + function retryReads() { + void refetchSettings(); + void refetchProvider(); + void refetchChain(); + } + // v1.16.13 — heal the web AI-consent receipt on mount. The server-side // consent gate (admin-openai egress) requires an active `ai_full` // receipt; the web client never minted one, so existing web users on a @@ -160,65 +184,71 @@ export function AiInsightsCard({ />
- + {hasReadError ? ( + + ) : ( + <> + - + - + - {/* v1.22 (#89) — per-user response timeout (mainly for slow + {/* v1.22 (#89) — per-user response timeout (mainly for slow local/self-hosted backends). */} - + - {/* Read uploaded vault documents automatically with AI (opt-in). */} - + {/* Read uploaded vault documents automatically with AI (opt-in). */} + - {/* Use the operator's shared central Codex connection (opt-in; only + {/* Use the operator's shared central Codex connection (opt-in; only renders when the operator has connected it). */} - + - {/* The standing consent and the one control that withdraws it. It + {/* The standing consent and the one control that withdraws it. It sits last because it governs everything above rather than configuring any single provider. */} - + - - queryClient.invalidateQueries({ - queryKey: queryKeys.insightsRoot(), - }) - } - onPrivacyChanged={() => - queryClient.invalidateQueries({ - queryKey: queryKeys.insightsRoot(), - }) - } - /> + + queryClient.invalidateQueries({ + queryKey: queryKeys.insightsRoot(), + }) + } + onPrivacyChanged={() => + queryClient.invalidateQueries({ + queryKey: queryKeys.insightsRoot(), + }) + } + /> - {/* v1.25 — the data-posture statement lives where the provider key is + {/* v1.25 — the data-posture statement lives where the provider key is configured: self-hosted, data stays on this instance, BYOK / local model, no third-party analytics. One calm line, not a banner. */} - + + + )}
);