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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-09 04:11:08 +02:00
1 parent 51c40c61d9
commit 22d5c01a68
2 files changed
+127 -51

No files matched your search

@@ -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(
<I18nProvider initialLocale="en">
<AiInsightsCard isAuthenticated={true} />
</I18nProvider>,
);
}
describe("<AiInsightsCard> — 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"');
});
});
+81 -51
View File
@@ -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({
/>
<div className="space-y-4">
<ActiveProviderSelect
value={selectedProvider}
onChange={pickProvider}
/>
{hasReadError ? (
<QueryErrorCard onRetry={retryReads} />
) : (
<>
<ActiveProviderSelect
value={selectedProvider}
onChange={pickProvider}
/>
<ProviderConfigCard
provider={selectedProvider}
insightsSettings={insightsSettings}
userProvider={userProvider}
/>
<ProviderConfigCard
provider={selectedProvider}
insightsSettings={insightsSettings}
userProvider={userProvider}
/>
<FallbackChainCard
chain={chainData?.configuredChain ?? []}
selected={selectedProvider}
onSelect={pickProvider}
/>
<FallbackChainCard
chain={chainData?.configuredChain ?? []}
selected={selectedProvider}
onSelect={pickProvider}
/>
{/* v1.22 (#89) — per-user response timeout (mainly for slow
{/* v1.22 (#89) — per-user response timeout (mainly for slow
local/self-hosted backends). */}
<ResponseTimeoutCard userProvider={userProvider} />
<ResponseTimeoutCard userProvider={userProvider} />
{/* Read uploaded vault documents automatically with AI (opt-in). */}
<AutoReadCard />
{/* Read uploaded vault documents automatically with AI (opt-in). */}
<AutoReadCard />
{/* 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). */}
<CentralCodexSwitch settings={insightsSettings} />
<CentralCodexSwitch settings={insightsSettings} />
{/* 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. */}
<AiConsentCard isAuthenticated={isAuthenticated} />
<AiConsentCard isAuthenticated={isAuthenticated} />
<RuntimeActionsRow
provider={selectedProvider}
userProvider={userProvider}
canRegenerate={
insightsSettings?.codexStatus === "connected" ||
insightsSettings?.hasAdminKey ||
Boolean(userProvider?.provider)
}
privacyMode={insightsSettings?.privacyMode ?? "aggregated"}
lastInsightAt={insightsSettings?.lastInsightAt ?? null}
onRegenerated={() =>
queryClient.invalidateQueries({
queryKey: queryKeys.insightsRoot(),
})
}
onPrivacyChanged={() =>
queryClient.invalidateQueries({
queryKey: queryKeys.insightsRoot(),
})
}
/>
<RuntimeActionsRow
provider={selectedProvider}
userProvider={userProvider}
canRegenerate={
insightsSettings?.codexStatus === "connected" ||
insightsSettings?.hasAdminKey ||
Boolean(userProvider?.provider)
}
privacyMode={insightsSettings?.privacyMode ?? "aggregated"}
lastInsightAt={insightsSettings?.lastInsightAt ?? null}
onRegenerated={() =>
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. */}
<MedicalDisclaimer variant="dataPosture" />
<MedicalDisclaimer variant="dataPosture" />
</>
)}
</div>
</SettingsCard>
);