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:
Marc-André Bombeck committed 2026-11-04 10:14:17 +01:00
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"');
});
});
+319
View File
@@ -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([]);
});
});
+380
View File
@@ -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,
});
});
});