mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
web: one API call layer (fetchJson/fetchOk/fetchBlob), notifyError, ratchets with the full baseline (audit package 5, stage 1)
lib/scopedFetch.ts becomes lib/apiClient.ts: ApiError carries retryAfterSeconds (body first, Retry-After header second), fetchJson<T> refuses empty and malformed 2xx bodies instead of handing back undefined, sets Content-Type for string bodies, fetchOk covers action calls and fetchBlob downloads. describeApiError renders 429/503 with the server's retry advice; apiErrorText is the one-string form for inline error slots; apiErrorNotification folds the "Session Expired" dialect into one place and NotificationContext.notifyError uses it. useAuthenticatedFetch throws ApiError (correlation id preserved), drops the dead 204 branch and onTokenExpired. dedupedFetchJson<T> for the three feature-flag readers. lib/__tests__/apiClient.guard.test.ts ratchets raw authenticatedFetch(/dedupedAuthFetch(/ .json()/instanceof TokenExpiredError per file against a baseline that may only shrink (a count above fails as a new site, below as a stale entry); fetchJson without a type argument is a violation. Baseline today: 228 raw fetches, 221 parses, 140 token checks. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -42,12 +42,10 @@ export default function AnalyzeRulesPage() {
|
||||
|
||||
const { data: rules, loading, execute: fetchRulesExec, setData: setRules } = useAuthenticatedFetch<AnalyzeRule[]>({
|
||||
onError: (err) => showError(err.message),
|
||||
onTokenExpired: (err) => showError(err.message),
|
||||
});
|
||||
|
||||
const { execute: mutate } = useAuthenticatedFetch({
|
||||
onError: (err) => showError(err.message),
|
||||
onTokenExpired: (err) => showError(err.message),
|
||||
});
|
||||
|
||||
// Filter state
|
||||
|
||||
@@ -7,8 +7,7 @@ import { useTenant } from "../../../contexts/TenantContext";
|
||||
import { useAuth } from "../../../contexts/AuthContext";
|
||||
import { useNotifications } from "../../../contexts/NotificationContext";
|
||||
import { scopedApi } from "@/lib/scopedApi";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import { apiErrorFromResponse, describeApiError } from "@/lib/scopedFetch";
|
||||
import { fetchJson } from "@/lib/apiClient";
|
||||
import { formatBytes, formatDuration } from "@/lib/formatting";
|
||||
import DoBreakdownBar from "@/components/DoBreakdownBar";
|
||||
import { CalculatingInline } from "@/components/CalculatingCard";
|
||||
@@ -82,7 +81,7 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) {
|
||||
const router = useRouter();
|
||||
const { tenantId } = useTenant();
|
||||
const { getAccessToken } = useAuth();
|
||||
const { addNotification } = useNotifications();
|
||||
const { notifyError } = useNotifications();
|
||||
const { isGlobalAdmin, scopeInitialized, scopeKey } = scope;
|
||||
|
||||
const [data, setData] = useState<AppsListResponse | null>(null);
|
||||
@@ -110,32 +109,26 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) {
|
||||
const listUrl = scopedApi.appsList(scope, days);
|
||||
const metricsUrl = scopedApi.appMetrics(scope, days);
|
||||
|
||||
const [listRes, metricsRes] = await Promise.all([
|
||||
authenticatedFetch(listUrl, getAccessToken, { signal: AbortSignal.timeout(APPS_FETCH_TIMEOUT_MS) }),
|
||||
authenticatedFetch(metricsUrl, getAccessToken, { signal: AbortSignal.timeout(APPS_FETCH_TIMEOUT_MS) }),
|
||||
// The list is the page; the DO rollup is decoration — its failure stays silent.
|
||||
const [listRes, metricsRes] = await Promise.allSettled([
|
||||
fetchJson<AppsListResponse>(listUrl, getAccessToken, { signal: AbortSignal.timeout(APPS_FETCH_TIMEOUT_MS) }),
|
||||
fetchJson<AppMetricsResponse>(metricsUrl, getAccessToken, { signal: AbortSignal.timeout(APPS_FETCH_TIMEOUT_MS) }),
|
||||
]);
|
||||
|
||||
if (cancelled) return;
|
||||
if (listRes.ok) {
|
||||
setData((await listRes.json()) as AppsListResponse);
|
||||
if (listRes.status === "fulfilled") {
|
||||
setData(listRes.value);
|
||||
succeeded = true;
|
||||
} else {
|
||||
// Error envelope: the backend's message plus the correlation id as a quotable reference.
|
||||
const { message, reference } = describeApiError(await apiErrorFromResponse(listRes));
|
||||
addNotification("error", "Backend Error", `Failed to load apps: ${message}`, "apps-list-error", undefined, reference ?? undefined);
|
||||
notifyError("Backend Error", listRes.reason, "apps-list-error", "Failed to load apps.");
|
||||
}
|
||||
if (metricsRes.ok) {
|
||||
const m = (await metricsRes.json()) as AppMetricsResponse;
|
||||
setDoRollup(m.deliveryOptimization ?? null);
|
||||
if (metricsRes.status === "fulfilled") {
|
||||
setDoRollup(metricsRes.value.deliveryOptimization ?? null);
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
if (err instanceof TokenExpiredError) {
|
||||
addNotification("error", "Session Expired", err.message, "session-expired-error");
|
||||
} else {
|
||||
console.error("Failed to fetch app installs", err);
|
||||
addNotification("error", "Backend Not Reachable", "Unable to load app dashboard data.", "apps-list-error");
|
||||
}
|
||||
console.error("Failed to fetch app installs", err);
|
||||
notifyError("Backend Not Reachable", err, "apps-list-error", "Unable to load app dashboard data.");
|
||||
} finally {
|
||||
progressFinish(succeeded);
|
||||
if (!cancelled) setLoading(false);
|
||||
|
||||
@@ -3,17 +3,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import { dedupedAuthFetch } from "@/lib/dedupedAuthFetch";
|
||||
import type { NotificationType } from "@/contexts/NotificationContext";
|
||||
import { dedupedFetchJson } from "@/lib/dedupedAuthFetch";
|
||||
import { missingContactProfileParts } from "@/lib/edition";
|
||||
|
||||
type AddNotification = (
|
||||
type: NotificationType,
|
||||
title: string,
|
||||
message: string,
|
||||
key?: string,
|
||||
href?: string,
|
||||
) => void;
|
||||
/** NotificationContext.notifyError, injected so the hook stays free of the provider. */
|
||||
type NotifyError = (title: string, err: unknown, key?: string, fallback?: string) => void;
|
||||
|
||||
/** The subset of the feature-flags response the dashboard banners read. */
|
||||
export interface TenantConfigurationSummary {
|
||||
@@ -79,7 +73,7 @@ export function useTenantSecurityConfig(
|
||||
tenantId: string | null | undefined,
|
||||
user: User | null | undefined,
|
||||
getAccessToken: (forceRefresh?: boolean) => Promise<string | null>,
|
||||
addNotification: AddNotification,
|
||||
notifyError: NotifyError,
|
||||
): TenantSecuritySummary {
|
||||
const [summary, setSummary] = useState<TenantSecuritySummary>(EMPTY_SUMMARY);
|
||||
|
||||
@@ -95,19 +89,12 @@ export function useTenantSecurityConfig(
|
||||
if (!user.isTenantAdmin && !user.isGlobalAdmin && user.role == null) return;
|
||||
|
||||
try {
|
||||
const response = await dedupedAuthFetch(api.config.featureFlags(tenantId), getAccessToken);
|
||||
|
||||
if (!response.ok) {
|
||||
setSummary(EMPTY_SUMMARY);
|
||||
return;
|
||||
}
|
||||
|
||||
const data: TenantConfigurationSummary = await response.json();
|
||||
const data = await dedupedFetchJson<TenantConfigurationSummary>(api.config.featureFlags(tenantId), getAccessToken);
|
||||
setSummary(summarizeTenantSecurityConfig(data));
|
||||
} catch (error) {
|
||||
if (error instanceof TokenExpiredError) {
|
||||
addNotification('error', 'Session Expired', error.message, 'session-expired-error');
|
||||
}
|
||||
// Fail-soft on a backend refusal (the banners just stay quiet); a token expiry is the
|
||||
// one failure the user must hear about.
|
||||
if (error instanceof TokenExpiredError) notifyError('Session Expired', error);
|
||||
setSummary(EMPTY_SUMMARY);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,7 +99,7 @@ function HomeContent() {
|
||||
? "w-full px-4 sm:px-6 lg:px-8 py-4"
|
||||
: "max-w-7xl mx-auto py-4 sm:px-6 lg:px-8";
|
||||
const { user, getAccessToken, isActivationPending, hasGlobalScope } = useAuth();
|
||||
const { addNotification } = useNotifications();
|
||||
const { addNotification, notifyError } = useNotifications();
|
||||
// `?tenant=<id>` deep-links a cross-tenant view onto one tenant — used by the /fleet card grid to drill
|
||||
// a managed tenant into this dashboard. Ignored for non-cross-tenant users (the filter is unused there).
|
||||
const initialTenantFilter = searchParams?.get("tenant") ?? "";
|
||||
@@ -262,7 +262,7 @@ function HomeContent() {
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
const { serialValidationEnabled, proContactMissing, proContactMissingParts, appHomingFunnelActive } = useTenantSecurityConfig(tenantId, user, getAccessToken, addNotification);
|
||||
const { serialValidationEnabled, proContactMissing, proContactMissingParts, appHomingFunnelActive } = useTenantSecurityConfig(tenantId, user, getAccessToken, notifyError);
|
||||
|
||||
// Disable global-scope mode for users without platform scope. A read-only Global Reader keeps it
|
||||
// (their cross-tenant view is read-only-safe; writes are gated separately + backend-enforced).
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useTenant } from "../../contexts/TenantContext";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { useNotifications } from "../../contexts/NotificationContext";
|
||||
import { scopedApi } from "@/lib/scopedApi";
|
||||
import { fetchJson } from "@/lib/scopedFetch";
|
||||
import { fetchJson } from "@/lib/apiClient";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import FleetStatCard from "./components/FleetStatCard";
|
||||
import TimeAttributionSection, { TimeAttributionResponseDto } from "./components/TimeAttributionSection";
|
||||
|
||||
@@ -28,12 +28,10 @@ export default function GatherRulesPage() {
|
||||
|
||||
const { data: rules, loading, execute: fetchRulesExec, setData: setRules } = useAuthenticatedFetch<GatherRule[]>({
|
||||
onError: (err) => showError(err.message),
|
||||
onTokenExpired: (err) => showError(err.message),
|
||||
});
|
||||
|
||||
const { execute: mutate } = useAuthenticatedFetch({
|
||||
onError: (err) => showError(err.message),
|
||||
onTokenExpired: (err) => showError(err.message),
|
||||
});
|
||||
|
||||
// Filter state
|
||||
|
||||
@@ -28,12 +28,10 @@ export default function ImeLogPatternsPage() {
|
||||
|
||||
const { data: patterns, loading, execute: fetchPatternsExec, setData: setPatterns } = useAuthenticatedFetch<ImeLogPattern[]>({
|
||||
onError: (err) => showError(err.message),
|
||||
onTokenExpired: (err) => showError(err.message),
|
||||
});
|
||||
|
||||
const { execute: mutate } = useAuthenticatedFetch({
|
||||
onError: (err) => showError(err.message),
|
||||
onTokenExpired: (err) => showError(err.message),
|
||||
});
|
||||
|
||||
// Filters
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { dedupedAuthFetch } from "@/lib/dedupedAuthFetch";
|
||||
import { dedupedFetchJson } from "@/lib/dedupedAuthFetch";
|
||||
import type { TenantFeatureFlagsResponse } from "@/utils/wire-types.generated";
|
||||
|
||||
interface UseSessionTenantConfigReturn {
|
||||
showScriptOutput: boolean;
|
||||
@@ -36,9 +37,7 @@ export function useSessionTenantConfig(
|
||||
// These flags are exposed via the member-readable feature-flags endpoint so that
|
||||
// Operators and Viewers can load session details without 403'ing on the admin-only
|
||||
// full /api/config/{tenantId} response.
|
||||
const res = await dedupedAuthFetch(api.config.featureFlags(sessionTenantId), getAccessToken);
|
||||
if (!res.ok || cancelled) return;
|
||||
const cfg = await res.json();
|
||||
const cfg = await dedupedFetchJson<TenantFeatureFlagsResponse>(api.config.featureFlags(sessionTenantId), getAccessToken);
|
||||
if (cancelled) return;
|
||||
setShowScriptOutput(cfg.showScriptOutput ?? true);
|
||||
setEnableSoftwareInventoryAnalyzer(cfg.enableSoftwareInventoryAnalyzer ?? false);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
|
||||
import { apiErrorNotification } from '@/lib/apiClient';
|
||||
|
||||
export type NotificationType = 'error' | 'warning' | 'info' | 'success';
|
||||
|
||||
@@ -20,6 +21,11 @@ interface NotificationContextType {
|
||||
notifications: Notification[];
|
||||
unreadCount: number;
|
||||
addNotification: (type: NotificationType, title: string, message: string, key?: string, href?: string, reference?: string) => void;
|
||||
/**
|
||||
* The error toast for a failed API call: message and reference from the error envelope
|
||||
* (describeApiError); a token expiry renders as "Session Expired" whatever the title.
|
||||
*/
|
||||
notifyError: (title: string, err: unknown, key?: string, fallback?: string) => void;
|
||||
markAsRead: (id: string) => void;
|
||||
markAllAsRead: () => void;
|
||||
removeNotification: (id: string) => void;
|
||||
@@ -101,6 +107,11 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
|
||||
}
|
||||
}, []);
|
||||
|
||||
const notifyError = useCallback((title: string, err: unknown, key?: string, fallback?: string) => {
|
||||
const n = apiErrorNotification(title, err, key, fallback);
|
||||
addNotification(n.type, n.title, n.message, n.key, undefined, n.reference);
|
||||
}, [addNotification]);
|
||||
|
||||
const markAsRead = useCallback((id: string) => {
|
||||
setNotifications(prev =>
|
||||
prev.map(n => n.id === id ? { ...n, read: true } : n)
|
||||
@@ -130,11 +141,12 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
|
||||
notifications,
|
||||
unreadCount,
|
||||
addNotification,
|
||||
notifyError,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
removeNotification,
|
||||
clearAll,
|
||||
}), [notifications, unreadCount, addNotification, markAsRead, markAllAsRead, removeNotification, clearAll]);
|
||||
}), [notifications, unreadCount, addNotification, notifyError, markAsRead, markAllAsRead, removeNotification, clearAll]);
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={value}>
|
||||
|
||||
@@ -4,16 +4,15 @@ import { useState, useCallback, useRef } from "react";
|
||||
import { useLatest } from "@/hooks/useLatest";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useNotifications } from "@/contexts/NotificationContext";
|
||||
import {
|
||||
authenticatedFetch,
|
||||
TokenExpiredError,
|
||||
} from "@/lib/authenticatedFetch";
|
||||
import { TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import { apiErrorText, fetchJson } from "@/lib/apiClient";
|
||||
|
||||
export interface UseAuthenticatedFetchOptions {
|
||||
/** Called on non-TokenExpiredError failures. If not set, only the error state is updated. */
|
||||
/**
|
||||
* Called on every failure (ApiError, TokenExpiredError, network). If not set, only the error
|
||||
* state is updated — except a token expiry, which the notification bell reports.
|
||||
*/
|
||||
onError?: (error: Error) => void;
|
||||
/** Called on TokenExpiredError. Default: addNotification via NotificationContext. */
|
||||
onTokenExpired?: (error: TokenExpiredError) => void;
|
||||
}
|
||||
|
||||
export interface ExecuteOptions<T> {
|
||||
@@ -41,7 +40,7 @@ export function useAuthenticatedFetch<T = unknown>(
|
||||
options?: UseAuthenticatedFetchOptions,
|
||||
): UseAuthenticatedFetchReturn<T> {
|
||||
const { getAccessToken } = useAuth();
|
||||
const { addNotification } = useNotifications();
|
||||
const { notifyError } = useNotifications();
|
||||
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -78,25 +77,7 @@ export function useAuthenticatedFetch<T = unknown>(
|
||||
}
|
||||
setError(null);
|
||||
|
||||
const response = await authenticatedFetch(url, getAccessToken, init);
|
||||
|
||||
if (!response.ok) {
|
||||
// Try to extract error message from response body (API returns { message: "..." })
|
||||
let message = `Failed: ${response.statusText}`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
if (errorData.message) message = errorData.message;
|
||||
} catch {
|
||||
/* response body wasn't JSON */
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const json = await fetchJson<unknown>(url, getAccessToken, init);
|
||||
const result = executeOptions?.transform
|
||||
? executeOptions.transform(json)
|
||||
: (json as T);
|
||||
@@ -111,26 +92,13 @@ export function useAuthenticatedFetch<T = unknown>(
|
||||
return null;
|
||||
}
|
||||
const opts = optionsRef.current;
|
||||
if (err instanceof TokenExpiredError) {
|
||||
if (opts?.onTokenExpired) {
|
||||
opts.onTokenExpired(err);
|
||||
} else {
|
||||
addNotification(
|
||||
"error",
|
||||
"Session Expired",
|
||||
err.message,
|
||||
"session-expired",
|
||||
);
|
||||
}
|
||||
} else if (opts?.onError) {
|
||||
opts.onError(
|
||||
err instanceof Error ? err : new Error(String(err)),
|
||||
);
|
||||
if (opts?.onError) {
|
||||
opts.onError(err instanceof Error ? err : new Error(String(err)));
|
||||
} else if (err instanceof TokenExpiredError) {
|
||||
notifyError("Session Expired", err);
|
||||
}
|
||||
|
||||
const message =
|
||||
err instanceof Error ? err.message : "An unknown error occurred";
|
||||
setError(message);
|
||||
setError(apiErrorText(err, "An unknown error occurred"));
|
||||
return null;
|
||||
} finally {
|
||||
// Only the spinner's owner may clear it — a superseded request finishing early
|
||||
@@ -140,7 +108,7 @@ export function useAuthenticatedFetch<T = unknown>(
|
||||
}
|
||||
}
|
||||
},
|
||||
[getAccessToken, addNotification, optionsRef],
|
||||
[getAccessToken, notifyError, optionsRef],
|
||||
);
|
||||
|
||||
return { data, loading, error, execute, clearError, setData };
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { api } from "@/lib/api";
|
||||
import { dedupedAuthFetch } from "@/lib/dedupedAuthFetch";
|
||||
import { dedupedFetchJson } from "@/lib/dedupedAuthFetch";
|
||||
import { parseEditionInfo, type EditionInfo } from "@/lib/edition";
|
||||
|
||||
/**
|
||||
@@ -30,9 +30,7 @@ export function useEditionInfo(): EditionInfo | null {
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
try {
|
||||
const res = await dedupedAuthFetch(api.config.featureFlags(tenantId), getAccessToken);
|
||||
if (!res.ok) return;
|
||||
const parsed = parseEditionInfo(await res.json());
|
||||
const parsed = parseEditionInfo(await dedupedFetchJson<unknown>(api.config.featureFlags(tenantId), getAccessToken));
|
||||
if (!cancelled) setInfo(parsed);
|
||||
} catch {
|
||||
// Leave null — chrome renders nothing rather than a guessed edition.
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
// Ratchet baseline for lib/__tests__/apiClient.guard.test.ts — per-file counts of the legacy
|
||||
// call-site patterns on 2026-09-06. Only ever shrinks: a migrated file lowers or drops its entry,
|
||||
// a new raw site anywhere fails the guard. Regenerate a shrunken map by hand, never by script over
|
||||
// the whole tree (the guard's stale check tells you which entries to lower).
|
||||
|
||||
/** Files that keep a raw site for a stated reason; they never leave the baseline. */
|
||||
export const PERMANENT: Record<string, string> = {
|
||||
"contexts/AuthContext.tsx": "auth/me bootstrap runs before the auth context exists — raw fetch by design",
|
||||
"components/landing/StatsBand.tsx": "unauthenticated public blob manifest, not the API",
|
||||
"app/health-check/page.tsx": "/version.json of the SWA itself, not the API",
|
||||
"app/settings/TenantConfigContext.tsx": "consent return path routes on TokenExpiredError (router.replace) — the one non-message handling",
|
||||
};
|
||||
|
||||
export const AUTHENTICATEDFETCH_BASELINE: Record<string, number> = {
|
||||
"app/admin/AdminConfigContext.tsx": 8,
|
||||
"app/admin/backups/components/RestoreRowDiffModal.tsx": 1,
|
||||
"app/admin/backups/detail/page.tsx": 2,
|
||||
"app/admin/backups/page.tsx": 3,
|
||||
"app/admin/components/ConfigReseedSection.tsx": 4,
|
||||
"app/admin/components/DeviceBlockSection.tsx": 6,
|
||||
"app/admin/components/DistressReportsSection.tsx": 1,
|
||||
"app/admin/components/FeedbackSection.tsx": 1,
|
||||
"app/admin/components/IdentityBindingsSection.tsx": 3,
|
||||
"app/admin/components/IgnoredSoftwareTab.tsx": 4,
|
||||
"app/admin/components/MaintenanceTriggerSection.tsx": 2,
|
||||
"app/admin/components/MappedSoftwareTab.tsx": 3,
|
||||
"app/admin/components/OpsEventsSection.tsx": 1,
|
||||
"app/admin/components/SessionExportSection.tsx": 1,
|
||||
"app/admin/components/SessionReportsSection.tsx": 3,
|
||||
"app/admin/components/TenantAdminSection.tsx": 5,
|
||||
"app/admin/components/TenantManagementSection.tsx": 10,
|
||||
"app/admin/components/UnmappedSoftwareTab.tsx": 5,
|
||||
"app/admin/components/VersionBlockSection.tsx": 3,
|
||||
"app/admin/components/VulnerabilityDataSection.tsx": 11,
|
||||
"app/admin/customs-archive/detail/page.tsx": 3,
|
||||
"app/admin/customs-archive/page.tsx": 2,
|
||||
"app/admin/metrics/sections/SectionAgentMetrics.tsx": 1,
|
||||
"app/admin/metrics/sections/SectionImePatternHealth.tsx": 1,
|
||||
"app/admin/metrics/sections/SectionMcpUsage.tsx": 2,
|
||||
"app/admin/metrics/sections/SectionPlatformUsage.tsx": 1,
|
||||
"app/admin/metrics/sections/SectionVerdictCalibration.tsx": 1,
|
||||
"app/admin/ops/session-cleanup/components/DeletionManifestSummaryView.tsx": 2,
|
||||
"app/admin/ops/session-cleanup/components/MaintenanceStatusBanner.tsx": 2,
|
||||
"app/admin/ops/session-cleanup/components/RestoreBrowserTab.tsx": 2,
|
||||
"app/admin/ops/session-cleanup/components/RestoreConfirmDialog.tsx": 1,
|
||||
"app/admin/ops/session-cleanup/page.tsx": 3,
|
||||
"app/admin/presence/page.tsx": 1,
|
||||
"app/admin/settings/sections/SectionDelegatedAdmins.tsx": 4,
|
||||
"app/admin/settings/sections/SectionEmailTemplates.tsx": 4,
|
||||
"app/admin/settings/sections/SectionTenantGroups.tsx": 2,
|
||||
"app/admin/settings/sections/SectionUsagePlans.tsx": 2,
|
||||
"app/admin/tenants/sections/SectionTenantConfigReport.tsx": 2,
|
||||
"app/analyze-rules/page.tsx": 2,
|
||||
"app/annotations/page.tsx": 1,
|
||||
"app/apps/components/InventoryTab.tsx": 1,
|
||||
"app/apps/components/VulnerabilitiesTab.tsx": 1,
|
||||
"app/apps/detail/page.tsx": 2,
|
||||
"app/audit/page.tsx": 1,
|
||||
"app/dashboard/hooks/useBlockDevice.ts": 1,
|
||||
"app/dashboard/hooks/useDashboardSessions.ts": 3,
|
||||
"app/dashboard/hooks/useDashboardStats.ts": 1,
|
||||
"app/dashboard/hooks/useDeleteSession.ts": 3,
|
||||
"app/dashboard/hooks/useTenantList.ts": 1,
|
||||
"app/dashboard/page.tsx": 1,
|
||||
"app/delegations/accept/page.tsx": 2,
|
||||
"app/diagnosis/page.tsx": 3,
|
||||
"app/fleet-health/hooks/useFleetHealth.ts": 1,
|
||||
"app/fleet-health/page.tsx": 1,
|
||||
"app/fleet/hooks/useFleetSummaries.ts": 1,
|
||||
"app/gather-rules/page.tsx": 1,
|
||||
"app/geographic-performance/page.tsx": 1,
|
||||
"app/geographic-performance/sessions/page.tsx": 1,
|
||||
"app/health-check/page.tsx": 2,
|
||||
"app/progress/hooks/useProgressEvents.ts": 3,
|
||||
"app/progress/hooks/useProgressSearch.ts": 1,
|
||||
"app/sessions/components/AnalysisResultsSection.tsx": 1,
|
||||
"app/sessions/components/CollectLogsButton.tsx": 3,
|
||||
"app/sessions/components/DeviceHistoryBanner.tsx": 1,
|
||||
"app/sessions/components/SessionAnnotationsCard.tsx": 2,
|
||||
"app/sessions/hooks/useSessionAnalysis.ts": 2,
|
||||
"app/sessions/hooks/useSessionDetail.ts": 1,
|
||||
"app/sessions/hooks/useSessionEvents.ts": 2,
|
||||
"app/sessions/network-timeline/page.tsx": 2,
|
||||
"app/sessions/page.tsx": 5,
|
||||
"app/settings/TenantConfigContext.tsx": 20,
|
||||
"app/settings/components/HardwareRejectionInsights.tsx": 1,
|
||||
"app/settings/components/McpUsersSection.tsx": 8,
|
||||
"app/settings/components/NotRegisteredDevicesInsights.tsx": 1,
|
||||
"app/settings/components/OffboardingSection.tsx": 1,
|
||||
"app/settings/components/TpmIncompatibleDevicesInsights.tsx": 1,
|
||||
"app/settings/reporting/sections/SectionMcpUsage.tsx": 2,
|
||||
"app/settings/tenant/sections/SectionDelegatedAccess.tsx": 6,
|
||||
"app/settings/tenant/sections/SectionOptionalGraphCapabilities.tsx": 2,
|
||||
"app/settings/tenant/sections/SectionSubmitLogs.tsx": 1,
|
||||
"app/sla/page.tsx": 1,
|
||||
"app/usage-metrics/page.tsx": 1,
|
||||
"components/DelegatedSlotPrompt.tsx": 1,
|
||||
"components/FeedbackBubble.tsx": 3,
|
||||
"components/GlobalSearch.tsx": 1,
|
||||
"contexts/GlobalNotificationContext.tsx": 3,
|
||||
"contexts/SignalRContext.tsx": 3,
|
||||
"contexts/TenantNotificationContext.tsx": 3,
|
||||
"hooks/useDiagnosticsPathsCatalog.ts": 1,
|
||||
"hooks/useTenantList.ts": 1,
|
||||
};
|
||||
|
||||
export const DEDUPEDAUTHFETCH_BASELINE: Record<string, number> = {
|
||||
};
|
||||
|
||||
export const JSONPARSE_BASELINE: Record<string, number> = {
|
||||
"app/activation/page.tsx": 1,
|
||||
"app/admin/AdminConfigContext.tsx": 8,
|
||||
"app/admin/backups/components/RestoreRowDiffModal.tsx": 1,
|
||||
"app/admin/backups/detail/page.tsx": 2,
|
||||
"app/admin/backups/page.tsx": 3,
|
||||
"app/admin/components/ConfigReseedSection.tsx": 8,
|
||||
"app/admin/components/DeviceBlockSection.tsx": 6,
|
||||
"app/admin/components/DistressReportsSection.tsx": 1,
|
||||
"app/admin/components/FeedbackSection.tsx": 1,
|
||||
"app/admin/components/IdentityBindingsSection.tsx": 3,
|
||||
"app/admin/components/IgnoredSoftwareTab.tsx": 2,
|
||||
"app/admin/components/MaintenanceTriggerSection.tsx": 1,
|
||||
"app/admin/components/MappedSoftwareTab.tsx": 3,
|
||||
"app/admin/components/OpsEventsSection.tsx": 1,
|
||||
"app/admin/components/SessionExportSection.tsx": 1,
|
||||
"app/admin/components/SessionReportsSection.tsx": 2,
|
||||
"app/admin/components/TenantAdminSection.tsx": 5,
|
||||
"app/admin/components/TenantManagementSection.tsx": 12,
|
||||
"app/admin/components/UnmappedSoftwareTab.tsx": 3,
|
||||
"app/admin/components/VersionBlockSection.tsx": 3,
|
||||
"app/admin/components/VulnerabilityDataSection.tsx": 11,
|
||||
"app/admin/customs-archive/detail/page.tsx": 2,
|
||||
"app/admin/customs-archive/page.tsx": 2,
|
||||
"app/admin/metrics/sections/SectionAgentMetrics.tsx": 1,
|
||||
"app/admin/metrics/sections/SectionImePatternHealth.tsx": 1,
|
||||
"app/admin/metrics/sections/SectionMcpUsage.tsx": 2,
|
||||
"app/admin/metrics/sections/SectionPlatformUsage.tsx": 1,
|
||||
"app/admin/metrics/sections/SectionVerdictCalibration.tsx": 1,
|
||||
"app/admin/ops/session-cleanup/components/DeletionManifestSummaryView.tsx": 1,
|
||||
"app/admin/ops/session-cleanup/components/MaintenanceStatusBanner.tsx": 1,
|
||||
"app/admin/ops/session-cleanup/components/RestoreBrowserTab.tsx": 2,
|
||||
"app/admin/ops/session-cleanup/components/RestoreConfirmDialog.tsx": 1,
|
||||
"app/admin/ops/session-cleanup/page.tsx": 3,
|
||||
"app/admin/presence/page.tsx": 1,
|
||||
"app/admin/settings/sections/SectionDelegatedAdmins.tsx": 4,
|
||||
"app/admin/settings/sections/SectionEmailTemplates.tsx": 3,
|
||||
"app/admin/settings/sections/SectionTenantGroups.tsx": 2,
|
||||
"app/admin/settings/sections/SectionUsagePlans.tsx": 3,
|
||||
"app/admin/tenants/sections/SectionTenantConfigReport.tsx": 2,
|
||||
"app/analyze-rules/page.tsx": 2,
|
||||
"app/annotations/page.tsx": 1,
|
||||
"app/apps/components/InventoryTab.tsx": 1,
|
||||
"app/apps/components/VulnerabilitiesTab.tsx": 1,
|
||||
"app/apps/detail/page.tsx": 2,
|
||||
"app/audit/page.tsx": 1,
|
||||
"app/dashboard/hooks/deleteSessionResponse.ts": 1,
|
||||
"app/dashboard/hooks/useBlockDevice.ts": 1,
|
||||
"app/dashboard/hooks/useDashboardSessions.ts": 4,
|
||||
"app/dashboard/hooks/useDashboardStats.ts": 2,
|
||||
"app/dashboard/hooks/useTenantList.ts": 1,
|
||||
"app/dashboard/page.tsx": 1,
|
||||
"app/delegations/accept/page.tsx": 3,
|
||||
"app/diagnosis/page.tsx": 3,
|
||||
"app/fleet-health/hooks/useFleetHealth.ts": 2,
|
||||
"app/fleet-health/page.tsx": 1,
|
||||
"app/fleet/hooks/useFleetSummaries.ts": 1,
|
||||
"app/gather-rules/page.tsx": 1,
|
||||
"app/geographic-performance/page.tsx": 1,
|
||||
"app/geographic-performance/sessions/page.tsx": 1,
|
||||
"app/health-check/page.tsx": 3,
|
||||
"app/progress/hooks/useProgressEvents.ts": 3,
|
||||
"app/progress/hooks/useProgressSearch.ts": 1,
|
||||
"app/sessions/components/AnalysisResultsSection.tsx": 1,
|
||||
"app/sessions/components/CollectLogsButton.tsx": 1,
|
||||
"app/sessions/components/DeviceHistoryBanner.tsx": 1,
|
||||
"app/sessions/components/SessionAnnotationsCard.tsx": 2,
|
||||
"app/sessions/hooks/useSessionAnalysis.ts": 2,
|
||||
"app/sessions/hooks/useSessionDetail.ts": 1,
|
||||
"app/sessions/hooks/useSessionEvents.ts": 2,
|
||||
"app/sessions/network-timeline/page.tsx": 2,
|
||||
"app/sessions/page.tsx": 2,
|
||||
"app/settings/TenantConfigContext.tsx": 23,
|
||||
"app/settings/components/HardwareRejectionInsights.tsx": 1,
|
||||
"app/settings/components/McpUsersSection.tsx": 8,
|
||||
"app/settings/components/NotRegisteredDevicesInsights.tsx": 1,
|
||||
"app/settings/components/TpmIncompatibleDevicesInsights.tsx": 1,
|
||||
"app/settings/reporting/sections/SectionMcpUsage.tsx": 2,
|
||||
"app/settings/tenant/sections/SectionDelegatedAccess.tsx": 6,
|
||||
"app/settings/tenant/sections/SectionOptionalGraphCapabilities.tsx": 1,
|
||||
"app/settings/tenant/sections/SectionSubmitLogs.tsx": 1,
|
||||
"app/sla/page.tsx": 1,
|
||||
"app/usage-metrics/page.tsx": 1,
|
||||
"components/DelegatedSlotPrompt.tsx": 1,
|
||||
"components/FeedbackBubble.tsx": 1,
|
||||
"components/GlobalSearch.tsx": 1,
|
||||
"components/landing/StatsBand.tsx": 2,
|
||||
"contexts/AuthContext.tsx": 3,
|
||||
"contexts/GlobalNotificationContext.tsx": 1,
|
||||
"contexts/TenantNotificationContext.tsx": 1,
|
||||
"hooks/useDiagnosticsPathsCatalog.ts": 1,
|
||||
"hooks/useTenantList.ts": 1,
|
||||
};
|
||||
|
||||
export const TOKENEXPIRED_BASELINE: Record<string, number> = {
|
||||
"app/admin/AdminConfigContext.tsx": 6,
|
||||
"app/admin/backups/components/RestoreRowDiffModal.tsx": 1,
|
||||
"app/admin/backups/detail/page.tsx": 2,
|
||||
"app/admin/backups/page.tsx": 3,
|
||||
"app/admin/components/ConfigReseedSection.tsx": 4,
|
||||
"app/admin/components/DeviceBlockSection.tsx": 6,
|
||||
"app/admin/components/DistressReportsSection.tsx": 1,
|
||||
"app/admin/components/FeedbackSection.tsx": 1,
|
||||
"app/admin/components/IdentityBindingsSection.tsx": 1,
|
||||
"app/admin/components/MaintenanceTriggerSection.tsx": 2,
|
||||
"app/admin/components/MappedSoftwareTab.tsx": 3,
|
||||
"app/admin/components/OpsEventsSection.tsx": 1,
|
||||
"app/admin/components/SessionExportSection.tsx": 1,
|
||||
"app/admin/components/SessionReportsSection.tsx": 3,
|
||||
"app/admin/components/TenantAdminSection.tsx": 5,
|
||||
"app/admin/components/TenantManagementSection.tsx": 6,
|
||||
"app/admin/components/UnmappedSoftwareTab.tsx": 2,
|
||||
"app/admin/components/VersionBlockSection.tsx": 3,
|
||||
"app/admin/components/VulnerabilityDataSection.tsx": 5,
|
||||
"app/admin/customs-archive/detail/page.tsx": 1,
|
||||
"app/admin/customs-archive/page.tsx": 2,
|
||||
"app/admin/metrics/sections/SectionAgentMetrics.tsx": 1,
|
||||
"app/admin/metrics/sections/SectionImePatternHealth.tsx": 1,
|
||||
"app/admin/metrics/sections/SectionMcpUsage.tsx": 1,
|
||||
"app/admin/metrics/sections/SectionPlatformUsage.tsx": 1,
|
||||
"app/admin/metrics/sections/SectionVerdictCalibration.tsx": 1,
|
||||
"app/admin/ops/session-cleanup/components/DeletionManifestSummaryView.tsx": 2,
|
||||
"app/admin/ops/session-cleanup/components/MaintenanceStatusBanner.tsx": 2,
|
||||
"app/admin/ops/session-cleanup/components/RestoreBrowserTab.tsx": 2,
|
||||
"app/admin/ops/session-cleanup/components/RestoreConfirmDialog.tsx": 1,
|
||||
"app/admin/ops/session-cleanup/page.tsx": 3,
|
||||
"app/admin/presence/page.tsx": 1,
|
||||
"app/admin/settings/sections/SectionDelegatedAdmins.tsx": 1,
|
||||
"app/admin/settings/sections/SectionEmailTemplates.tsx": 1,
|
||||
"app/admin/settings/sections/SectionTenantGroups.tsx": 1,
|
||||
"app/admin/settings/sections/SectionUsagePlans.tsx": 2,
|
||||
"app/admin/tenants/sections/SectionTenantConfigReport.tsx": 2,
|
||||
"app/apps/components/InventoryTab.tsx": 1,
|
||||
"app/apps/components/VulnerabilitiesTab.tsx": 1,
|
||||
"app/apps/detail/page.tsx": 2,
|
||||
"app/audit/page.tsx": 1,
|
||||
"app/dashboard/hooks/useBlockDevice.ts": 2,
|
||||
"app/dashboard/hooks/useDashboardSessions.ts": 3,
|
||||
"app/dashboard/hooks/useDashboardStats.ts": 1,
|
||||
"app/dashboard/hooks/useDeleteSession.ts": 2,
|
||||
"app/dashboard/hooks/useTenantSecurityConfig.ts": 1,
|
||||
"app/delegations/accept/page.tsx": 1,
|
||||
"app/diagnosis/page.tsx": 3,
|
||||
"app/fleet-health/hooks/useFleetHealth.ts": 1,
|
||||
"app/fleet-health/page.tsx": 1,
|
||||
"app/geographic-performance/page.tsx": 1,
|
||||
"app/geographic-performance/sessions/page.tsx": 1,
|
||||
"app/health-check/page.tsx": 1,
|
||||
"app/progress/hooks/useProgressEvents.ts": 1,
|
||||
"app/progress/hooks/useProgressSearch.ts": 1,
|
||||
"app/sessions/components/CollectLogsButton.tsx": 1,
|
||||
"app/sessions/components/SessionAnnotationsCard.tsx": 1,
|
||||
"app/sessions/hooks/useSessionDetail.ts": 1,
|
||||
"app/sessions/hooks/useSessionEvents.ts": 1,
|
||||
"app/sessions/page.tsx": 1,
|
||||
"app/settings/TenantConfigContext.tsx": 16,
|
||||
"app/settings/components/McpUsersSection.tsx": 6,
|
||||
"app/settings/components/OffboardingSection.tsx": 1,
|
||||
"app/settings/reporting/sections/SectionMcpUsage.tsx": 1,
|
||||
"app/settings/tenant/sections/SectionDelegatedAccess.tsx": 1,
|
||||
"app/settings/tenant/sections/SectionSubmitLogs.tsx": 1,
|
||||
"app/sla/page.tsx": 1,
|
||||
"app/usage-metrics/page.tsx": 1,
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import {
|
||||
AUTHENTICATEDFETCH_BASELINE,
|
||||
DEDUPEDAUTHFETCH_BASELINE,
|
||||
JSONPARSE_BASELINE,
|
||||
PERMANENT,
|
||||
TOKENEXPIRED_BASELINE,
|
||||
} from "./apiClient.guard.baseline";
|
||||
|
||||
/**
|
||||
* Ratchet for the one API call layer (lib/apiClient.ts, D-203): pages, components, hooks and
|
||||
* contexts call the backend through fetchJson<T>/fetchOk/fetchBlob and render failures through
|
||||
* describeApiError/apiErrorText/notifyError. Raw `authenticatedFetch(`, `.json()` parses and
|
||||
* `instanceof TokenExpiredError` branches are legacy; their per-file counts live in the baseline
|
||||
* module and may only go down. A count above its baseline (or a file not in the baseline) is a new
|
||||
* raw site; a count below it is a stale baseline entry that must be lowered — so the baseline
|
||||
* always states the true remaining debt.
|
||||
*
|
||||
* Comment lines are ignored; test files are excluded (they stub fetch deliberately).
|
||||
*/
|
||||
|
||||
const WEB_ROOT = join(__dirname, "..", "..");
|
||||
const SCAN_DIRS = ["app", "components", "hooks", "contexts"];
|
||||
/** The lib-level hook is itself a fetchJson consumer and the one place allowed to spell TokenExpiredError. */
|
||||
const EXEMPT_FILES = new Set(["hooks/useAuthenticatedFetch.ts"]);
|
||||
|
||||
interface Pattern {
|
||||
name: string;
|
||||
regex: RegExp;
|
||||
baseline: Record<string, number>;
|
||||
remedy: string;
|
||||
}
|
||||
|
||||
const PATTERNS: Pattern[] = [
|
||||
{
|
||||
name: "authenticatedFetch(",
|
||||
regex: /\bauthenticatedFetch\(/g,
|
||||
baseline: AUTHENTICATEDFETCH_BASELINE,
|
||||
remedy: "call fetchJson<T>/fetchOk/fetchBlob from @/lib/apiClient instead",
|
||||
},
|
||||
{
|
||||
name: "dedupedAuthFetch(",
|
||||
regex: /\bdedupedAuthFetch\(/g,
|
||||
baseline: DEDUPEDAUTHFETCH_BASELINE,
|
||||
remedy: "call dedupedFetchJson<T> from @/lib/dedupedAuthFetch instead",
|
||||
},
|
||||
{
|
||||
name: ".json()",
|
||||
regex: /\.json\(\)/g,
|
||||
baseline: JSONPARSE_BASELINE,
|
||||
remedy: "fetchJson<T> parses the body; a hand parse hides the error envelope",
|
||||
},
|
||||
{
|
||||
name: "instanceof TokenExpiredError",
|
||||
regex: /instanceof TokenExpiredError/g,
|
||||
baseline: TOKENEXPIRED_BASELINE,
|
||||
remedy: "notifyError/apiErrorText render a token expiry centrally; keep a branch only where the UI must act (route, reset)",
|
||||
},
|
||||
];
|
||||
|
||||
function collectFiles(dir: string, acc: string[]): void {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
if (entry === "node_modules" || entry === "__tests__" || entry.startsWith(".")) continue;
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
collectFiles(full, acc);
|
||||
} else if (/\.(ts|tsx)$/.test(entry) && !/\.test\.(ts|tsx)$/.test(entry)) {
|
||||
acc.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isCommentLine(line: string): boolean {
|
||||
const trimmed = line.trimStart();
|
||||
return trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*");
|
||||
}
|
||||
|
||||
/** file (repo-relative, forward slashes) → code lines. */
|
||||
function scannedSources(): Map<string, string[]> {
|
||||
const files: string[] = [];
|
||||
for (const dir of SCAN_DIRS) collectFiles(join(WEB_ROOT, dir), files);
|
||||
const out = new Map<string, string[]>();
|
||||
for (const file of files) {
|
||||
const rel = relative(WEB_ROOT, file).replace(/\\/g, "/");
|
||||
if (EXEMPT_FILES.has(rel)) continue;
|
||||
out.set(
|
||||
rel,
|
||||
readFileSync(file, "utf-8")
|
||||
.split("\n")
|
||||
.filter((l) => !isCommentLine(l)),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function countPerFile(sources: Map<string, string[]>, regex: RegExp): Map<string, number> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const [rel, lines] of sources) {
|
||||
let n = 0;
|
||||
for (const line of lines) n += line.match(regex)?.length ?? 0;
|
||||
if (n > 0) counts.set(rel, n);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
const sources = scannedSources();
|
||||
|
||||
describe("apiClient ratchet", () => {
|
||||
it("scans the app (plausibility floor)", () => {
|
||||
expect(sources.size).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
for (const pattern of PATTERNS) {
|
||||
describe(pattern.name, () => {
|
||||
const counts = countPerFile(sources, pattern.regex);
|
||||
|
||||
it("has no raw site above its baseline", () => {
|
||||
const violations: string[] = [];
|
||||
for (const [rel, n] of counts) {
|
||||
const allowed = pattern.baseline[rel] ?? 0;
|
||||
if (n > allowed) violations.push(`${rel}: ${n} (baseline ${allowed})`);
|
||||
}
|
||||
expect(
|
||||
violations,
|
||||
`New raw \`${pattern.name}\` site(s) — ${pattern.remedy}:\n ${violations.join("\n ")}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("has no stale baseline entry (the baseline states the true remaining debt)", () => {
|
||||
const stale: string[] = [];
|
||||
for (const [rel, allowed] of Object.entries(pattern.baseline)) {
|
||||
const n = counts.get(rel) ?? 0;
|
||||
if (n < allowed) stale.push(`${rel}: now ${n} (baseline ${allowed})`);
|
||||
}
|
||||
expect(
|
||||
stale,
|
||||
`Lower these entries in lib/__tests__/apiClient.guard.baseline.ts (${pattern.name}):\n ${stale.join("\n ")}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it("never calls fetchJson without an explicit type argument", () => {
|
||||
const violations: string[] = [];
|
||||
for (const [rel, lines] of sources) {
|
||||
lines.forEach((line, i) => {
|
||||
if (/\b(?:fetchJson|dedupedFetchJson)\(/.test(line)) violations.push(`${rel}:${i + 1}`);
|
||||
});
|
||||
}
|
||||
expect(violations, "fetchJson<T>(…) — name the wire type (utils/wire-types.generated):\n " + violations.join("\n ")).toEqual([]);
|
||||
});
|
||||
|
||||
it("documents every permanent exception against a file that is still in a baseline", () => {
|
||||
const inSomeBaseline = new Set(PATTERNS.flatMap((p) => Object.keys(p.baseline)));
|
||||
const rotten = Object.keys(PERMANENT).filter((rel) => !inSomeBaseline.has(rel));
|
||||
expect(rotten, "PERMANENT names a file with no baseline entry left — drop the reason").toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ApiError,
|
||||
apiErrorFromResponse,
|
||||
apiErrorNotification,
|
||||
apiErrorText,
|
||||
describeApiError,
|
||||
fetchBlob,
|
||||
fetchJson,
|
||||
fetchOk,
|
||||
} from "../apiClient";
|
||||
import { TokenExpiredError } from "../authenticatedFetch";
|
||||
|
||||
vi.mock("../appInsights", () => ({ trackEvent: vi.fn() }));
|
||||
|
||||
function jsonResponse(status: number, body: unknown, statusText = "Error", headers: Record<string, string> = {}): Response {
|
||||
return new Response(JSON.stringify(body), { status, statusText, headers: { "Content-Type": "application/json", ...headers } });
|
||||
}
|
||||
|
||||
describe("apiErrorFromResponse", () => {
|
||||
it("reads the error envelope: error, code, correlationId, hint, retryAfterSeconds", async () => {
|
||||
const err = await apiErrorFromResponse(
|
||||
jsonResponse(429, {
|
||||
error: "Rate limit exceeded.",
|
||||
code: "RateLimitExceeded",
|
||||
correlationId: "3f2a9c1e-7b4d-4e0a-9d2c-1a2b3c4d5e6f",
|
||||
hint: "Slow down.",
|
||||
retryAfterSeconds: 12,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(err).toBeInstanceOf(ApiError);
|
||||
expect(err.status).toBe(429);
|
||||
expect(err.message).toBe("Rate limit exceeded.");
|
||||
expect(err.code).toBe("RateLimitExceeded");
|
||||
expect(err.correlationId).toBe("3f2a9c1e-7b4d-4e0a-9d2c-1a2b3c4d5e6f");
|
||||
expect(err.hint).toBe("Slow down.");
|
||||
expect(err.retryAfterSeconds).toBe(12);
|
||||
});
|
||||
|
||||
it("takes Retry-After from the header when the body has no retryAfterSeconds", async () => {
|
||||
const err = await apiErrorFromResponse(jsonResponse(503, { error: "Busy.", code: "ServiceUnavailable", correlationId: "c" }, "x", { "Retry-After": "5" }));
|
||||
expect(err.retryAfterSeconds).toBe(5);
|
||||
|
||||
const none = await apiErrorFromResponse(jsonResponse(503, { error: "Busy.", code: "ServiceUnavailable", correlationId: "c" }));
|
||||
expect(none.retryAfterSeconds).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the pre-envelope message, then to statusText; an empty body is not an exception", async () => {
|
||||
const legacy = await apiErrorFromResponse(jsonResponse(400, { success: false, message: "tenantId is required" }, "Bad Request"));
|
||||
expect(legacy.message).toBe("tenantId is required");
|
||||
expect(legacy.code).toBe("");
|
||||
expect(legacy.correlationId).toBe("");
|
||||
|
||||
const bare = await apiErrorFromResponse(new Response("not json", { status: 502, statusText: "Bad Gateway" }));
|
||||
expect(bare.message).toBe("Bad Gateway");
|
||||
expect(bare.hint).toBeNull();
|
||||
|
||||
const empty = await apiErrorFromResponse(new Response("", { status: 500, statusText: "Internal Server Error" }));
|
||||
expect(empty.message).toBe("Internal Server Error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchJson / fetchOk / fetchBlob", () => {
|
||||
beforeEach(() => vi.stubGlobal("fetch", vi.fn()));
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
const fetchMock = () => globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
const getToken = () => vi.fn().mockResolvedValue("tok");
|
||||
|
||||
it("returns the parsed body on success and throws the envelope ApiError otherwise", async () => {
|
||||
fetchMock().mockResolvedValueOnce(jsonResponse(200, { items: [1] }));
|
||||
await expect(fetchJson<{ items: number[] }>("https://api/x", getToken())).resolves.toEqual({ items: [1] });
|
||||
|
||||
fetchMock().mockResolvedValueOnce(jsonResponse(403, { error: "Access denied.", code: "Forbidden", correlationId: "cid-1" }));
|
||||
const err = await fetchJson<unknown>("https://api/x", getToken()).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(ApiError);
|
||||
expect((err as ApiError).code).toBe("Forbidden");
|
||||
expect((err as ApiError).correlationId).toBe("cid-1");
|
||||
});
|
||||
|
||||
it("refuses an empty or malformed 2xx body instead of handing back undefined", async () => {
|
||||
fetchMock().mockResolvedValueOnce(new Response("", { status: 200 }));
|
||||
const empty = await fetchJson<unknown>("https://api/x", getToken()).catch((e) => e);
|
||||
expect(empty).toBeInstanceOf(ApiError);
|
||||
expect((empty as ApiError).message).toBe("Empty response body");
|
||||
|
||||
fetchMock().mockResolvedValueOnce(new Response("<html>", { status: 200 }));
|
||||
const html = await fetchJson<unknown>("https://api/x", getToken()).catch((e) => e);
|
||||
expect((html as ApiError).message).toBe("Malformed response body");
|
||||
});
|
||||
|
||||
it("sets Content-Type: application/json for a string body unless the caller set one", async () => {
|
||||
fetchMock().mockResolvedValue(jsonResponse(200, {}));
|
||||
await fetchJson<unknown>("https://api/x", getToken(), { method: "POST", body: JSON.stringify({ a: 1 }) });
|
||||
const headers1 = fetchMock().mock.calls[0][1].headers as Headers;
|
||||
expect(headers1.get("Content-Type")).toBe("application/json");
|
||||
|
||||
await fetchOk("https://api/x", getToken(), { method: "PUT", body: "raw", headers: { "Content-Type": "text/plain" } });
|
||||
const headers2 = fetchMock().mock.calls[1][1].headers as Headers;
|
||||
expect(headers2.get("Content-Type")).toBe("text/plain");
|
||||
|
||||
await fetchOk("https://api/x", getToken(), { method: "DELETE" });
|
||||
const headers3 = fetchMock().mock.calls[2][1].headers as Headers;
|
||||
expect(headers3.has("Content-Type")).toBe(false);
|
||||
});
|
||||
|
||||
it("fetchOk returns the response for status checks and throws on non-ok; fetchBlob returns the blob", async () => {
|
||||
fetchMock().mockResolvedValueOnce(new Response("", { status: 202 }));
|
||||
const res = await fetchOk("https://api/x", getToken(), { method: "POST" });
|
||||
expect(res.status).toBe(202);
|
||||
|
||||
fetchMock().mockResolvedValueOnce(jsonResponse(409, { error: "Locked.", code: "Conflict", correlationId: "c" }));
|
||||
await expect(fetchOk("https://api/x", getToken())).rejects.toBeInstanceOf(ApiError);
|
||||
|
||||
fetchMock().mockResolvedValueOnce(new Response(new Blob(["zip"]), { status: 200 }));
|
||||
const blob = await fetchBlob("https://api/x", getToken());
|
||||
expect(await blob.text()).toBe("zip");
|
||||
});
|
||||
|
||||
it("lets TokenExpiredError pass through untouched", async () => {
|
||||
const noToken = vi.fn().mockResolvedValue(null);
|
||||
await expect(fetchJson<unknown>("https://api/x", noToken)).rejects.toBeInstanceOf(TokenExpiredError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeApiError / apiErrorText / apiErrorNotification", () => {
|
||||
it("renders an ApiError with the short correlation id as reference", () => {
|
||||
const err = new ApiError(500, "GetSessions failed.", "InternalError", "3f2a9c1e-7b4d-4e0a-9d2c-1a2b3c4d5e6f");
|
||||
expect(describeApiError(err)).toEqual({ message: "GetSessions failed.", reference: "3f2a9c1e" });
|
||||
expect(apiErrorText(err)).toBe("GetSessions failed. (Ref 3f2a9c1e)");
|
||||
});
|
||||
|
||||
it("adds the retry advice on 429 and 503", () => {
|
||||
const limited = new ApiError(429, "Rate limit exceeded.", "RateLimitExceeded", "cid", null, 12);
|
||||
expect(describeApiError(limited).message).toBe("Rate limit exceeded. Try again in 12 s.");
|
||||
const busy = new ApiError(503, "", "ServiceUnavailable", "cid");
|
||||
expect(describeApiError(busy).message).toBe("Service temporarily unavailable. Try again shortly.");
|
||||
});
|
||||
|
||||
it("has no reference for a token expiry or a plain Error, and uses the fallback for unknown values", () => {
|
||||
expect(describeApiError(new TokenExpiredError()).reference).toBeNull();
|
||||
expect(describeApiError(new Error("boom"))).toEqual({ message: "boom", reference: null });
|
||||
expect(describeApiError(undefined, "Could not load.")).toEqual({ message: "Could not load.", reference: null });
|
||||
expect(describeApiError(new ApiError(502, "", "", ""), "Upstream failed.").message).toBe("Upstream failed.");
|
||||
expect(apiErrorText(new Error("boom"))).toBe("boom");
|
||||
});
|
||||
|
||||
it("builds the notification: caller title and key, reference from the envelope; token expiry overrides both", () => {
|
||||
const err = new ApiError(404, "Session not found.", "NotFound", "3f2a9c1e-7b4d-4e0a-9d2c-1a2b3c4d5e6f");
|
||||
expect(apiErrorNotification("Backend Error", err, "apps-list-error")).toEqual({
|
||||
type: "error",
|
||||
title: "Backend Error",
|
||||
message: "Session not found.",
|
||||
key: "apps-list-error",
|
||||
reference: "3f2a9c1e",
|
||||
});
|
||||
expect(apiErrorNotification("Backend Error", new TokenExpiredError(), "apps-list-error")).toEqual({
|
||||
type: "error",
|
||||
title: "Session Expired",
|
||||
message: new TokenExpiredError().message,
|
||||
key: "session-expired",
|
||||
reference: undefined,
|
||||
});
|
||||
expect(apiErrorNotification("Backend Error", "?", undefined, "Unable to load.").message).toBe("Unable to load.");
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError, apiErrorFromResponse, describeApiError, fetchJson } from "../scopedFetch";
|
||||
import { TokenExpiredError } from "../authenticatedFetch";
|
||||
|
||||
vi.mock("../appInsights", () => ({ trackEvent: vi.fn() }));
|
||||
|
||||
function jsonResponse(status: number, body: unknown, statusText = "Error"): Response {
|
||||
return new Response(JSON.stringify(body), { status, statusText, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
describe("apiErrorFromResponse", () => {
|
||||
it("reads the error envelope: error, code, correlationId, hint", async () => {
|
||||
const err = await apiErrorFromResponse(
|
||||
jsonResponse(404, { error: "Session not found", code: "NotFound", correlationId: "3f2a9c1e-7b4d-4e0a-9d2c-1a2b3c4d5e6f", hint: "Check the id." }),
|
||||
);
|
||||
|
||||
expect(err).toBeInstanceOf(ApiError);
|
||||
expect(err.status).toBe(404);
|
||||
expect(err.message).toBe("Session not found");
|
||||
expect(err.code).toBe("NotFound");
|
||||
expect(err.correlationId).toBe("3f2a9c1e-7b4d-4e0a-9d2c-1a2b3c4d5e6f");
|
||||
expect(err.hint).toBe("Check the id.");
|
||||
});
|
||||
|
||||
it("falls back to the pre-envelope message and then to statusText", async () => {
|
||||
const legacy = await apiErrorFromResponse(jsonResponse(400, { success: false, message: "tenantId is required" }, "Bad Request"));
|
||||
expect(legacy.message).toBe("tenantId is required");
|
||||
expect(legacy.code).toBe("");
|
||||
expect(legacy.correlationId).toBe("");
|
||||
|
||||
const bare = await apiErrorFromResponse(new Response("not json", { status: 502, statusText: "Bad Gateway" }));
|
||||
expect(bare.message).toBe("Bad Gateway");
|
||||
expect(bare.hint).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchJson", () => {
|
||||
beforeEach(() => vi.stubGlobal("fetch", vi.fn()));
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns the parsed body on success and throws the envelope ApiError otherwise", async () => {
|
||||
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(200, { items: [1] }));
|
||||
const getToken = vi.fn().mockResolvedValue("tok");
|
||||
|
||||
await expect(fetchJson<{ items: number[] }>("https://api/x", getToken)).resolves.toEqual({ items: [1] });
|
||||
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(403, { error: "Access denied.", code: "Forbidden", correlationId: "cid-1" }));
|
||||
const err = await fetchJson("https://api/x", getToken).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(ApiError);
|
||||
expect((err as ApiError).code).toBe("Forbidden");
|
||||
expect((err as ApiError).correlationId).toBe("cid-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeApiError", () => {
|
||||
it("renders an ApiError with the short correlation id as reference", () => {
|
||||
const err = new ApiError(500, "GetSessions failed.", "InternalError", "3f2a9c1e-7b4d-4e0a-9d2c-1a2b3c4d5e6f");
|
||||
expect(describeApiError(err)).toEqual({ message: "GetSessions failed.", reference: "3f2a9c1e" });
|
||||
});
|
||||
|
||||
it("has no reference for a token expiry or a plain Error, and uses the fallback for unknown values", () => {
|
||||
expect(describeApiError(new TokenExpiredError()).reference).toBeNull();
|
||||
expect(describeApiError(new Error("boom"))).toEqual({ message: "boom", reference: null });
|
||||
expect(describeApiError(undefined, "Could not load.")).toEqual({ message: "Could not load.", reference: null });
|
||||
expect(describeApiError(new ApiError(502, "", "", ""), "Upstream failed.").message).toBe("Upstream failed.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { authenticatedFetch, TokenExpiredError } from "./authenticatedFetch";
|
||||
import { shortCorrelationId } from "./correlationId";
|
||||
import type { ApiErrorResponse } from "@/utils/wire-types.generated";
|
||||
|
||||
/**
|
||||
* The one API call layer above authenticatedFetch: ok-check, body parse and the backend's
|
||||
* error envelope in one place, so no call site hand-rolls `if (!res.ok)`, `.json()` or an
|
||||
* `instanceof TokenExpiredError` branch (guarded by lib/__tests__/apiClient.guard.test.ts).
|
||||
*
|
||||
* fetchJson<T> — JSON body expected; throws ApiError on non-2xx, empty or malformed body
|
||||
* fetchOk — action calls whose body nobody reads; returns the ok Response (status only)
|
||||
* fetchBlob — downloads
|
||||
*
|
||||
* Every non-2xx body is the typed envelope `{ error, code, correlationId, hint?,
|
||||
* retryAfterSeconds? }` (D-192); TokenExpiredError from authenticatedFetch passes through
|
||||
* untouched and is rendered by describeApiError like any other failure.
|
||||
*/
|
||||
|
||||
export type GetAccessToken = (forceRefresh?: boolean) => Promise<string | null>;
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
/** Machine-readable code (Constants.ApiErrorCodes or a domain code class); "" when the body had none. */
|
||||
public readonly code: string = "",
|
||||
/** The request's correlation id — the handle for the backend log; "" when the body had none. */
|
||||
public readonly correlationId: string = "",
|
||||
public readonly hint: string | null = null,
|
||||
/** Seconds the server asked us to wait (429/503); null when it sent none. */
|
||||
public readonly retryAfterSeconds: number | null = null,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a non-2xx body may carry: the generated envelope plus `message`, which pre-envelope
|
||||
* backends wrote. The `message` fallback is a deploy-window remnant (backend 1.5.1171 shipped the
|
||||
* envelope on 2026-09-05); drop it once no older backend can answer this web build.
|
||||
*/
|
||||
type ErrorEnvelope = Partial<ApiErrorResponse> & { message?: unknown };
|
||||
|
||||
const str = (v: unknown): string | null => (typeof v === "string" && v.length > 0 ? v : null);
|
||||
const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
||||
|
||||
function retryAfterFromHeader(response: Response): number | null {
|
||||
// Platform CORS may not expose the header to browser script; the body's retryAfterSeconds
|
||||
// is the primary source, this is the best-effort second one.
|
||||
const raw = response.headers?.get?.("Retry-After");
|
||||
if (!raw) return null;
|
||||
const seconds = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(seconds) && seconds >= 0 ? seconds : null;
|
||||
}
|
||||
|
||||
/** Build an ApiError from a non-ok response: envelope fields when present, statusText otherwise. */
|
||||
export async function apiErrorFromResponse(response: Response): Promise<ApiError> {
|
||||
let body: ErrorEnvelope | null = null;
|
||||
try {
|
||||
const text = await response.text();
|
||||
if (text.length > 0) body = JSON.parse(text) as ErrorEnvelope;
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
const message = str(body?.error) ?? str(body?.message) ?? response.statusText;
|
||||
return new ApiError(
|
||||
response.status,
|
||||
message,
|
||||
str(body?.code) ?? "",
|
||||
str(body?.correlationId) ?? "",
|
||||
str(body?.hint),
|
||||
num(body?.retryAfterSeconds) ?? retryAfterFromHeader(response),
|
||||
);
|
||||
}
|
||||
|
||||
/** A string body without an explicit Content-Type is JSON — every backend route deserialises JSON. */
|
||||
function withJsonContentType(init: RequestInit | undefined): RequestInit | undefined {
|
||||
if (typeof init?.body !== "string") return init;
|
||||
const headers = new Headers(init.headers);
|
||||
if (headers.has("Content-Type")) return init;
|
||||
headers.set("Content-Type", "application/json");
|
||||
return { ...init, headers };
|
||||
}
|
||||
|
||||
/** Parse the JSON body of an ok response; an empty or malformed body is an ApiError, never `undefined`. */
|
||||
export async function parseJsonBody<T>(response: Response): Promise<T> {
|
||||
const text = await response.text();
|
||||
if (text.length === 0) throw new ApiError(response.status, "Empty response body");
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
throw new ApiError(response.status, "Malformed response body");
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchJson<T>(url: string, getAccessToken: GetAccessToken, init?: RequestInit): Promise<T> {
|
||||
const response = await authenticatedFetch(url, getAccessToken, withJsonContentType(init));
|
||||
if (!response.ok) throw await apiErrorFromResponse(response);
|
||||
return parseJsonBody<T>(response);
|
||||
}
|
||||
|
||||
/** For calls whose body nobody reads: resolves with the ok Response (for status checks), throws ApiError otherwise. */
|
||||
export async function fetchOk(url: string, getAccessToken: GetAccessToken, init?: RequestInit): Promise<Response> {
|
||||
const response = await authenticatedFetch(url, getAccessToken, withJsonContentType(init));
|
||||
if (!response.ok) throw await apiErrorFromResponse(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function fetchBlob(url: string, getAccessToken: GetAccessToken, init?: RequestInit): Promise<Blob> {
|
||||
const response = await authenticatedFetch(url, getAccessToken, init);
|
||||
if (!response.ok) throw await apiErrorFromResponse(response);
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
function retryAdvice(err: ApiError): string {
|
||||
return err.retryAfterSeconds !== null && err.retryAfterSeconds > 0
|
||||
? `Try again in ${err.retryAfterSeconds} s.`
|
||||
: "Try again shortly.";
|
||||
}
|
||||
|
||||
/**
|
||||
* One rendering for any thrown error: the user-facing message plus, for backend failures,
|
||||
* the short correlation id as a reference the user can quote to support. 429 and 503 add the
|
||||
* server's retry advice. Pass `reference` to addNotification so the bell shows it.
|
||||
*/
|
||||
export function describeApiError(err: unknown, fallback = "Request failed."): { message: string; reference: string | null } {
|
||||
if (err instanceof TokenExpiredError) return { message: err.message, reference: null };
|
||||
if (err instanceof ApiError) {
|
||||
let message = err.message || fallback;
|
||||
if (err.status === 429) message = `${err.message || "Too many requests."} ${retryAdvice(err)}`;
|
||||
else if (err.status === 503) message = `${err.message || "Service temporarily unavailable."} ${retryAdvice(err)}`;
|
||||
return { message, reference: err.correlationId ? shortCorrelationId(err.correlationId) : null };
|
||||
}
|
||||
if (err instanceof Error && err.message) return { message: err.message, reference: null };
|
||||
return { message: fallback, reference: null };
|
||||
}
|
||||
|
||||
/** describeApiError as one string for inline error slots (`setError`): "message (Ref abcd1234)". */
|
||||
export function apiErrorText(err: unknown, fallback = "Request failed."): string {
|
||||
const { message, reference } = describeApiError(err, fallback);
|
||||
return reference ? `${message} (Ref ${reference})` : message;
|
||||
}
|
||||
|
||||
export interface ApiErrorNotification {
|
||||
type: "error";
|
||||
title: string;
|
||||
message: string;
|
||||
key: string | undefined;
|
||||
reference: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The notification for a failed call. A token expiry always renders as "Session Expired" under
|
||||
* one dedup key, whatever the caller's title — that is the one dialect every catch block used to
|
||||
* hand-write.
|
||||
*/
|
||||
export function apiErrorNotification(title: string, err: unknown, key?: string, fallback?: string): ApiErrorNotification {
|
||||
const { message, reference } = describeApiError(err, fallback);
|
||||
if (err instanceof TokenExpiredError) {
|
||||
return { type: "error", title: "Session Expired", message, key: "session-expired", reference: undefined };
|
||||
}
|
||||
return { type: "error", title, message, key, reference: reference ?? undefined };
|
||||
}
|
||||
@@ -16,8 +16,7 @@
|
||||
* body stream is independently readable.
|
||||
*/
|
||||
import { authenticatedFetch } from './authenticatedFetch';
|
||||
|
||||
type GetAccessToken = (forceRefresh?: boolean) => Promise<string | null>;
|
||||
import { apiErrorFromResponse, parseJsonBody, type GetAccessToken } from './apiClient';
|
||||
|
||||
const inFlight = new Map<string, Promise<Response>>();
|
||||
|
||||
@@ -47,6 +46,13 @@ export async function dedupedAuthFetch(
|
||||
return (await pending).clone();
|
||||
}
|
||||
|
||||
/** dedupedAuthFetch with the apiClient contract: ApiError on non-ok, parsed JSON body otherwise. */
|
||||
export async function dedupedFetchJson<T>(url: string, getAccessToken: GetAccessToken, init?: RequestInit): Promise<T> {
|
||||
const response = await dedupedAuthFetch(url, getAccessToken, init);
|
||||
if (!response.ok) throw await apiErrorFromResponse(response);
|
||||
return parseJsonBody<T>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only helper to clear the in-flight Map between tests so they don't
|
||||
* leak state into one another.
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { authenticatedFetch, TokenExpiredError } from "./authenticatedFetch";
|
||||
import { shortCorrelationId } from "./correlationId";
|
||||
|
||||
/**
|
||||
* JSON fetch layer for the scope-migrated pages: authenticatedFetch + ok-check + body
|
||||
* parse in one call, so call sites stop hand-rolling the response boilerplate. Throws
|
||||
* ApiError on a non-ok response carrying the backend's error envelope
|
||||
* (`{ error, code, correlationId, hint? }` — every non-2xx body since the 2026-09 envelope
|
||||
* pass; TokenExpiredError from authenticatedFetch passes through untouched).
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
/** Machine-readable code (Constants.ApiErrorCodes or a domain code class); "" when the body had none. */
|
||||
public readonly code: string = "",
|
||||
/** The request's correlation id — the handle for the backend log; "" when the body had none. */
|
||||
public readonly correlationId: string = "",
|
||||
public readonly hint: string | null = null,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Shape of the backend error envelope (ApiErrorResponse and its specialised siblings). */
|
||||
interface ErrorEnvelope {
|
||||
error?: unknown;
|
||||
code?: unknown;
|
||||
correlationId?: unknown;
|
||||
hint?: unknown;
|
||||
/** Pre-envelope bodies used `message`; still read for the deploy window between backend and web. */
|
||||
message?: unknown;
|
||||
}
|
||||
|
||||
const str = (v: unknown): string | null => (typeof v === "string" && v.length > 0 ? v : null);
|
||||
|
||||
/** Build an ApiError from a non-ok response: envelope fields when present, statusText otherwise. */
|
||||
export async function apiErrorFromResponse(response: Response): Promise<ApiError> {
|
||||
let body: ErrorEnvelope | null = null;
|
||||
try {
|
||||
body = (await response.json()) as ErrorEnvelope;
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
const message = str(body?.error) ?? str(body?.message) ?? response.statusText;
|
||||
return new ApiError(response.status, message, str(body?.code) ?? "", str(body?.correlationId) ?? "", str(body?.hint));
|
||||
}
|
||||
|
||||
export async function fetchJson<T>(
|
||||
url: string,
|
||||
getAccessToken: (forceRefresh?: boolean) => Promise<string | null>,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const response = await authenticatedFetch(url, getAccessToken, init);
|
||||
if (!response.ok) {
|
||||
throw await apiErrorFromResponse(response);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* One rendering for any thrown error: the user-facing message plus, for backend failures,
|
||||
* the short correlation id as a reference the user can quote to support. Pass `reference`
|
||||
* to addNotification so the bell shows it.
|
||||
*/
|
||||
export function describeApiError(err: unknown, fallback = "Request failed."): { message: string; reference: string | null } {
|
||||
if (err instanceof TokenExpiredError) return { message: err.message, reference: null };
|
||||
if (err instanceof ApiError) {
|
||||
return { message: err.message || fallback, reference: err.correlationId ? shortCorrelationId(err.correlationId) : null };
|
||||
}
|
||||
if (err instanceof Error && err.message) return { message: err.message, reference: null };
|
||||
return { message: fallback, reference: null };
|
||||
}
|
||||
Reference in New Issue
Block a user