mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
The sweep that follows from the primitives. Nothing here is new behaviour; every change removes a second way of saying something the surface already said one way somewhere else. Headers. The two collapsible security cards stop rebuilding `SettingsCardHeader` by hand to carry a chevron — the chevron rides inside the title button, and the description moves out of the collapsed region, so the first paint is no longer a bare title. The import card shell, the export and import section headings, and the four free-standing group headers stop inventing their own type scale: a heading above a GROUP of cards is `SectionHeading` (`text-base`), a card title is `SettingsCardHeader` (`text-lg`), and the step between them is what says which is which. The targets card gets a title instead of a naked icon. Actions. Every card-level row is `SettingsCardActions`. The primary action comes out of the header's status slot in six cards (password, web push, pillar order, coach memory, and the two danger-zone triggers) and out of the middle of the card in three more; the reset that hung in the dashboard-layout header joins save as a secondary in the same row. Result and status lines move ABOVE the row — sources' cross-link, the invite form's two status lines — because nothing follows a card's actions. Revoking a share link is `variant="destructive"` rather than an `outline` hand-tinted red in two different ways. The import row had four variants for four buttons; it has two. Export downloads are the card's primary, not an outline link. Integrations. One description sentence with the setup-guide link inline: the second paragraph was byte-identical across six of eight cards, which is the tell that it said nothing about the provider it sat under. The `<hr>` under the header — which existed only in this folder — is gone. So is the hint that pointed at a guide 40 px above it, and the second format for "not configured". Connect is one button everywhere: primary, `sm`, in the action row, instead of primary-and-small in three cards and outline-and-large in four. Plus the standards leftovers the audit listed: two `pl-5` list gutters, one `scroll-mt-20` anchor, a purple header icon, `text-[10px]` badges, four hand-rolled callout shells that `SettingsInfoTile` already paints, and the two inline buttons that never had the mobile tap floor at all.
325 lines
11 KiB
TypeScript
325 lines
11 KiB
TypeScript
/**
|
|
* v1.4.19 Phase A5 — Settings → Integrations status consolidation.
|
|
*
|
|
* The maintainer's verbatim concern (paraphrased):
|
|
* - Withings card had 3 status displays — top-right "connected"
|
|
* badge, mid-card "connected / last successful / last attempt"
|
|
* trio container, and the badge inside that container.
|
|
* - Mood Log card had 4 — same as Withings PLUS a "letzter Sync"
|
|
* line at the very bottom.
|
|
*
|
|
* After A5: each card has EXACTLY ONE status display (the pill, top
|
|
* right). Error context (the failure message itself, when actionable)
|
|
* is allowed to surface below the pill since it carries information
|
|
* the pill can't fit. Timestamp repetition is gone.
|
|
*
|
|
* The tests pin:
|
|
* 1. Healthy Withings + Mood Log: ONE pill per card, no banner.
|
|
* 2. Withings transient error: ONE pill per card + error message,
|
|
* NO redundant "{n}/{threshold} consecutive failures" badge,
|
|
* NO "Last successful sync / Last attempt" trio.
|
|
* 3. Withings reauth: pill carries the "Error — reconnect" label.
|
|
* 4. Mood Log disconnected: pill says "Not connected", no other
|
|
* status display surfaces.
|
|
* 5. no card paints a divider under its header
|
|
* — consistency the maintainer explicitly called out.
|
|
*/
|
|
import { describe, expect, it, vi, beforeEach } 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(),
|
|
}),
|
|
}));
|
|
|
|
// v1.12.1 — the four cards now read every field off the single
|
|
// /api/integrations/status envelope; the per-card /api/<provider>/status
|
|
// queries are gone. The test mock only needs the consolidated payload.
|
|
let integrationStatusPayload: unknown = null;
|
|
function setIntegrationStatus(payload: unknown) {
|
|
integrationStatusPayload = payload;
|
|
}
|
|
|
|
vi.mock("@tanstack/react-query", () => ({
|
|
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
|
|
const key = Array.isArray(queryKey) ? queryKey.join("/") : "";
|
|
if (key === "integrations/status") {
|
|
return { data: integrationStatusPayload, isLoading: false };
|
|
}
|
|
if (key === "settings/global-services") {
|
|
return {
|
|
data: {
|
|
telegramGlobal: true,
|
|
ntfyGlobal: true,
|
|
webPushGlobal: true,
|
|
apiGlobal: true,
|
|
},
|
|
isLoading: false,
|
|
};
|
|
}
|
|
return { data: null, isLoading: false, refetch: vi.fn() };
|
|
},
|
|
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
|
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
|
}));
|
|
|
|
import { I18nProvider } from "@/lib/i18n/context";
|
|
import { IntegrationsSection } from "../integrations-section";
|
|
|
|
function render() {
|
|
return renderToStaticMarkup(
|
|
<I18nProvider initialLocale="en">
|
|
<IntegrationsSection />
|
|
</I18nProvider>,
|
|
);
|
|
}
|
|
|
|
/** Count occurrences of a substring in the rendered HTML. */
|
|
function count(html: string, needle: string): number {
|
|
let n = 0;
|
|
let i = 0;
|
|
while ((i = html.indexOf(needle, i)) !== -1) {
|
|
n++;
|
|
i += needle.length;
|
|
}
|
|
return n;
|
|
}
|
|
|
|
describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
|
beforeEach(() => {
|
|
integrationStatusPayload = null;
|
|
});
|
|
|
|
it("renders exactly ONE status pill per card when both integrations are healthy", () => {
|
|
setIntegrationStatus({
|
|
threshold: 3,
|
|
integrations: [
|
|
{
|
|
integration: "withings",
|
|
state: "connected",
|
|
lastSuccessAt: "2026-05-09T18:00:00.000Z",
|
|
lastAttemptAt: "2026-05-09T18:00:00.000Z",
|
|
lastError: null,
|
|
connected: true,
|
|
configured: true,
|
|
legacyLastSyncedAt: "2026-05-09T18:00:00.000Z",
|
|
hasActivityScope: true,
|
|
syncHealth: { verdict: "fresh", since: null },
|
|
},
|
|
],
|
|
});
|
|
|
|
const html = render();
|
|
// 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"');
|
|
// Card-body "letzter Sync" repetition is gone — no
|
|
// "Last sync:" label outside the pill.
|
|
expect(html).not.toContain("Last sync:");
|
|
// No "Last successful sync" / "Last attempt" trio.
|
|
expect(html).not.toContain("Last successful sync");
|
|
expect(html).not.toContain("Last attempt");
|
|
});
|
|
|
|
it("Withings transient error keeps the pill + error text but drops the {n}/{threshold} duplicate badge", () => {
|
|
setIntegrationStatus({
|
|
threshold: 3,
|
|
integrations: [
|
|
{
|
|
integration: "withings",
|
|
state: "error_transient",
|
|
lastSuccessAt: "2026-05-09T08:00:00.000Z",
|
|
lastAttemptAt: "2026-05-09T18:00:00.000Z",
|
|
lastError: "Withings refresh error: 503 - upstream",
|
|
connected: true,
|
|
configured: true,
|
|
legacyLastSyncedAt: "2026-05-09T08:00:00.000Z",
|
|
hasActivityScope: true,
|
|
syncHealth: { verdict: "failing", since: "2026-05-09T08:00:00.000Z" },
|
|
},
|
|
],
|
|
});
|
|
|
|
const html = render();
|
|
// 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");
|
|
// …but the consecutive-failure badge that doubled the pill's
|
|
// signal is gone.
|
|
expect(html).not.toContain("2/3 consecutive failures");
|
|
expect(html).not.toContain("Last successful sync");
|
|
expect(html).not.toContain("Last attempt");
|
|
});
|
|
|
|
it("Withings reauth state surfaces 'Error — reconnect' on the pill (the maintainer's wording)", () => {
|
|
setIntegrationStatus({
|
|
threshold: 3,
|
|
integrations: [
|
|
{
|
|
integration: "withings",
|
|
state: "error_reauth",
|
|
lastSuccessAt: "2026-05-08T12:00:00.000Z",
|
|
lastAttemptAt: "2026-05-09T18:00:00.000Z",
|
|
lastError: "Withings refresh error: 100 - invalid_grant",
|
|
connected: true,
|
|
configured: true,
|
|
legacyLastSyncedAt: "2026-05-08T12:00:00.000Z",
|
|
tokenExpired: true,
|
|
hasActivityScope: true,
|
|
syncHealth: {
|
|
verdict: "reauth_required",
|
|
since: "2026-05-09T18:00:00.000Z",
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
const html = render();
|
|
expect(html).toContain('data-state="error"');
|
|
// Old "Reconnect required" banner is gone — the pill is now
|
|
// the canonical surface.
|
|
expect(html).not.toContain('data-testid="integration-status-banner"');
|
|
});
|
|
|
|
it("disconnected state shows 'Not connected' on the pill, no extra status surfaces", () => {
|
|
setIntegrationStatus({
|
|
threshold: 3,
|
|
integrations: [
|
|
{
|
|
integration: "withings",
|
|
state: "disconnected",
|
|
lastSuccessAt: "2026-05-08T10:00:00.000Z",
|
|
lastAttemptAt: "2026-05-08T10:00:00.000Z",
|
|
lastError: null,
|
|
syncHealth: { verdict: "disconnected", since: null },
|
|
},
|
|
],
|
|
});
|
|
|
|
const html = render();
|
|
expect(html).toContain("Not connected");
|
|
expect(html).not.toContain('data-testid="integration-status-banner"');
|
|
// No trailing "letzter Sync" line outside the pill.
|
|
expect(html).not.toContain("Last sync:");
|
|
});
|
|
|
|
// v1.4.43 W14 — parked-state surface check.
|
|
// The pill flips to `data-state="parked"` and the resume banner
|
|
// renders with the reconnect CTA. The legacy `error` reconnect text
|
|
// (red) must NOT appear because the user-facing copy is the lighter
|
|
// "Paused — reconnect manually" string, not "Error — reconnect".
|
|
it("Withings parked state surfaces the parked pill + resume CTA", () => {
|
|
setIntegrationStatus({
|
|
threshold: 3,
|
|
integrations: [
|
|
{
|
|
integration: "withings",
|
|
state: "parked",
|
|
lastSuccessAt: "2026-05-08T12:00:00.000Z",
|
|
lastAttemptAt: "2026-05-09T18:00:00.000Z",
|
|
lastError: "Withings activity error: 293",
|
|
connected: true,
|
|
configured: true,
|
|
legacyLastSyncedAt: "2026-05-08T12:00:00.000Z",
|
|
hasActivityScope: true,
|
|
syncHealth: { verdict: "parked", since: "2026-05-09T18:00:00.000Z" },
|
|
},
|
|
],
|
|
});
|
|
|
|
const html = render();
|
|
expect(html).toContain('data-state="parked"');
|
|
expect(html).toContain("Paused");
|
|
expect(html).toContain("reconnect manually");
|
|
expect(html).toContain('data-testid="withings-parked-banner"');
|
|
expect(html).toContain('data-testid="withings-resume-button"');
|
|
// The lastError is surfaced under the pill so the operator sees
|
|
// the contract-mismatch reason without opening Audit Log.
|
|
expect(html).toContain("Withings activity error: 293");
|
|
});
|
|
|
|
it("no integration card paints a divider under its header", () => {
|
|
setIntegrationStatus({
|
|
threshold: 3,
|
|
integrations: [
|
|
{
|
|
integration: "withings",
|
|
state: "connected",
|
|
lastSuccessAt: "2026-05-09T18:00:00.000Z",
|
|
lastAttemptAt: "2026-05-09T18:00:00.000Z",
|
|
lastError: null,
|
|
connected: true,
|
|
configured: true,
|
|
legacyLastSyncedAt: "2026-05-09T18:00:00.000Z",
|
|
hasActivityScope: true,
|
|
syncHealth: { verdict: "fresh", since: null },
|
|
},
|
|
],
|
|
});
|
|
|
|
const html = render();
|
|
// The `<hr>` under the header existed only in `integrations/` — nine
|
|
// cards carrying a rule no other Settings card has. The card's own gap
|
|
// does the separating now, identically here and everywhere else.
|
|
expect(count(html, 'data-testid="integration-card-divider"')).toBe(0);
|
|
});
|
|
|
|
// v1.17.1 — every integration card carries the same discreet "Setup guide"
|
|
// doc-link pointing at its runbook under the single shared docs base. The
|
|
// affordance is one family across all six providers.
|
|
it("every integration card carries the shared Setup-guide doc-link", () => {
|
|
setIntegrationStatus({
|
|
threshold: 3,
|
|
integrations: [
|
|
{
|
|
integration: "withings",
|
|
state: "connected",
|
|
lastSuccessAt: "2026-05-09T18:00:00.000Z",
|
|
lastAttemptAt: "2026-05-09T18:00:00.000Z",
|
|
lastError: null,
|
|
connected: true,
|
|
configured: true,
|
|
legacyLastSyncedAt: "2026-05-09T18:00:00.000Z",
|
|
hasActivityScope: true,
|
|
},
|
|
],
|
|
});
|
|
|
|
const html = render();
|
|
// One setup-guide link per card → eight providers (the Garmin info note is
|
|
// not an OAuth card and carries its own docs link, not a setup-guide).
|
|
expect(count(html, 'data-slot="integration-setup-guide"')).toBe(8);
|
|
for (const provider of [
|
|
"withings",
|
|
"whoop",
|
|
"fitbit",
|
|
"google-health",
|
|
"polar",
|
|
"oura",
|
|
"strava",
|
|
"nightscout",
|
|
]) {
|
|
expect(html).toContain(`data-testid="${provider}-setup-guide"`);
|
|
expect(html).toContain(
|
|
`href="https://docs.healthlog.dev/integrations/${provider}"`,
|
|
);
|
|
}
|
|
});
|
|
});
|