mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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"');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user