mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Garmin Connect now offers a Personal API: a person creates a key for their own account and any client can buy short-lived tokens with it. HealthLog takes that key in a new Garmin card, checks it with Garmin before storing it sealed on the user row, and polls every hour. - Transport: key exchange with a per-process token cache keyed on the key, one re-exchange on a refused token and then reauth_required, the 429 Retry-After carried to the caller, the China host tried when the global one does not know the key, answers read with a byte cap, all egress through safeFetch to the two fixed hosts. - Sync: daily summaries, sleep summaries, HRV, weigh-ins and the activity search, chunked to each resource's documented range, read one after another and stopped at the first rate limit; today's menstrual cycle summary adds logged period days the person has not logged. - Backfill: a resumable walk through the shared admission lane that learns where the history starts from the daily summaries and the activities, waits out Garmin's rate limit and stamps its marker. - Types: GARMIN source; BODY_BATTERY and STRESS_LEVEL for Garmin's own day scores, kept apart from the computed STRESS_SCORE; TRAINING_READINESS and ACUTE_TRAINING_LOAD for figures a bridge pushes through the ingest token. All four chart on the recovery page and sit on the ladders, units, ranges, labels and report groups like the device-native scores. - Key expiry: learned from a token capped short of a day, else estimated from the validity picked at connect; the card warns a week ahead and asks for a new key once Garmin refuses the old one. - Ledger, health, freshness, watchdog, backup classes, wipe plan, encrypted-column registry, rotation script, OpenAPI and all ten locales carry the new integration. The old "no Garmin connector" note is gone.
260 lines
8.0 KiB
TypeScript
260 lines
8.0 KiB
TypeScript
/**
|
|
* Settings → Integrations: one status display per card.
|
|
*
|
|
* v1.4.19 consolidated three and four status surfaces per card into one pill.
|
|
* v1.42.4 replaces the pill with the shared card's badge, rendered from the
|
|
* server's `health`, and removes what had grown back around it: the raw
|
|
* provider error line, the failure counter, the per-provider banners.
|
|
*
|
|
* The tests pin, on the whole section:
|
|
* 1. one badge per connected card, and nothing else that states a status;
|
|
* 2. the stored provider error text never reaches the page;
|
|
* 3. a paused connection offers exactly one Resume;
|
|
* 4. no card paints a divider under its header;
|
|
* 5. every not-connected card links its setup guide.
|
|
*/
|
|
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";
|
|
|
|
const CALLBACK_URLS = {
|
|
withings: "https://app.example/api/withings/callback",
|
|
whoop: "https://app.example/api/whoop/callback",
|
|
fitbit: "https://app.example/api/fitbit/callback",
|
|
"google-health": "https://app.example/api/google-health/callback",
|
|
polar: "https://app.example/api/polar/callback",
|
|
oura: "https://app.example/api/oura/callback",
|
|
strava: "https://app.example/api/strava/callback",
|
|
};
|
|
|
|
function render() {
|
|
return renderToStaticMarkup(
|
|
<I18nProvider initialLocale="en">
|
|
<IntegrationsSection callbackUrls={CALLBACK_URLS} />
|
|
</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;
|
|
}
|
|
|
|
const PROVIDERS = [
|
|
"withings",
|
|
"whoop",
|
|
"fitbit",
|
|
"google-health",
|
|
"polar",
|
|
"oura",
|
|
"strava",
|
|
"nightscout",
|
|
"garmin",
|
|
] as const;
|
|
|
|
function health(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
state: "ok",
|
|
reason: "data_arriving",
|
|
since: null,
|
|
action: null,
|
|
unavailable: [],
|
|
lastSuccessAt: "2026-05-09T18:00:00.000Z",
|
|
lastDataAt: "2026-05-09T17:00:00.000Z",
|
|
diagnostics: {
|
|
ledgerState: "connected",
|
|
failingLegs: [],
|
|
failingSince: null,
|
|
lastAttemptAt: "2026-05-09T18:00:00.000Z",
|
|
verdict: "fresh",
|
|
recentCalls: [],
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function disconnected(integration: string) {
|
|
return {
|
|
integration,
|
|
lastError: null,
|
|
connected: false,
|
|
configured: false,
|
|
available: false,
|
|
health: null,
|
|
};
|
|
}
|
|
|
|
/** The Withings card's markup, up to the next card. */
|
|
function withingsCard(html: string): string {
|
|
const start = html.indexOf('data-testid="withings-card"');
|
|
const next = html.indexOf('data-slot="integration-card"', start + 1);
|
|
return html.slice(start, next === -1 ? undefined : next);
|
|
}
|
|
|
|
/** Withings as given, every other provider present and not connected. */
|
|
function envelopeWith(withings: Record<string, unknown>) {
|
|
return {
|
|
threshold: 3,
|
|
integrations: [
|
|
{
|
|
integration: "withings",
|
|
lastError: null,
|
|
connected: true,
|
|
configured: true,
|
|
...withings,
|
|
},
|
|
...PROVIDERS.filter((p) => p !== "withings").map(disconnected),
|
|
],
|
|
};
|
|
}
|
|
|
|
describe("IntegrationsSection — one status display per card", () => {
|
|
beforeEach(() => {
|
|
integrationStatusPayload = null;
|
|
});
|
|
|
|
it("renders exactly one badge for the connected card and no other status surface", () => {
|
|
setIntegrationStatus(envelopeWith({ health: health() }));
|
|
|
|
const html = render();
|
|
expect(count(html, 'data-slot="integration-health-badge"')).toBe(1);
|
|
expect(html).not.toContain('data-testid="integration-status-pill"');
|
|
expect(html).not.toContain('data-testid="integration-status-banner"');
|
|
expect(html).not.toContain("Last successful sync");
|
|
expect(html).not.toContain("Last attempt");
|
|
// The seven other providers and Apple Health are not connected and say so
|
|
// once each.
|
|
expect(count(html, ">Not connected.<")).toBe(9);
|
|
});
|
|
|
|
it("never shows the stored provider error or a failure counter", () => {
|
|
setIntegrationStatus(
|
|
envelopeWith({
|
|
lastError: "Withings refresh error: 503 - upstream",
|
|
consecutiveFailuresByKind: {
|
|
transient: 2,
|
|
reauth_required: 0,
|
|
persistent: 0,
|
|
},
|
|
health: health({
|
|
state: "degraded",
|
|
reason: "provider_errors",
|
|
since: "2026-05-09T08:00:00.000Z",
|
|
}),
|
|
}),
|
|
);
|
|
|
|
const html = render();
|
|
expect(html).toMatch(
|
|
/data-slot="integration-health-badge"[^>]*data-state="degraded"/,
|
|
);
|
|
expect(html).not.toContain("Withings refresh error");
|
|
expect(html).not.toContain("2/3 consecutive failures");
|
|
// Not the user's to fix: the Withings card offers no button.
|
|
expect(withingsCard(html)).toContain('data-testid="withings-menu"');
|
|
expect(withingsCard(html)).not.toContain(
|
|
'data-slot="integration-primary-action"',
|
|
);
|
|
});
|
|
|
|
it("a paused connection offers exactly one Resume", () => {
|
|
setIntegrationStatus(
|
|
envelopeWith({
|
|
lastError: "Withings activity error: 293",
|
|
health: health({
|
|
state: "action_required",
|
|
reason: "paused_after_errors",
|
|
since: "2026-05-09T18:00:00.000Z",
|
|
action: "resume",
|
|
}),
|
|
}),
|
|
);
|
|
|
|
const html = render();
|
|
expect(html).toMatch(
|
|
/data-slot="integration-health-badge"[^>]*data-state="action_required"/,
|
|
);
|
|
expect(html).toContain('data-testid="withings-resume-button"');
|
|
expect(count(html, 'data-action="resume"')).toBe(1);
|
|
expect(html).not.toContain('data-testid="withings-parked-banner"');
|
|
expect(html).not.toContain("Withings activity error: 293");
|
|
});
|
|
|
|
it("no integration card paints a divider under its header", () => {
|
|
setIntegrationStatus(envelopeWith({ health: health() }));
|
|
expect(count(render(), 'data-testid="integration-card-divider"')).toBe(0);
|
|
});
|
|
|
|
// Every card that is not connected yet carries the same discreet "Setup
|
|
// guide" link to its runbook under the single shared docs base. Once
|
|
// connected, the link moves into the card's menu.
|
|
it("every not-connected integration card carries the shared Setup-guide link", () => {
|
|
setIntegrationStatus({
|
|
threshold: 3,
|
|
integrations: PROVIDERS.map(disconnected),
|
|
});
|
|
|
|
const html = render();
|
|
expect(count(html, 'data-slot="integration-setup-guide"')).toBe(9);
|
|
for (const provider of PROVIDERS) {
|
|
expect(html).toContain(`data-testid="${provider}-setup-guide"`);
|
|
expect(html).toContain(
|
|
`href="https://docs.healthlog.dev/guides/connect-${provider}/"`,
|
|
);
|
|
}
|
|
});
|
|
});
|