mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
test(garmin): pin the key exchange, the mapping and a sync against a fake Garmin
Unit tests cover the token cache, the single re-exchange on a refused token, the key's end read from a capped token, the 429 wait, the range chunking, and every resource's mapping over made-up records shaped from Garmin's document. The card test pins the shared anatomy, the menu and the expiry sentences. The integration test runs the sync, the backfill, connect and disconnect against a fake Garmin served over HTTP and the real schema: rows per resource under GARMIN, no second row on a re-sync, a refused key parked after one retry, a 429 that stops the run, a history walked to its start across slices, and a purge on disconnect that keeps the imported history.
This commit is contained in:
1 parent
e2ba86b12f
commit
13e232c836
4 files changed
+1510
No files matched your search
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Garmin on the shared integration card.
|
||||
*
|
||||
* 1. Not connected: the key form, the link to Garmin Connect's key page,
|
||||
* the validity choice and Connect, inside the shared card.
|
||||
* 2. Connected: the same menu as Nightscout, a refused key opens the form,
|
||||
* and disconnect asks first and invalidates the envelope.
|
||||
* 3. The key's end: a sentence in the body while it is close, past or
|
||||
* unknown, and the date in the details while it is comfortably valid.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
const invalidateSpy = vi.fn();
|
||||
const capturedDisconnectOnSuccess: { fn: (() => void) | null } = { fn: null };
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useMutation: ({ onSuccess }: { onSuccess?: () => void }) => {
|
||||
capturedDisconnectOnSuccess.fn = onSuccess ?? null;
|
||||
return { mutate: vi.fn(), isPending: false };
|
||||
},
|
||||
useQueryClient: () => ({ invalidateQueries: invalidateSpy }),
|
||||
}));
|
||||
|
||||
let lastCardProps: IntegrationCardProps | null = null;
|
||||
vi.mock("../integration-card", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../integration-card")>();
|
||||
return {
|
||||
...actual,
|
||||
IntegrationCard: (props: IntegrationCardProps) => {
|
||||
lastCardProps = props;
|
||||
return createElement(actual.IntegrationCard, props);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { GarminKeyExpiry } from "@/lib/garmin/key-expiry";
|
||||
import type { IntegrationCardProps } from "../integration-card";
|
||||
import { GarminCard, GARMIN_KEY_SETTINGS_URL } from "../garmin-card";
|
||||
import type { IntegrationStatusViewModel } from "../shared";
|
||||
import { healthFixture } from "./health-fixture";
|
||||
|
||||
function render(viewModel?: Partial<IntegrationStatusViewModel>) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<GarminCard
|
||||
viewModel={
|
||||
viewModel
|
||||
? ({
|
||||
integration: "garmin",
|
||||
...viewModel,
|
||||
} as IntegrationStatusViewModel)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function connected(keyExpiry: GarminKeyExpiry | null) {
|
||||
return render({
|
||||
connected: true,
|
||||
configured: true,
|
||||
health: healthFixture(),
|
||||
keyExpiry,
|
||||
metricFreshness: [],
|
||||
});
|
||||
}
|
||||
|
||||
describe("GarminCard on the shared card", () => {
|
||||
it("shows the key form inside the shared card while not connected", () => {
|
||||
const html = render({ connected: false, configured: false, health: null });
|
||||
expect(html).toContain('data-slot="integration-card"');
|
||||
expect(html).toContain('data-state="not_connected"');
|
||||
expect(html).toContain('id="garmin-api-key"');
|
||||
expect(html).toContain('id="garmin-key-validity"');
|
||||
expect(html).toContain(`href="${GARMIN_KEY_SETTINGS_URL}"`);
|
||||
expect(html).toContain('data-testid="garmin-connect"');
|
||||
expect(html).toContain(
|
||||
'href="https://docs.healthlog.dev/guides/connect-garmin/"',
|
||||
);
|
||||
// 30, 90, 180 and 365 days, plus "I don't know".
|
||||
expect(html.match(/<option /g)).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("keeps the sibling menu and opens the form for a new key", () => {
|
||||
render({
|
||||
connected: true,
|
||||
configured: true,
|
||||
health: healthFixture({
|
||||
state: "action_required",
|
||||
reason: "reauth_required",
|
||||
action: "reconnect",
|
||||
}),
|
||||
});
|
||||
expect((lastCardProps?.menu ?? []).map((i) => i.id)).toEqual([
|
||||
"sync",
|
||||
"test",
|
||||
"view-data",
|
||||
"credentials",
|
||||
"setup-guide",
|
||||
"disconnect",
|
||||
]);
|
||||
expect(lastCardProps?.primary?.onReconnect).toBeTypeOf("function");
|
||||
const disconnect = lastCardProps?.menu?.find((i) => i.id === "disconnect");
|
||||
expect(disconnect?.destructive).toBe(true);
|
||||
expect(disconnect?.confirm?.title).toBe("Disconnect Garmin?");
|
||||
invalidateSpy.mockClear();
|
||||
capturedDisconnectOnSuccess.fn?.();
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: queryKeys.integrationsStatus(),
|
||||
});
|
||||
});
|
||||
|
||||
it("warns in the body when the key is about to expire", () => {
|
||||
const html = connected({
|
||||
state: "expiring",
|
||||
expiresAt: "2026-11-08T00:00:00.000Z",
|
||||
daysLeft: 3,
|
||||
});
|
||||
expect(html).toMatch(
|
||||
/data-testid="garmin-key-expiry"[^>]*data-state="expiring"/,
|
||||
);
|
||||
expect(html).toContain("Your Garmin key expires on");
|
||||
expect(html).not.toContain('data-testid="garmin-key-valid-until"');
|
||||
});
|
||||
|
||||
it("says when the key expires today and when it has expired", () => {
|
||||
expect(
|
||||
connected({
|
||||
state: "expiring",
|
||||
expiresAt: "2026-11-04T20:00:00.000Z",
|
||||
daysLeft: 0,
|
||||
}),
|
||||
).toContain("Your Garmin key expires today.");
|
||||
expect(
|
||||
connected({
|
||||
state: "expired",
|
||||
expiresAt: "2026-11-01T00:00:00.000Z",
|
||||
daysLeft: null,
|
||||
}),
|
||||
).toContain("Your Garmin key expired on");
|
||||
});
|
||||
|
||||
it("asks for the validity when the end is unknown", () => {
|
||||
expect(
|
||||
connected({ state: "unknown", expiresAt: null, daysLeft: null }),
|
||||
).toContain("Garmin does not say when this key expires.");
|
||||
});
|
||||
|
||||
it("keeps a valid key's date in the details, not the body", () => {
|
||||
const html = connected({
|
||||
state: "ok",
|
||||
expiresAt: "2027-01-30T00:00:00.000Z",
|
||||
daysLeft: 87,
|
||||
});
|
||||
expect(html).not.toContain('data-testid="garmin-key-expiry"');
|
||||
const details = html.slice(html.indexOf('data-slot="integration-details"'));
|
||||
expect(details).toContain('data-testid="garmin-key-valid-until"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
chunkGarminRange,
|
||||
clearGarminTokenCache,
|
||||
exchangeGarminKey,
|
||||
extractGarminToken,
|
||||
GarminApiError,
|
||||
GarminSession,
|
||||
garminTokenExpiry,
|
||||
GARMIN_TOKEN_PATH,
|
||||
isGarminKeyRefusal,
|
||||
isGarminRateLimit,
|
||||
} from "../client";
|
||||
|
||||
const NOW = Date.parse("2026-11-04T08:00:00Z");
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
/** A made-up unsigned JWT with the given `exp` (epoch seconds). */
|
||||
function jwt(expSeconds: number, nonce = "a"): string {
|
||||
const enc = (o: object) =>
|
||||
Buffer.from(JSON.stringify(o)).toString("base64url");
|
||||
return `${enc({ alg: "none" })}.${enc({ exp: expSeconds, n: nonce })}.sig`;
|
||||
}
|
||||
|
||||
interface Call {
|
||||
url: string;
|
||||
auth: string | null;
|
||||
}
|
||||
|
||||
/** A scripted Garmin: each request takes the next answer for its path kind. */
|
||||
function fakeGarmin(script: {
|
||||
token?: Array<() => Response>;
|
||||
data?: Array<() => Response>;
|
||||
}) {
|
||||
const calls: Call[] = [];
|
||||
const tokens = [...(script.token ?? [])];
|
||||
const data = [...(script.data ?? [])];
|
||||
const fetch = (async (url: string | URL, init?: RequestInit) => {
|
||||
const u = url.toString();
|
||||
const headers = new Headers(init?.headers);
|
||||
calls.push({ url: u, auth: headers.get("authorization") });
|
||||
const next = u.includes(GARMIN_TOKEN_PATH) ? tokens.shift() : data.shift();
|
||||
if (!next) throw new Error(`unexpected request ${u}`);
|
||||
return next();
|
||||
}) as unknown as typeof import("@/lib/safe-fetch").safeFetch;
|
||||
return { fetch, calls };
|
||||
}
|
||||
|
||||
const tokenResponse = (value: string) => () =>
|
||||
new Response(value, { status: 200 });
|
||||
const json =
|
||||
(body: unknown, status = 200) =>
|
||||
() =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
afterEach(() => clearGarminTokenCache());
|
||||
|
||||
describe("extractGarminToken", () => {
|
||||
it("reads a bare token, a quoted one and a JSON wrapper", () => {
|
||||
expect(extractGarminToken(" abc.def.ghi\n")).toBe("abc.def.ghi");
|
||||
expect(extractGarminToken('"abc.def.ghi"')).toBe("abc.def.ghi");
|
||||
expect(extractGarminToken('{"access_token":"abc.def.ghi"}')).toBe(
|
||||
"abc.def.ghi",
|
||||
);
|
||||
expect(extractGarminToken("{not json")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("garminTokenExpiry", () => {
|
||||
it("reads exp in ms and ignores tokens without one", () => {
|
||||
expect(garminTokenExpiry(jwt(1_800_000_000))).toBe(1_800_000_000_000);
|
||||
expect(garminTokenExpiry("opaque")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("exchangeGarminKey", () => {
|
||||
it("sends the key as ApiKey and returns a token with its refresh point", async () => {
|
||||
const exp = Math.floor((NOW + 24 * HOUR) / 1000);
|
||||
const g = fakeGarmin({ token: [tokenResponse(jwt(exp))] });
|
||||
const token = await exchangeGarminKey("k".repeat(20), "global", {
|
||||
fetch: g.fetch,
|
||||
now: () => NOW,
|
||||
});
|
||||
expect(g.calls[0]!.auth).toBe(`ApiKey ${"k".repeat(20)}`);
|
||||
expect(g.calls[0]!.url).toBe(
|
||||
`https://personal.connectapi.garmin.com${GARMIN_TOKEN_PATH}`,
|
||||
);
|
||||
expect(token.expiresAt).toBe(exp * 1000);
|
||||
// An hour before expiry for a regular day-long token.
|
||||
expect(token.refreshAt).toBe(exp * 1000 - HOUR);
|
||||
// A regular token says nothing about the key's end.
|
||||
expect(token.keyExpiresAt).toBeNull();
|
||||
});
|
||||
|
||||
it("learns the key's end from a token capped short of a day", async () => {
|
||||
const exp = Math.floor((NOW + 5 * HOUR) / 1000);
|
||||
const g = fakeGarmin({ token: [tokenResponse(jwt(exp))] });
|
||||
const token = await exchangeGarminKey("k".repeat(20), "global", {
|
||||
fetch: g.fetch,
|
||||
now: () => NOW,
|
||||
});
|
||||
expect(token.keyExpiresAt).toBe(exp * 1000);
|
||||
});
|
||||
|
||||
it.each([401, 403, 404])(
|
||||
"treats %s at the exchange as a refused key",
|
||||
async (status) => {
|
||||
const g = fakeGarmin({
|
||||
token: [() => new Response("", { status })],
|
||||
});
|
||||
const err = await exchangeGarminKey("k".repeat(20), "china", {
|
||||
fetch: g.fetch,
|
||||
}).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(GarminApiError);
|
||||
expect(isGarminKeyRefusal(err)).toBe(true);
|
||||
expect(err.classification).toBe("reauth_required");
|
||||
expect(g.calls[0]!.url).toContain("connectapi.garmin.cn");
|
||||
},
|
||||
);
|
||||
|
||||
it("carries Retry-After on a rate-limited exchange", async () => {
|
||||
const g = fakeGarmin({
|
||||
token: [
|
||||
() =>
|
||||
new Response("", { status: 429, headers: { "retry-after": "120" } }),
|
||||
],
|
||||
});
|
||||
const err = await exchangeGarminKey("k".repeat(20), "global", {
|
||||
fetch: g.fetch,
|
||||
}).catch((e) => e);
|
||||
expect(isGarminRateLimit(err)).toBe(true);
|
||||
expect(err.retryAfterMs).toBe(120_000);
|
||||
expect(isGarminKeyRefusal(err)).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a 200 that is not a token, without echoing the body", async () => {
|
||||
const g = fakeGarmin({
|
||||
token: [() => new Response("<html>maintenance</html>", { status: 200 })],
|
||||
});
|
||||
const err = await exchangeGarminKey("k".repeat(20), "global", {
|
||||
fetch: g.fetch,
|
||||
}).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(GarminApiError);
|
||||
expect(err.reason).toBe("token_not_jwt");
|
||||
expect(String(err.message)).not.toContain("maintenance");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GarminSession", () => {
|
||||
const key = "secret-key-0123456789";
|
||||
|
||||
function session(fetch: ReturnType<typeof fakeGarmin>["fetch"], now = NOW) {
|
||||
return new GarminSession({
|
||||
userId: "u1",
|
||||
apiKey: key,
|
||||
region: "global",
|
||||
transport: { fetch, now: () => now },
|
||||
});
|
||||
}
|
||||
|
||||
it("caches the token across calls and sessions", async () => {
|
||||
const exp = Math.floor((NOW + 24 * HOUR) / 1000);
|
||||
const g = fakeGarmin({
|
||||
token: [tokenResponse(jwt(exp))],
|
||||
data: [json({ a: 1 }), json({ a: 2 })],
|
||||
});
|
||||
expect(await session(g.fetch).get("x", "/p")).toEqual({ a: 1 });
|
||||
expect(await session(g.fetch).get("x", "/p")).toEqual({ a: 2 });
|
||||
const exchanges = g.calls.filter((c) => c.url.includes(GARMIN_TOKEN_PATH));
|
||||
expect(exchanges).toHaveLength(1);
|
||||
expect(g.calls[1]!.auth).toBe(`Bearer ${jwt(exp)}`);
|
||||
});
|
||||
|
||||
it("buys a new token once the cached one is near its end", async () => {
|
||||
const exp = Math.floor((NOW + 24 * HOUR) / 1000);
|
||||
const g = fakeGarmin({
|
||||
token: [tokenResponse(jwt(exp, "a")), tokenResponse(jwt(exp + 10, "b"))],
|
||||
data: [json({}), json({})],
|
||||
});
|
||||
await session(g.fetch).get("x", "/p");
|
||||
await session(g.fetch, NOW + 23.5 * HOUR).get("x", "/p");
|
||||
expect(
|
||||
g.calls.filter((c) => c.url.includes(GARMIN_TOKEN_PATH)),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not reuse another key's token", async () => {
|
||||
const exp = Math.floor((NOW + 24 * HOUR) / 1000);
|
||||
const g = fakeGarmin({
|
||||
token: [tokenResponse(jwt(exp, "a")), tokenResponse(jwt(exp, "b"))],
|
||||
data: [json({}), json({})],
|
||||
});
|
||||
await session(g.fetch).get("x", "/p");
|
||||
await new GarminSession({
|
||||
userId: "u1",
|
||||
apiKey: "another-key-0123456789",
|
||||
region: "global",
|
||||
transport: { fetch: g.fetch, now: () => NOW },
|
||||
}).get("x", "/p");
|
||||
expect(
|
||||
g.calls.filter((c) => c.url.includes(GARMIN_TOKEN_PATH)),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("re-exchanges once on a 401 and retries", async () => {
|
||||
const exp = Math.floor((NOW + 24 * HOUR) / 1000);
|
||||
const g = fakeGarmin({
|
||||
token: [tokenResponse(jwt(exp, "a")), tokenResponse(jwt(exp, "b"))],
|
||||
data: [() => new Response("", { status: 401 }), json({ ok: true })],
|
||||
});
|
||||
const s = session(g.fetch);
|
||||
expect(await s.get("x", "/p")).toEqual({ ok: true });
|
||||
expect(s.exchanges).toBe(2);
|
||||
expect(g.calls.at(-1)!.auth).toBe(`Bearer ${jwt(exp, "b")}`);
|
||||
});
|
||||
|
||||
it("treats a second 401 as the key's end and stops", async () => {
|
||||
const exp = Math.floor((NOW + 24 * HOUR) / 1000);
|
||||
const g = fakeGarmin({
|
||||
token: [tokenResponse(jwt(exp, "a")), tokenResponse(jwt(exp, "b"))],
|
||||
data: [
|
||||
() => new Response("", { status: 401 }),
|
||||
() => new Response("", { status: 401 }),
|
||||
],
|
||||
});
|
||||
const err = await session(g.fetch)
|
||||
.get("x", "/p")
|
||||
.catch((e) => e);
|
||||
expect(isGarminKeyRefusal(err)).toBe(true);
|
||||
// Exactly one retry: two data calls, two exchanges, nothing more.
|
||||
expect(g.calls).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("surfaces a 429 with its wait and does not retry", async () => {
|
||||
const exp = Math.floor((NOW + 24 * HOUR) / 1000);
|
||||
const g = fakeGarmin({
|
||||
token: [tokenResponse(jwt(exp))],
|
||||
data: [
|
||||
() =>
|
||||
new Response("", { status: 429, headers: { "retry-after": "30" } }),
|
||||
],
|
||||
});
|
||||
const err = (await session(g.fetch)
|
||||
.get("x", "/p")
|
||||
.catch((e) => e)) as GarminApiError;
|
||||
expect(isGarminRateLimit(err)).toBe(true);
|
||||
expect(err.retryAfterMs).toBe(30_000);
|
||||
expect(g.calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("answers null for 204 and an empty body", async () => {
|
||||
const exp = Math.floor((NOW + 24 * HOUR) / 1000);
|
||||
const g = fakeGarmin({
|
||||
token: [tokenResponse(jwt(exp))],
|
||||
data: [
|
||||
() => new Response(null, { status: 204 }),
|
||||
() => new Response("", { status: 200 }),
|
||||
],
|
||||
});
|
||||
const s = session(g.fetch);
|
||||
expect(await s.get("x", "/p")).toBeNull();
|
||||
expect(await s.get("x", "/p")).toBeNull();
|
||||
});
|
||||
|
||||
it("puts the query on the URL and drops empty values", async () => {
|
||||
const exp = Math.floor((NOW + 24 * HOUR) / 1000);
|
||||
const g = fakeGarmin({
|
||||
token: [tokenResponse(jwt(exp))],
|
||||
data: [json([])],
|
||||
});
|
||||
await session(g.fetch).get("x", "/p", {
|
||||
startDate: "2026-10-01",
|
||||
none: undefined,
|
||||
flag: true,
|
||||
});
|
||||
expect(g.calls[1]!.url).toBe(
|
||||
"https://personal.connectapi.garmin.com/p?startDate=2026-10-01&flag=true",
|
||||
);
|
||||
});
|
||||
|
||||
it("remembers the key's end when a token reveals it", async () => {
|
||||
const exp = Math.floor((NOW + 3 * HOUR) / 1000);
|
||||
const g = fakeGarmin({
|
||||
token: [tokenResponse(jwt(exp))],
|
||||
data: [json({})],
|
||||
});
|
||||
const s = session(g.fetch);
|
||||
await s.get("x", "/p");
|
||||
expect(s.learnedKeyExpiresAt).toBe(exp * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chunkGarminRange", () => {
|
||||
it("splits newest first into windows of at most maxDays, both ends counted", () => {
|
||||
expect(chunkGarminRange("2026-01-01", "2026-03-01", 28)).toEqual([
|
||||
{ startDate: "2026-02-02", endDate: "2026-03-01" },
|
||||
{ startDate: "2026-01-05", endDate: "2026-02-01" },
|
||||
{ startDate: "2026-01-01", endDate: "2026-01-04" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a year of daily summaries in one 365-day request", () => {
|
||||
const chunks = chunkGarminRange("2025-11-05", "2026-11-04", 365);
|
||||
expect(chunks).toEqual([
|
||||
{ startDate: "2025-11-05", endDate: "2026-11-04" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns one single-day window and nothing for an inverted range", () => {
|
||||
expect(chunkGarminRange("2026-05-05", "2026-05-05", 31)).toEqual([
|
||||
{ startDate: "2026-05-05", endDate: "2026-05-05" },
|
||||
]);
|
||||
expect(chunkGarminRange("2026-05-06", "2026-05-05", 31)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* Mapping per Garmin resource, over made-up records shaped from Garmin's
|
||||
* OpenAPI document (Personal API 1.0.0). No value here comes from a real
|
||||
* account.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
mapGarminDailySummary,
|
||||
mapGarminHrv,
|
||||
mapGarminSleep,
|
||||
mapGarminWeightRange,
|
||||
parseGarminInstant,
|
||||
} from "../map";
|
||||
import { mapGarminActivity } from "../sync-workouts";
|
||||
import { mapGarminSportType } from "../sport-map";
|
||||
import { garminCycleDay, periodFromGarminCycleSummary } from "../cycle-sync";
|
||||
import { estimateGarminKeyExpiry, resolveGarminKeyExpiry } from "../key-expiry";
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
|
||||
function byType(rows: Array<{ type: string; value: number }>) {
|
||||
return Object.fromEntries(rows.map((r) => [r.type, r.value]));
|
||||
}
|
||||
|
||||
describe("parseGarminInstant", () => {
|
||||
it("reads epoch ms and zone-less GMT strings", () => {
|
||||
expect(parseGarminInstant(1_792_560_600_000)?.toISOString()).toBe(
|
||||
"2026-10-21T05:30:00.000Z",
|
||||
);
|
||||
expect(parseGarminInstant("2026-10-22 05:15:02")?.toISOString()).toBe(
|
||||
"2026-10-22T05:15:02.000Z",
|
||||
);
|
||||
expect(parseGarminInstant("2026-10-22T05:40:00.0")?.toISOString()).toBe(
|
||||
"2026-10-22T05:40:00.000Z",
|
||||
);
|
||||
expect(parseGarminInstant(null)).toBeNull();
|
||||
expect(parseGarminInstant("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapGarminDailySummary", () => {
|
||||
const day = {
|
||||
calendarDate: "2026-10-22",
|
||||
includesWellnessData: true,
|
||||
movement: {
|
||||
steps: { value: 8421, distanceInMeters: 6410.5 },
|
||||
floorsAscended: { value: 7 },
|
||||
},
|
||||
calories: { burnedActive: 512 },
|
||||
heartRate: { restingValue: 55 },
|
||||
stress: { avgLevel: 31 },
|
||||
bodyBattery: { maxValue: 88 },
|
||||
};
|
||||
|
||||
it("maps every daily field to its type and unit, keyed on the day", () => {
|
||||
const rows = mapGarminDailySummary(day, TZ);
|
||||
expect(byType(rows)).toEqual({
|
||||
ACTIVITY_STEPS: 8421,
|
||||
WALKING_RUNNING_DISTANCE: 6410.5,
|
||||
FLIGHTS_CLIMBED: 7,
|
||||
ACTIVE_ENERGY_BURNED: 512,
|
||||
RESTING_HEART_RATE: 55,
|
||||
STRESS_LEVEL: 31,
|
||||
BODY_BATTERY: 88,
|
||||
});
|
||||
const units = Object.fromEntries(rows.map((r) => [r.type, r.unit]));
|
||||
expect(units).toMatchObject({
|
||||
ACTIVITY_STEPS: "steps",
|
||||
WALKING_RUNNING_DISTANCE: "m",
|
||||
FLIGHTS_CLIMBED: "flights",
|
||||
ACTIVE_ENERGY_BURNED: "kcal",
|
||||
RESTING_HEART_RATE: "bpm",
|
||||
STRESS_LEVEL: "score",
|
||||
BODY_BATTERY: "score",
|
||||
});
|
||||
expect(rows.map((r) => r.externalId)).toContain(
|
||||
"daily:2026-10-22:body_battery",
|
||||
);
|
||||
// Local noon of the date in the user's zone (CEST, UTC+2).
|
||||
expect(rows[0]!.measuredAt.toISOString()).toBe("2026-10-22T10:00:00.000Z");
|
||||
});
|
||||
|
||||
it("writes nothing for a day the watch recorded nothing", () => {
|
||||
expect(
|
||||
mapGarminDailySummary({ ...day, includesWellnessData: false }, TZ),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
mapGarminDailySummary({ ...day, includesWellnessData: null }, TZ),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops Garmin's negative not-enough-data sentinels", () => {
|
||||
const rows = mapGarminDailySummary(
|
||||
{ ...day, stress: { avgLevel: -1 }, bodyBattery: { maxValue: -2 } },
|
||||
TZ,
|
||||
);
|
||||
expect(rows.map((r) => r.type)).not.toContain("STRESS_LEVEL");
|
||||
expect(rows.map((r) => r.type)).not.toContain("BODY_BATTERY");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapGarminSleep", () => {
|
||||
const night = {
|
||||
calendarDate: "2026-10-22",
|
||||
sleepTimeSeconds: 25_200,
|
||||
sleepEndTimestampGMT: 1_792_647_000_000,
|
||||
deepSleepSeconds: 5_400,
|
||||
lightSleepSeconds: 13_800,
|
||||
remSleepSeconds: 6_000,
|
||||
awakeSleepSeconds: 900,
|
||||
averageSpO2Value: 95,
|
||||
averageRespirationValue: 14.5,
|
||||
sleepScores: { overall: { value: 79, qualifierKey: "FAIR" } },
|
||||
};
|
||||
|
||||
it("maps stage totals onto the wake instant, light as CORE", () => {
|
||||
const rows = mapGarminSleep(night, TZ);
|
||||
const stages = rows
|
||||
.filter((r) => r.type === "SLEEP_DURATION")
|
||||
.map((r) => [r.sleepStage, r.value]);
|
||||
expect(stages).toEqual([
|
||||
["CORE", 230],
|
||||
["DEEP", 90],
|
||||
["REM", 100],
|
||||
["AWAKE", 15],
|
||||
]);
|
||||
for (const r of rows) {
|
||||
expect(r.measuredAt.getTime()).toBe(1_792_647_000_000);
|
||||
}
|
||||
expect(byType(rows.filter((r) => r.type !== "SLEEP_DURATION"))).toEqual({
|
||||
SLEEP_SCORE: 79,
|
||||
OXYGEN_SATURATION: 95,
|
||||
RESPIRATORY_RATE: 14.5,
|
||||
});
|
||||
expect(new Set(rows.map((r) => r.externalId)).size).toBe(rows.length);
|
||||
});
|
||||
|
||||
it("maps a no-sleep day (null date, all-null fields) to nothing", () => {
|
||||
expect(
|
||||
mapGarminSleep(
|
||||
{
|
||||
calendarDate: null,
|
||||
sleepTimeSeconds: null,
|
||||
deepSleepSeconds: null,
|
||||
},
|
||||
TZ,
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(mapGarminSleep({ ...night, sleepTimeSeconds: 0 }, TZ)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapGarminHrv", () => {
|
||||
it("maps the overnight average to HRV_RMSSD and skips empty nights", () => {
|
||||
const rows = mapGarminHrv(
|
||||
{
|
||||
hrvSummaries: [
|
||||
{ calendarDate: "2026-10-21", lastNightAvg: 41 },
|
||||
{ calendarDate: "2026-10-22", lastNightAvg: null },
|
||||
{ calendarDate: null, lastNightAvg: 50 },
|
||||
],
|
||||
},
|
||||
TZ,
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
type: "HRV_RMSSD",
|
||||
value: 41,
|
||||
unit: "ms",
|
||||
externalId: "hrv:2026-10-21:hrv_rmssd",
|
||||
});
|
||||
expect(mapGarminHrv(null, TZ)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapGarminWeightRange", () => {
|
||||
it("converts grams to kilograms and body water from percent", () => {
|
||||
const rows = mapGarminWeightRange({
|
||||
dailyWeightSummaries: [
|
||||
{
|
||||
summaryDate: "2026-10-20",
|
||||
allWeightMetrics: [
|
||||
{
|
||||
samplePk: 11,
|
||||
timestampGMT: 1_792_474_200_000,
|
||||
weight: 80_000,
|
||||
bmi: 24.7,
|
||||
bodyFat: 18.5,
|
||||
bodyWater: 55,
|
||||
muscleMass: 36_400,
|
||||
boneMass: 3_200,
|
||||
visceralFat: 7,
|
||||
sourceType: "INDEX_SCALE",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
summaryDate: "2026-10-21",
|
||||
latestWeight: {
|
||||
samplePk: 12,
|
||||
date: 1_792_560_000_000,
|
||||
weight: 79_600,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const first = rows.filter((r) => r.externalId.startsWith("weight:11:"));
|
||||
expect(byType(first)).toEqual({
|
||||
WEIGHT: 80,
|
||||
BODY_MASS_INDEX: 24.7,
|
||||
BODY_FAT: 18.5,
|
||||
TOTAL_BODY_WATER: 44,
|
||||
MUSCLE_MASS: 36.4,
|
||||
BONE_MASS: 3.2,
|
||||
VISCERAL_FAT: 7,
|
||||
});
|
||||
expect(first.every((r) => r.deviceType === "scale")).toBe(true);
|
||||
const second = rows.filter((r) => r.externalId.startsWith("weight:12:"));
|
||||
expect(byType(second)).toEqual({ WEIGHT: 79.6 });
|
||||
expect(second[0]!.deviceType).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not write a weigh-in twice when it is both latest and listed", () => {
|
||||
const w = { samplePk: 5, date: 1_792_560_000_000, weight: 70_000 };
|
||||
const rows = mapGarminWeightRange({
|
||||
dailyWeightSummaries: [{ latestWeight: w, allWeightMetrics: [w] }],
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapGarminActivity", () => {
|
||||
it("maps an activity to a workout row", () => {
|
||||
const row = mapGarminActivity({
|
||||
activityId: 10_000_000_001,
|
||||
startTimeGMT: "2026-10-22 05:15:02",
|
||||
activityType: { typeKey: "trail_running" },
|
||||
distance: 8012.4,
|
||||
duration: 2701.3,
|
||||
elapsedDuration: 2750.5,
|
||||
elevationGain: 54,
|
||||
calories: 610.4,
|
||||
averageHR: 148,
|
||||
maxHR: 171,
|
||||
steps: 7400,
|
||||
aerobicTrainingEffect: 3.2,
|
||||
activityTrainingLoad: 112.5,
|
||||
});
|
||||
expect(row).toMatchObject({
|
||||
externalId: "10000000001",
|
||||
sportType: "running",
|
||||
durationSec: 2701,
|
||||
totalEnergyKcal: 610,
|
||||
totalDistanceM: 8012.4,
|
||||
avgHeartRate: 148,
|
||||
maxHeartRate: 171,
|
||||
stepCount: 7400,
|
||||
elevationM: 54,
|
||||
metadata: {
|
||||
garminActivity: "trail_running",
|
||||
garminAerobicEffect: 3.2,
|
||||
garminTrainingLoad: 112.5,
|
||||
},
|
||||
});
|
||||
expect(row!.startedAt.toISOString()).toBe("2026-10-22T05:15:02.000Z");
|
||||
expect(row!.endedAt.getTime() - row!.startedAt.getTime()).toBe(2_750_500);
|
||||
});
|
||||
|
||||
it("drops an activity without id, start or duration", () => {
|
||||
expect(
|
||||
mapGarminActivity({ startTimeGMT: "2026-10-22 05:15:02" }),
|
||||
).toBeNull();
|
||||
expect(mapGarminActivity({ activityId: 1, duration: 60 })).toBeNull();
|
||||
expect(
|
||||
mapGarminActivity({
|
||||
activityId: 1,
|
||||
beginTimestamp: 1_792_560_000_000,
|
||||
duration: 0,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapGarminSportType", () => {
|
||||
it.each([
|
||||
["running", "running"],
|
||||
["road_biking", "cycling"],
|
||||
["lap_swimming", "swimming"],
|
||||
["kayaking_v2", "rowing"],
|
||||
["strength_training", "strength"],
|
||||
["yoga", "yoga"],
|
||||
["resort_skiing", "other"],
|
||||
["something_new", "other"],
|
||||
[null, "other"],
|
||||
])("%s → %s", (key, expected) => {
|
||||
expect(mapGarminSportType(key)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cycle summary", () => {
|
||||
it("reads a cycle date bare or wrapped", () => {
|
||||
expect(garminCycleDay("2026-10-10")).toBe("2026-10-10");
|
||||
expect(garminCycleDay({ localDate: "2026-10-10" })).toBe("2026-10-10");
|
||||
expect(garminCycleDay({ date: "2026-10-10T00:00:00Z" })).toBe("2026-10-10");
|
||||
expect(garminCycleDay(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("derives the logged period days, never in the future", () => {
|
||||
expect(
|
||||
periodFromGarminCycleSummary(
|
||||
{ startDate: "2026-10-10", periodLength: 5, predictedCycle: false },
|
||||
"2026-11-04",
|
||||
),
|
||||
).toEqual({
|
||||
start: "2026-10-10",
|
||||
days: [
|
||||
"2026-10-10",
|
||||
"2026-10-11",
|
||||
"2026-10-12",
|
||||
"2026-10-13",
|
||||
"2026-10-14",
|
||||
],
|
||||
});
|
||||
expect(
|
||||
periodFromGarminCycleSummary(
|
||||
{ startDate: "2026-11-03", periodLength: 5 },
|
||||
"2026-11-04",
|
||||
)?.days,
|
||||
).toEqual(["2026-11-03", "2026-11-04"]);
|
||||
});
|
||||
|
||||
it("imports nothing from a predicted cycle or a pregnancy", () => {
|
||||
expect(
|
||||
periodFromGarminCycleSummary(
|
||||
{ startDate: "2026-10-10", periodLength: 5, predictedCycle: true },
|
||||
"2026-11-04",
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
periodFromGarminCycleSummary(
|
||||
{ startDate: "2026-10-10", periodLength: 5, pregnancyCycle: true },
|
||||
"2026-11-04",
|
||||
),
|
||||
).toBeNull();
|
||||
expect(periodFromGarminCycleSummary({}, "2026-11-04")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("key expiry", () => {
|
||||
const now = new Date("2026-11-04T12:00:00Z");
|
||||
|
||||
it("estimates the end from the picked validity", () => {
|
||||
expect(
|
||||
estimateGarminKeyExpiry(
|
||||
new Date("2026-11-01T00:00:00Z"),
|
||||
90,
|
||||
).toISOString(),
|
||||
).toBe("2027-01-30T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("is ok well ahead, expiring inside the last week, expired after", () => {
|
||||
expect(
|
||||
resolveGarminKeyExpiry(new Date("2026-12-01T00:00:00Z"), now).state,
|
||||
).toBe("ok");
|
||||
const soon = resolveGarminKeyExpiry(new Date("2026-11-08T00:00:00Z"), now);
|
||||
expect(soon).toMatchObject({ state: "expiring", daysLeft: 3 });
|
||||
expect(
|
||||
resolveGarminKeyExpiry(new Date("2026-11-04T18:00:00Z"), now).daysLeft,
|
||||
).toBe(0);
|
||||
expect(
|
||||
resolveGarminKeyExpiry(new Date("2026-11-01T00:00:00Z"), now),
|
||||
).toMatchObject({ state: "expired", daysLeft: null });
|
||||
expect(resolveGarminKeyExpiry(null, now)).toEqual({
|
||||
state: "unknown",
|
||||
expiresAt: null,
|
||||
daysLeft: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user