mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
test(integrations): cover the verdict ladder and the coverage guarantee
The load-bearing one is the panel-coverage test: render the panel with an envelope carrying every integration key and require each configured one to surface as a card or as a fallback row. Drop the fallback row and it goes red, which is the whole point — the previous shape of this bug was a provider on the wire that no screen rendered, and no card-shaped test could have caught it. Alongside it: the ladder table with the month-silent fixture that resolves to stalled, the 24-hour and 7-day boundaries asserted against literals rather than the constants they check, the per-metric stale flag firing only under a healthy provider, and a structural guard that the verdict-to-pill mapping is defined in exactly one module. The card suites migrate with the semantics: a transient failure now expects the warning pill everywhere, and the Apple Health suite trades its bespoke badge states for pill assertions. The e2e mocks gain the verdict shape, the nightscout entry, and an assertion that the card-less row is on the page.
This commit is contained in:
1 parent
ce74fd52a2
commit
10593a11a8
13 files changed
+1245
-183
No files matched your search
@@ -117,4 +117,82 @@ describe("IntegrationStatusPill", () => {
|
||||
expect(html).toContain("Pausiert");
|
||||
expect(html).toContain("manuell wieder verbinden");
|
||||
});
|
||||
|
||||
// The three states the server verdict added. `stale` and `stalled` both
|
||||
// carry an inline timestamp, because "since when" is the whole point of
|
||||
// them; `pending-setup` has nothing to timestamp yet.
|
||||
it("renders the stale state in the warning tone with its timestamp", () => {
|
||||
const html = render(
|
||||
<IntegrationStatusPill
|
||||
state="stale"
|
||||
lastSyncAt={new Date(Date.now() - 9 * 24 * 60 * 60 * 1000)}
|
||||
now={new Date()}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-state="stale"');
|
||||
expect(html).toContain("no recent data");
|
||||
expect(html).toMatch(/9\s+d\s+ago/);
|
||||
expect(html).toContain("bg-warning/15");
|
||||
});
|
||||
|
||||
it("renders the stalled state as a stopped sync, not an error", () => {
|
||||
const html = render(
|
||||
<IntegrationStatusPill
|
||||
state="stalled"
|
||||
lastSyncAt={new Date(Date.now() - 28 * 24 * 60 * 60 * 1000)}
|
||||
now={new Date()}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-state="stalled"');
|
||||
expect(html).toContain("Sync stopped");
|
||||
expect(html).toMatch(/28\s+d\s+ago/);
|
||||
// Warning tone, never destructive: the user cannot fix a cron that
|
||||
// stopped by clicking reconnect.
|
||||
expect(html).toContain("bg-warning/15");
|
||||
expect(html).not.toContain('data-variant="destructive"');
|
||||
});
|
||||
|
||||
it("renders the pending-setup state with no timestamp", () => {
|
||||
const html = render(
|
||||
<IntegrationStatusPill state="pending-setup" lastSyncAt={null} />,
|
||||
);
|
||||
expect(html).toContain('data-state="pending-setup"');
|
||||
expect(html).toContain("Waiting for first data");
|
||||
expect(html).not.toContain("bg-warning/15");
|
||||
});
|
||||
|
||||
/**
|
||||
* Red is reserved for "your action fixes this". A transient upstream failure
|
||||
* is not that: telling the user to reconnect against a 503 sends them
|
||||
* clicking at something they cannot repair.
|
||||
*/
|
||||
it("keeps the destructive variant for reauth alone", () => {
|
||||
expect(
|
||||
render(<IntegrationStatusPill state="error" lastSyncAt={null} />),
|
||||
).toContain('data-variant="destructive"');
|
||||
for (const state of [
|
||||
"warning",
|
||||
"stale",
|
||||
"stalled",
|
||||
"parked",
|
||||
"pending-setup",
|
||||
"connected",
|
||||
] as const) {
|
||||
expect(
|
||||
render(<IntegrationStatusPill state={state} lastSyncAt={null} />),
|
||||
state,
|
||||
).not.toContain('data-variant="destructive"');
|
||||
}
|
||||
});
|
||||
|
||||
it("lets a card keep its own established testid", () => {
|
||||
const html = render(
|
||||
<IntegrationStatusPill
|
||||
state="connected"
|
||||
lastSyncAt={null}
|
||||
testId="apple-health-status"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-testid="apple-health-status"');
|
||||
});
|
||||
});
|
||||
@@ -114,27 +114,27 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
configured: true,
|
||||
legacyLastSyncedAt: "2026-05-09T18:00:00.000Z",
|
||||
hasActivityScope: true,
|
||||
syncHealth: { verdict: "fresh", since: null },
|
||||
},
|
||||
{
|
||||
// Not configured here, so this entry claims no surface. The
|
||||
// configured, card-less case has its own coverage test — this one is
|
||||
// about one status display per CARD.
|
||||
integration: "moodlog",
|
||||
state: "connected",
|
||||
lastSuccessAt: "2026-05-09T17:00:00.000Z",
|
||||
lastAttemptAt: "2026-05-09T17:00:00.000Z",
|
||||
state: "disconnected",
|
||||
lastSuccessAt: null,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
configured: true,
|
||||
enabled: true,
|
||||
legacyLastSyncedAt: "2026-05-09T17:00:00.000Z",
|
||||
entryCount: 42,
|
||||
webhookSecret: "secret123",
|
||||
configured: false,
|
||||
syncHealth: { verdict: "disconnected", since: null },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const html = render();
|
||||
// Exactly one pill per card → 8 pills total (Withings, WHOOP, Fitbit,
|
||||
// Google Health, Polar, Oura, Strava, Nightscout). The moodLog integration
|
||||
// was removed; Polar + Oura (F4) were added in v1.17.0; Google Health in
|
||||
// v1.27.0; Strava in v1.28.x. (The Garmin info note carries no pill.)
|
||||
// Exactly one pill per card → 8 (Withings, WHOOP, Fitbit, Google Health,
|
||||
// Polar, Oura, Strava, Nightscout). The Garmin info note carries no pill;
|
||||
// the Apple Health card's pill keeps its own testid.
|
||||
expect(count(html, 'data-testid="integration-status-pill"')).toBe(8);
|
||||
// The redundant banner from v1.4.15 is gone.
|
||||
expect(html).not.toContain('data-testid="integration-status-banner"');
|
||||
@@ -160,6 +160,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
configured: true,
|
||||
legacyLastSyncedAt: "2026-05-09T08:00:00.000Z",
|
||||
hasActivityScope: true,
|
||||
syncHealth: { verdict: "failing", since: "2026-05-09T08:00:00.000Z" },
|
||||
},
|
||||
{
|
||||
integration: "moodlog",
|
||||
@@ -172,9 +173,10 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
});
|
||||
|
||||
const html = render();
|
||||
// Pill carries the "Error — reconnect" copy via the pill's
|
||||
// `data-state="error"` marker.
|
||||
expect(html).toContain('data-state="error"');
|
||||
// A 503 is not the user's to fix, so the pill carries the warning tone —
|
||||
// red is reserved for "reconnect actually helps".
|
||||
expect(html).toContain('data-state="warning"');
|
||||
expect(html).not.toContain('data-state="error"');
|
||||
// Actionable error message still surfaces (we kept it because
|
||||
// the pill can't fit "Withings refresh error: 503 - upstream").
|
||||
expect(html).toContain("Withings refresh error: 503 - upstream");
|
||||
@@ -200,6 +202,10 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
legacyLastSyncedAt: "2026-05-08T12:00:00.000Z",
|
||||
tokenExpired: true,
|
||||
hasActivityScope: true,
|
||||
syncHealth: {
|
||||
verdict: "reauth_required",
|
||||
since: "2026-05-09T18:00:00.000Z",
|
||||
},
|
||||
},
|
||||
{
|
||||
integration: "moodlog",
|
||||
@@ -228,6 +234,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
lastSuccessAt: "2026-05-08T10:00:00.000Z",
|
||||
lastAttemptAt: "2026-05-08T10:00:00.000Z",
|
||||
lastError: null,
|
||||
syncHealth: { verdict: "disconnected", since: null },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -258,6 +265,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
configured: true,
|
||||
legacyLastSyncedAt: "2026-05-08T12:00:00.000Z",
|
||||
hasActivityScope: true,
|
||||
syncHealth: { verdict: "parked", since: "2026-05-09T18:00:00.000Z" },
|
||||
},
|
||||
{
|
||||
integration: "moodlog",
|
||||
@@ -294,15 +302,16 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
configured: true,
|
||||
legacyLastSyncedAt: "2026-05-09T18:00:00.000Z",
|
||||
hasActivityScope: true,
|
||||
syncHealth: { verdict: "fresh", since: null },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const html = render();
|
||||
// Every integration card includes the section divider data-testid so the
|
||||
// header → body separation is visually consistent (Withings, WHOOP,
|
||||
// Fitbit, Google Health). The moodLog integration was removed.
|
||||
expect(count(html, 'data-testid="integration-card-divider"')).toBe(4);
|
||||
// header → body separation is visually consistent: the four legacy cards,
|
||||
// Apple Health, the three OAuth cards, and Nightscout.
|
||||
expect(count(html, 'data-testid="integration-card-divider"')).toBe(9);
|
||||
});
|
||||
|
||||
// v1.17.1 — every integration card carries the same discreet "Setup guide"
|
||||
|
||||
@@ -10,7 +10,17 @@ import fr from "../../../../../messages/fr.json";
|
||||
import itMessages from "../../../../../messages/it.json";
|
||||
import pl from "../../../../../messages/pl.json";
|
||||
|
||||
let statusPayload: { lastSyncedAt: string | null } | undefined;
|
||||
type Payload = {
|
||||
lastSyncedAt: string | null;
|
||||
syncHealth?: { verdict: string; since: string | null };
|
||||
metricFreshness?: Array<{
|
||||
type: string;
|
||||
lastSeenAt: string;
|
||||
stale: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
let statusPayload: Payload | undefined;
|
||||
let statusLoading = false;
|
||||
let statusError = false;
|
||||
|
||||
@@ -40,8 +50,10 @@ describe("<AppleHealthCard>", () => {
|
||||
});
|
||||
|
||||
it("explains that live sync uses the iOS app rather than OAuth", () => {
|
||||
statusPayload = { lastSyncedAt: null };
|
||||
statusLoading = false;
|
||||
statusPayload = {
|
||||
lastSyncedAt: null,
|
||||
syncHealth: { verdict: "pending_first_sync", since: null },
|
||||
};
|
||||
|
||||
const html = render();
|
||||
|
||||
@@ -53,35 +65,98 @@ describe("<AppleHealthCard>", () => {
|
||||
expect(html).not.toContain(">Connected<");
|
||||
});
|
||||
|
||||
it("keeps the status neutral when no Apple Health data has been observed", () => {
|
||||
statusPayload = { lastSyncedAt: null };
|
||||
statusLoading = false;
|
||||
/**
|
||||
* The defect this card carried: any `lastSyncedAt` at all painted the green
|
||||
* "data received" chip, so a pipe dead for three weeks looked exactly like
|
||||
* one that delivered this morning. The verdict now decides.
|
||||
*/
|
||||
it("shows a week-old delivery as stale, not as data received", () => {
|
||||
const eightDaysAgo = new Date(
|
||||
Date.now() - 8 * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
statusPayload = {
|
||||
lastSyncedAt: eightDaysAgo,
|
||||
syncHealth: { verdict: "stale", since: eightDaysAgo },
|
||||
};
|
||||
|
||||
const html = render();
|
||||
|
||||
expect(html).toContain('data-testid="apple-health-status"');
|
||||
expect(html).toContain('data-state="setup"');
|
||||
expect(html).toContain("Set up in iOS app");
|
||||
expect(html).toContain('data-state="stale"');
|
||||
expect(html).toContain("no recent data");
|
||||
// And it says what to do about it.
|
||||
expect(html).toContain('data-testid="apple-health-stale"');
|
||||
expect(html).not.toContain('data-state="connected"');
|
||||
});
|
||||
|
||||
it("reports only observed data freshness without claiming a connection", () => {
|
||||
statusPayload = { lastSyncedAt: "2026-07-20T10:30:00.000Z" };
|
||||
statusLoading = false;
|
||||
it("shows a recent delivery as connected with its relative time", () => {
|
||||
const anHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
statusPayload = {
|
||||
lastSyncedAt: anHourAgo,
|
||||
syncHealth: { verdict: "fresh", since: null },
|
||||
};
|
||||
|
||||
const html = render();
|
||||
|
||||
expect(html).toContain('data-state="recent-data"');
|
||||
expect(html).toContain("Apple Health data received");
|
||||
expect(html).toContain("Last Apple Health data:");
|
||||
expect(html).toContain('dateTime="2026-07-20T10:30:00.000Z"');
|
||||
expect(html).not.toContain(">Connected<");
|
||||
expect(html).toContain('data-state="connected"');
|
||||
expect(html).toMatch(/1\s+h\s+ago/);
|
||||
expect(html).not.toContain('data-testid="apple-health-stale"');
|
||||
});
|
||||
|
||||
it("waits for the first delivery rather than claiming a connection", () => {
|
||||
statusPayload = {
|
||||
lastSyncedAt: null,
|
||||
syncHealth: { verdict: "pending_first_sync", since: null },
|
||||
};
|
||||
|
||||
const html = render();
|
||||
|
||||
expect(html).toContain('data-state="pending-setup"');
|
||||
expect(html).toContain("Waiting for first data");
|
||||
expect(html).not.toContain('data-state="connected"');
|
||||
});
|
||||
|
||||
it("renders per-metric freshness so one dead permission is visible", () => {
|
||||
statusPayload = {
|
||||
lastSyncedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
|
||||
syncHealth: { verdict: "fresh", since: null },
|
||||
metricFreshness: [
|
||||
{
|
||||
type: "RESPIRATORY_RATE",
|
||||
lastSeenAt: new Date(
|
||||
Date.now() - 30 * 24 * 60 * 60 * 1000,
|
||||
).toISOString(),
|
||||
stale: true,
|
||||
},
|
||||
{
|
||||
type: "PULSE",
|
||||
lastSeenAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
|
||||
stale: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const html = render();
|
||||
|
||||
expect(html).toContain('data-slot="metric-freshness"');
|
||||
// The collapsed summary already names the problem — the signal must not
|
||||
// hide behind a click.
|
||||
expect(html).toContain("quiet");
|
||||
});
|
||||
|
||||
it("carries the shared card divider like every sibling", () => {
|
||||
statusPayload = {
|
||||
lastSyncedAt: null,
|
||||
syncHealth: { verdict: "pending_first_sync", since: null },
|
||||
};
|
||||
expect(render()).toContain('data-testid="integration-card-divider"');
|
||||
});
|
||||
|
||||
it("links to the existing one-shot Apple Health import fallback", () => {
|
||||
statusPayload = { lastSyncedAt: null };
|
||||
statusLoading = false;
|
||||
statusPayload = {
|
||||
lastSyncedAt: null,
|
||||
syncHealth: { verdict: "pending_first_sync", since: null },
|
||||
};
|
||||
|
||||
const html = render();
|
||||
|
||||
@@ -92,24 +167,24 @@ describe("<AppleHealthCard>", () => {
|
||||
expect(html).toContain("Open one-shot import");
|
||||
});
|
||||
|
||||
it("uses a non-committal checking state while status is loading", () => {
|
||||
it("claims no status at all while the read is in flight", () => {
|
||||
statusPayload = undefined;
|
||||
statusLoading = true;
|
||||
|
||||
const html = render();
|
||||
|
||||
expect(html).toContain('data-state="checking"');
|
||||
expect(html).toContain("Checking Apple Health data…");
|
||||
expect(html).not.toContain('data-testid="apple-health-status"');
|
||||
expect(html).not.toContain("Connected");
|
||||
});
|
||||
it("does not turn a failed status read into a setup or connection claim", () => {
|
||||
|
||||
it("treats a failed status read as an error line, not a status", () => {
|
||||
statusError = true;
|
||||
|
||||
const html = render();
|
||||
|
||||
expect(html).toContain('data-state="unavailable"');
|
||||
expect(html).toContain("Apple Health status unavailable");
|
||||
expect(html).not.toContain('data-state="setup"');
|
||||
expect(html).toContain('data-testid="integration-error-message"');
|
||||
expect(html).not.toContain('data-testid="apple-health-status"');
|
||||
expect(html).not.toContain("Connected");
|
||||
});
|
||||
|
||||
@@ -117,12 +192,12 @@ describe("<AppleHealthCard>", () => {
|
||||
const requiredKeys = [
|
||||
"title",
|
||||
"description",
|
||||
"lastDataLabel",
|
||||
"setupTitle",
|
||||
"permissionStep",
|
||||
"backgroundStep",
|
||||
"importNote",
|
||||
"importAction",
|
||||
"staleNote",
|
||||
] as const;
|
||||
|
||||
for (const catalog of [en, de, es, fr, itMessages, pl]) {
|
||||
@@ -131,20 +206,19 @@ describe("<AppleHealthCard>", () => {
|
||||
expect(appleHealth[key]).toBeTypeOf("string");
|
||||
expect(appleHealth[key].length).toBeGreaterThan(0);
|
||||
}
|
||||
expect(Object.keys(appleHealth.status).sort()).toEqual([
|
||||
"checking",
|
||||
"dataReceived",
|
||||
"setup",
|
||||
"unavailable",
|
||||
]);
|
||||
// The status copy the pill now owns is gone from the catalog; only the
|
||||
// failed-read line survives here.
|
||||
expect(Object.keys(appleHealth.status).sort()).toEqual(["unavailable"]);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["en", "de", "es", "fr", "it", "pl"] as const)(
|
||||
"renders localized Apple Health copy for %s without leaking keys",
|
||||
(locale) => {
|
||||
statusPayload = { lastSyncedAt: null };
|
||||
statusLoading = false;
|
||||
statusPayload = {
|
||||
lastSyncedAt: null,
|
||||
syncHealth: { verdict: "pending_first_sync", since: null },
|
||||
};
|
||||
|
||||
const html = render(locale);
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Nothing on the envelope may render nowhere.
|
||||
*
|
||||
* A provider whose Settings card was removed kept its cron, its ledger row and
|
||||
* its block on the status envelope, and lost the only surface that could have
|
||||
* shown it dying. It then stopped syncing for a month and no screen anywhere
|
||||
* said so — every audit that went looking worked from the cards, so a provider
|
||||
* without a card appeared in no cohort at all.
|
||||
*
|
||||
* The panel's unit of coverage is therefore the envelope entry, not the card.
|
||||
* This is the acceptance test in executable form: render the panel with an
|
||||
* envelope carrying every integration key, and require that each configured
|
||||
* one surfaces either as a bespoke card or as a fallback row.
|
||||
*/
|
||||
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/integrations",
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", role: "USER" },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
let envelope: unknown = null;
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
|
||||
const key = Array.isArray(queryKey) ? queryKey.join("/") : "";
|
||||
if (key === "integrations/status") {
|
||||
return { data: envelope, isLoading: false };
|
||||
}
|
||||
return { data: null, isLoading: false, isError: false, refetch: vi.fn() };
|
||||
},
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { ConnectionsPanel } from "../connections-panel";
|
||||
import { INTEGRATION_DISPLAY_NAMES } from "../integration-fallback-row";
|
||||
|
||||
const MONTH_AGO = new Date(Date.now() - 28 * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
/** Every key the server can ship, all configured, all a month silent. */
|
||||
function fullEnvelope() {
|
||||
return {
|
||||
threshold: 3,
|
||||
integrations: Object.keys(INTEGRATION_DISPLAY_NAMES).map((integration) => ({
|
||||
integration,
|
||||
state: "error_transient",
|
||||
lastSuccessAt: MONTH_AGO,
|
||||
lastAttemptAt: MONTH_AGO,
|
||||
lastError: null,
|
||||
configured: true,
|
||||
connected: true,
|
||||
available: true,
|
||||
syncHealth: { verdict: "stalled", since: MONTH_AGO },
|
||||
metricFreshness: [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<ConnectionsPanel />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ConnectionsPanel — every envelope entry surfaces", () => {
|
||||
it("renders a card or a fallback row for every configured entry", () => {
|
||||
envelope = fullEnvelope();
|
||||
const html = render();
|
||||
|
||||
for (const integration of Object.keys(INTEGRATION_DISPLAY_NAMES)) {
|
||||
const hasCard = html.includes(`data-testid="${integration}-card"`);
|
||||
const hasFallback = html.includes(`data-integration="${integration}"`);
|
||||
expect(
|
||||
hasCard || hasFallback,
|
||||
`${integration} renders nowhere on the connections panel`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("gives the card-less provider a fallback row with the stalled pill", () => {
|
||||
// moodLog is the live case: no card since its removal, still on the wire,
|
||||
// still running an hourly cron against an upstream that stopped answering.
|
||||
envelope = fullEnvelope();
|
||||
const html = render();
|
||||
|
||||
expect(html).toContain('data-testid="integration-fallback-row"');
|
||||
expect(html).toContain('data-integration="moodlog"');
|
||||
expect(html).toContain("moodLog");
|
||||
expect(html).toContain('data-state="stalled"');
|
||||
expect(html).toContain("Sync stopped");
|
||||
expect(html).toContain("Last attempt");
|
||||
});
|
||||
|
||||
it("renders no fallback row for a provider that is not configured", () => {
|
||||
// Absence stays absence: an unconfigured provider is not a problem to
|
||||
// report, so the panel says nothing about it.
|
||||
envelope = {
|
||||
threshold: 3,
|
||||
integrations: [
|
||||
{
|
||||
integration: "moodlog",
|
||||
state: "disconnected",
|
||||
lastSuccessAt: null,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
configured: false,
|
||||
syncHealth: { verdict: "disconnected", since: null },
|
||||
metricFreshness: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(render()).not.toContain('data-testid="integration-fallback-row"');
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* v1.17.0 — interaction-parity + correctness checks for the Nightscout card.
|
||||
* Nightscout parity + correctness.
|
||||
*
|
||||
* 1. A `parked` status renders the warning banner + reconnect button
|
||||
* 1. A `parked` verdict renders the warning banner + reconnect button
|
||||
* (matching the WHOOP card's parked treatment, byte-for-byte classes).
|
||||
* 2. A `connected` status renders the shared TestConnectionButton AND the
|
||||
* 2. A healthy connection renders the shared TestConnectionButton AND the
|
||||
* connect→data link to /insights/blood-glucose.
|
||||
* 3. The disconnect mutation invalidates `nightscoutStatus()` — the key the
|
||||
* status query actually reads — so the pill refreshes after disconnect.
|
||||
* 3. The card reads the consolidated envelope — it was the last card firing
|
||||
* its own status round-trip — so a disconnect invalidates that one key.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
@@ -14,14 +14,12 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
// Capture the disconnect mutation's onSuccess so the test can assert which
|
||||
// query keys it invalidates. Status is driven off a per-test payload.
|
||||
let statusPayload: unknown = null;
|
||||
// query keys it invalidates.
|
||||
const invalidateSpy = vi.fn();
|
||||
type OnSuccess = () => void;
|
||||
const capturedDisconnectOnSuccess: { fn: OnSuccess | null } = { fn: null };
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: statusPayload, isLoading: false }),
|
||||
useMutation: ({ onSuccess }: { onSuccess?: () => void }) => {
|
||||
// The Nightscout card declares exactly one mutation (disconnect).
|
||||
capturedDisconnectOnSuccess.fn = onSuccess ?? null;
|
||||
@@ -32,25 +30,38 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { NightscoutCard } from "../nightscout-card";
|
||||
import type { IntegrationStatusViewModel } from "../shared";
|
||||
|
||||
function render() {
|
||||
function render(viewModel?: Partial<IntegrationStatusViewModel>) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<NightscoutCard />
|
||||
<NightscoutCard
|
||||
viewModel={
|
||||
viewModel
|
||||
? ({
|
||||
integration: "nightscout",
|
||||
state: "connected",
|
||||
lastSuccessAt: null,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
...viewModel,
|
||||
} as IntegrationStatusViewModel)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("NightscoutCard — parked + test + data-link + invalidation", () => {
|
||||
it("renders the parked banner + reconnect button when state is parked", () => {
|
||||
statusPayload = {
|
||||
it("renders the parked banner + reconnect button when the verdict is parked", () => {
|
||||
const html = render({
|
||||
connected: true,
|
||||
configured: true,
|
||||
state: "parked",
|
||||
lastSuccessAt: null,
|
||||
lastError: "Nightscout unreachable",
|
||||
};
|
||||
const html = render();
|
||||
syncHealth: { verdict: "parked", since: null },
|
||||
});
|
||||
expect(html).toContain('data-state="parked"');
|
||||
expect(html).toContain('data-testid="nightscout-parked-banner"');
|
||||
expect(html).toContain('data-testid="nightscout-resume-button"');
|
||||
@@ -58,51 +69,78 @@ describe("NightscoutCard — parked + test + data-link + invalidation", () => {
|
||||
});
|
||||
|
||||
it("renders the test-connection button + data link when connected", () => {
|
||||
statusPayload = {
|
||||
const html = render({
|
||||
connected: true,
|
||||
configured: true,
|
||||
state: "connected",
|
||||
lastSuccessAt: "2026-06-01T00:00:00.000Z",
|
||||
lastError: null,
|
||||
};
|
||||
const html = render();
|
||||
syncHealth: { verdict: "fresh", since: null },
|
||||
});
|
||||
expect(html).toContain("Test connection");
|
||||
expect(html).toContain('data-testid="nightscout-data-link"');
|
||||
expect(html).toContain('href="/insights/blood-glucose"');
|
||||
});
|
||||
|
||||
it("disconnect invalidates the status key the query reads (nightscoutStatus)", () => {
|
||||
statusPayload = { connected: true, configured: true, state: "connected" };
|
||||
it("carries the shared card divider like every sibling", () => {
|
||||
const html = render({
|
||||
connected: true,
|
||||
configured: true,
|
||||
syncHealth: { verdict: "fresh", since: null },
|
||||
});
|
||||
expect(html).toContain('data-testid="integration-card-divider"');
|
||||
});
|
||||
|
||||
it("renders the per-metric freshness disclosure with a stale row", () => {
|
||||
const html = render({
|
||||
connected: true,
|
||||
configured: true,
|
||||
syncHealth: { verdict: "fresh", since: null },
|
||||
metricFreshness: [
|
||||
{
|
||||
type: "BLOOD_GLUCOSE",
|
||||
lastSeenAt: "2026-06-01T00:00:00.000Z",
|
||||
stale: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(html).toContain('data-slot="metric-freshness"');
|
||||
expect(html).toContain("quiet");
|
||||
});
|
||||
|
||||
it("disconnect invalidates the envelope the card actually reads", () => {
|
||||
invalidateSpy.mockClear();
|
||||
capturedDisconnectOnSuccess.fn = null;
|
||||
render();
|
||||
render({
|
||||
connected: true,
|
||||
configured: true,
|
||||
syncHealth: { verdict: "fresh", since: null },
|
||||
});
|
||||
const onSuccess = capturedDisconnectOnSuccess.fn as (() => void) | null;
|
||||
expect(typeof onSuccess).toBe("function");
|
||||
onSuccess?.();
|
||||
const invalidatedKeys = invalidateSpy.mock.calls.map((c) => c[0]?.queryKey);
|
||||
expect(invalidatedKeys).toContainEqual(queryKeys.nightscoutStatus());
|
||||
expect(invalidatedKeys).toContainEqual(queryKeys.integrationsStatus());
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.32.28 — sync now, matching the treatment the OAuth cards get.
|
||||
*/
|
||||
describe("NightscoutCard — sync-now action", () => {
|
||||
it("offers the sync action on a connected instance", () => {
|
||||
statusPayload = {
|
||||
const html = render({
|
||||
connected: true,
|
||||
configured: true,
|
||||
state: "connected",
|
||||
lastSuccessAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
const html = render();
|
||||
syncHealth: { verdict: "fresh", since: null },
|
||||
});
|
||||
expect(html).toContain('data-testid="nightscout-sync"');
|
||||
expect(html).toContain("Sync now");
|
||||
});
|
||||
|
||||
it("does not offer it before an instance is configured", () => {
|
||||
statusPayload = { connected: false, configured: false };
|
||||
expect(render()).not.toContain('data-testid="nightscout-sync"');
|
||||
expect(
|
||||
render({
|
||||
connected: false,
|
||||
configured: false,
|
||||
state: "disconnected",
|
||||
syncHealth: { verdict: "disconnected", since: null },
|
||||
}),
|
||||
).not.toContain('data-testid="nightscout-sync"');
|
||||
});
|
||||
});
|
||||
@@ -12,10 +12,9 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { Watch } from "lucide-react";
|
||||
|
||||
// Drive the card's own `useQuery` status read off a per-test payload.
|
||||
let statusPayload: unknown = null;
|
||||
// The card reads the consolidated envelope only — its per-card status fetch is
|
||||
// gone, so every fixture is a view-model.
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: statusPayload, isLoading: false }),
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
@@ -23,12 +22,14 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { OAuthProviderCard } from "../oauth-provider-card";
|
||||
|
||||
type ViewModel = Parameters<typeof OAuthProviderCard>[0]["viewModel"];
|
||||
|
||||
function render({
|
||||
credentials = false,
|
||||
viewModel,
|
||||
}: {
|
||||
credentials?: boolean;
|
||||
viewModel?: Parameters<typeof OAuthProviderCard>[0]["viewModel"];
|
||||
viewModel?: ViewModel;
|
||||
} = {}) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
@@ -47,15 +48,17 @@ function render({
|
||||
|
||||
describe("OAuthProviderCard — parked + test + data-link parity", () => {
|
||||
it("renders the parked banner + reconnect button when state is parked", () => {
|
||||
statusPayload = {
|
||||
connected: true,
|
||||
configured: true,
|
||||
available: true,
|
||||
state: "parked",
|
||||
lastSuccessAt: null,
|
||||
lastError: "Polar grant expired",
|
||||
};
|
||||
const html = render();
|
||||
const html = render({
|
||||
viewModel: {
|
||||
connected: true,
|
||||
configured: true,
|
||||
available: true,
|
||||
state: "parked",
|
||||
lastSuccessAt: null,
|
||||
lastError: "Polar grant expired",
|
||||
syncHealth: { verdict: "parked", since: null },
|
||||
},
|
||||
});
|
||||
expect(html).toContain('data-state="parked"');
|
||||
expect(html).toContain('data-testid="polar-parked-banner"');
|
||||
expect(html).toContain('data-testid="polar-resume-button"');
|
||||
@@ -65,27 +68,6 @@ describe("OAuthProviderCard — parked + test + data-link parity", () => {
|
||||
});
|
||||
|
||||
it("renders the test-connection button + data link when connected", () => {
|
||||
statusPayload = {
|
||||
connected: true,
|
||||
configured: true,
|
||||
available: true,
|
||||
state: "connected",
|
||||
lastSuccessAt: "2026-06-01T00:00:00.000Z",
|
||||
lastError: null,
|
||||
};
|
||||
const html = render();
|
||||
// The shared TestConnectionButton surfaces its "Test connection" label.
|
||||
expect(html).toContain("Test connection");
|
||||
// connect→data link points at the provider's insight surface.
|
||||
expect(html).toContain('data-testid="polar-data-link"');
|
||||
expect(html).toContain('href="/insights/sleep"');
|
||||
});
|
||||
|
||||
it("reads off the passed view-model instead of the per-card fetch (04-M2)", () => {
|
||||
// The per-card useQuery mock returns null; the card must render the
|
||||
// connected state from the supplied envelope view-model alone, proving the
|
||||
// /api/<provider>/status round-trip is no longer the source.
|
||||
statusPayload = null;
|
||||
const html = render({
|
||||
viewModel: {
|
||||
connected: true,
|
||||
@@ -94,6 +76,29 @@ describe("OAuthProviderCard — parked + test + data-link parity", () => {
|
||||
state: "connected",
|
||||
lastSuccessAt: "2026-06-01T00:00:00.000Z",
|
||||
lastError: null,
|
||||
syncHealth: { verdict: "fresh", since: null },
|
||||
},
|
||||
});
|
||||
// The shared TestConnectionButton surfaces its "Test connection" label.
|
||||
expect(html).toContain("Test connection");
|
||||
// connect→data link points at the provider's insight surface.
|
||||
expect(html).toContain('data-testid="polar-data-link"');
|
||||
expect(html).toContain('href="/insights/sleep"');
|
||||
});
|
||||
|
||||
it("reads off the passed view-model — the per-card fetch is gone", () => {
|
||||
// There is no `useQuery` left in this component: the envelope is the only
|
||||
// source. A per-provider status response carries no verdict, so falling
|
||||
// back to it would have painted a status the server never resolved.
|
||||
const html = render({
|
||||
viewModel: {
|
||||
connected: true,
|
||||
configured: true,
|
||||
available: true,
|
||||
state: "connected",
|
||||
lastSuccessAt: "2026-06-01T00:00:00.000Z",
|
||||
lastError: null,
|
||||
syncHealth: { verdict: "fresh", since: null },
|
||||
},
|
||||
});
|
||||
expect(html).toContain('data-testid="polar-data-link"');
|
||||
@@ -101,12 +106,14 @@ describe("OAuthProviderCard — parked + test + data-link parity", () => {
|
||||
});
|
||||
|
||||
it("does not render the data link or test button when disconnected", () => {
|
||||
statusPayload = {
|
||||
connected: false,
|
||||
configured: false,
|
||||
available: true,
|
||||
};
|
||||
const html = render();
|
||||
const html = render({
|
||||
viewModel: {
|
||||
connected: false,
|
||||
configured: false,
|
||||
available: true,
|
||||
syncHealth: { verdict: "disconnected", since: null },
|
||||
},
|
||||
});
|
||||
expect(html).not.toContain('data-testid="polar-data-link"');
|
||||
expect(html).not.toContain("Test connection");
|
||||
// The connect CTA stands in instead.
|
||||
@@ -116,35 +123,39 @@ describe("OAuthProviderCard — parked + test + data-link parity", () => {
|
||||
|
||||
describe("OAuthProviderCard — per-user BYO credentials form (v1.17.1)", () => {
|
||||
it("renders the credentials form only when the `credentials` prop is set", () => {
|
||||
statusPayload = {
|
||||
const viewModel: ViewModel = {
|
||||
connected: false,
|
||||
configured: false,
|
||||
available: true,
|
||||
hasOwnCredentials: false,
|
||||
syncHealth: { verdict: "disconnected", since: null },
|
||||
};
|
||||
// Opt-in: the BYO client-id/secret form + save button appear.
|
||||
const withForm = render({ credentials: true });
|
||||
const withForm = render({ credentials: true, viewModel });
|
||||
expect(withForm).toContain('data-testid="polar-credentials"');
|
||||
expect(withForm).toContain('id="polar-clientid"');
|
||||
expect(withForm).toContain('id="polar-secret"');
|
||||
|
||||
// Default: no credential inputs (env-only behaviour preserved).
|
||||
const withoutForm = render();
|
||||
const withoutForm = render({ viewModel });
|
||||
expect(withoutForm).not.toContain('data-testid="polar-credentials"');
|
||||
expect(withoutForm).not.toContain('id="polar-clientid"');
|
||||
});
|
||||
|
||||
it("shows the saved-placeholder once the user has stored their own pair", () => {
|
||||
statusPayload = {
|
||||
connected: true,
|
||||
configured: true,
|
||||
available: true,
|
||||
hasOwnCredentials: true,
|
||||
state: "connected",
|
||||
lastSuccessAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
const html = render({ credentials: true });
|
||||
const html = render({
|
||||
credentials: true,
|
||||
viewModel: {
|
||||
connected: true,
|
||||
configured: true,
|
||||
available: true,
|
||||
hasOwnCredentials: true,
|
||||
state: "connected",
|
||||
lastSuccessAt: null,
|
||||
lastError: null,
|
||||
syncHealth: { verdict: "pending_first_sync", since: null },
|
||||
},
|
||||
});
|
||||
expect(html).toContain("Saved — enter new to replace");
|
||||
});
|
||||
});
|
||||
@@ -152,29 +163,35 @@ describe("OAuthProviderCard — per-user BYO credentials form (v1.17.1)", () =>
|
||||
describe("OAuthProviderCard — redirect-URI mini-guide (v1.29.x, UX audit H2)", () => {
|
||||
it("shows the callback URL guide before the user has BYO credentials", () => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = "https://app.example";
|
||||
statusPayload = {
|
||||
connected: false,
|
||||
configured: false,
|
||||
available: true,
|
||||
hasOwnCredentials: false,
|
||||
};
|
||||
const html = render({ credentials: true });
|
||||
const html = render({
|
||||
credentials: true,
|
||||
viewModel: {
|
||||
connected: false,
|
||||
configured: false,
|
||||
available: true,
|
||||
hasOwnCredentials: false,
|
||||
syncHealth: { verdict: "disconnected", since: null },
|
||||
},
|
||||
});
|
||||
expect(html).toContain('data-testid="polar-redirect-guide"');
|
||||
expect(html).toContain('data-testid="polar-redirect-uri"');
|
||||
expect(html).toContain("https://app.example/api/polar/callback");
|
||||
});
|
||||
|
||||
it("hides the guide once the user has stored their own credentials", () => {
|
||||
statusPayload = {
|
||||
connected: true,
|
||||
configured: true,
|
||||
available: true,
|
||||
hasOwnCredentials: true,
|
||||
state: "connected",
|
||||
lastSuccessAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
const html = render({ credentials: true });
|
||||
const html = render({
|
||||
credentials: true,
|
||||
viewModel: {
|
||||
connected: true,
|
||||
configured: true,
|
||||
available: true,
|
||||
hasOwnCredentials: true,
|
||||
state: "connected",
|
||||
lastSuccessAt: null,
|
||||
lastError: null,
|
||||
syncHealth: { verdict: "fresh", since: null },
|
||||
},
|
||||
});
|
||||
expect(html).not.toContain('data-testid="polar-redirect-guide"');
|
||||
});
|
||||
});
|
||||
@@ -187,25 +204,31 @@ describe("OAuthProviderCard — redirect-URI mini-guide (v1.29.x, UX audit H2)",
|
||||
*/
|
||||
describe("OAuthProviderCard — sync-now action", () => {
|
||||
it("offers the sync action on a connected provider", () => {
|
||||
statusPayload = {
|
||||
connected: true,
|
||||
configured: true,
|
||||
available: true,
|
||||
state: "connected",
|
||||
lastSuccessAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
const html = render();
|
||||
const html = render({
|
||||
viewModel: {
|
||||
connected: true,
|
||||
configured: true,
|
||||
available: true,
|
||||
state: "connected",
|
||||
lastSuccessAt: null,
|
||||
lastError: null,
|
||||
syncHealth: { verdict: "fresh", since: null },
|
||||
},
|
||||
});
|
||||
expect(html).toContain('data-testid="polar-sync"');
|
||||
expect(html).toContain("Sync now");
|
||||
});
|
||||
|
||||
it("does not offer it before the provider is connected", () => {
|
||||
statusPayload = {
|
||||
connected: false,
|
||||
configured: true,
|
||||
available: true,
|
||||
};
|
||||
expect(render()).not.toContain('data-testid="polar-sync"');
|
||||
expect(
|
||||
render({
|
||||
viewModel: {
|
||||
connected: false,
|
||||
configured: true,
|
||||
available: true,
|
||||
syncHealth: { verdict: "disconnected", since: null },
|
||||
},
|
||||
}),
|
||||
).not.toContain('data-testid="polar-sync"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* One verdict, one pill mapping.
|
||||
*
|
||||
* There used to be three `pillStateFor` implementations — one per card family —
|
||||
* and they disagreed with each other. The same ledger state painted red on the
|
||||
* Withings card and orange on the Polar card, on the same page. The server now
|
||||
* resolves the verdict once; this pins that the client projects it once, and
|
||||
* that a fourth dialect cannot be minted without someone editing this file.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join, sep } from "node:path";
|
||||
import { globSync } from "node:fs";
|
||||
|
||||
import type { SyncVerdict } from "@/lib/integrations/sync-verdict";
|
||||
import { pillStateForVerdict } from "../shared";
|
||||
|
||||
describe("pillStateForVerdict — the verdict→pill table", () => {
|
||||
const table: Array<[SyncVerdict | undefined, string]> = [
|
||||
["fresh", "connected"],
|
||||
["stale", "stale"],
|
||||
["stalled", "stalled"],
|
||||
["failing", "warning"],
|
||||
["reauth_required", "error"],
|
||||
["parked", "parked"],
|
||||
["pending_first_sync", "pending-setup"],
|
||||
["disconnected", "disconnected"],
|
||||
// Still loading: the card renders its neutral default rather than
|
||||
// guessing at a status it has not been told.
|
||||
[undefined, "disconnected"],
|
||||
];
|
||||
|
||||
it.each(table)("maps %s to the %s pill", (verdict, expected) => {
|
||||
expect(pillStateForVerdict(verdict)).toBe(expected);
|
||||
});
|
||||
|
||||
/**
|
||||
* The decision this release makes and must not silently lose: **red means
|
||||
* "your action fixes this"**. Reconnecting cannot repair an upstream 503 or
|
||||
* a cron that stopped running, so neither of those may reach the destructive
|
||||
* pill.
|
||||
*/
|
||||
it("reserves the error pill for the states a reconnect actually fixes", () => {
|
||||
const red = table
|
||||
.filter(([, state]) => state === "error")
|
||||
.map(([verdict]) => verdict);
|
||||
expect(red).toEqual(["reauth_required"]);
|
||||
expect(pillStateForVerdict("failing")).not.toBe("error");
|
||||
expect(pillStateForVerdict("stalled")).not.toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("structural guard — no second pill dialect", () => {
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
return globSync("**/*.{ts,tsx}", { cwd: SRC })
|
||||
.filter(
|
||||
(p) => !p.startsWith(`generated${sep}`) && !p.startsWith("generated/"),
|
||||
)
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
||||
.map((p) => p.split(sep).join("/"))
|
||||
.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* The one module allowed to define a status→pill mapping. A tripwire, not a
|
||||
* proof: it cannot show the mapping is right, only that nobody added a
|
||||
* second one without editing this file.
|
||||
*/
|
||||
const MAPPER_HOME = "components/settings/integrations/shared.tsx";
|
||||
|
||||
it("defines the mapping in exactly one module", () => {
|
||||
const definers = sourceFiles().filter((rel) =>
|
||||
/function\s+pillStateFor\w*\s*\(/.test(
|
||||
readFileSync(join(SRC, rel), "utf8"),
|
||||
),
|
||||
);
|
||||
expect(definers).toEqual([MAPPER_HOME]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user