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,
});
});
});
+646
View File
@@ -0,0 +1,646 @@
/**
* The Garmin Personal API integration against the real schema and a fake
* Garmin served over HTTP.
*
* `safeFetch` is wrapped so a request to either Garmin host is sent to a
* local `http.Server` instead; everything else about the request (method,
* headers, body reading, the byte cap) is the production path. The fake
* answers the token exchange (`ApiKey <key>` → a JWT), and the daily
* summaries, sleep, HRV, weigh-ins, activity search and cycle summary from a
* made-up history generated on request. No value is from a real account.
*
* What is pinned:
* - a sync writes each resource's rows under `source = GARMIN`, a workout,
* the cycle's period days, and stamps the `garmin` ledger;
* - a second sync writes no second row (idempotent re-read);
* - a key Garmin stops accepting is re-exchanged exactly once, then the
* connection is parked at `reauth_required`;
* - a 429 ends the run without reading further resources;
* - the history backfill walks back to the start of the history in slices
* and stamps its marker; a 429 in the middle sends the next slice after
* the wait Garmin named;
* - connect validates and stores the key sealed, disconnect purges it and
* everything stored about it, while the imported history stays.
*/
import { createServer, type IncomingMessage, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { NextRequest } from "next/server";
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => cookieJar.set(name, value),
delete: (name: string) => cookieJar.delete(name),
})),
};
});
const fake = vi.hoisted(() => ({
base: "",
key: "made-up-garmin-key-0001",
/** Days with wellness data: from this day to `today`. */
historyFrom: "2026-10-01",
today: "2026-11-04",
requests: [] as string[],
/** Next data calls answered 401 (count down). */
dataUnauthorized: 0,
/** Answer the n-th data request with a 429 (0-based), -1 for never. */
rateLimitAt: -1,
dataCalls: 0,
/** Index in `requests` of the request the 429 answered. */
rateLimitHit: -1,
/** First day of the one logged cycle, relative to today. */
cycleStart: "",
sends: [] as Array<{ queue: string; payload: unknown }>,
}));
vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: () => ({
send: async (queue: string, payload: unknown) => {
fake.sends.push({ queue, payload });
return `job-${fake.sends.length}`;
},
}),
}));
vi.mock("@/lib/safe-fetch", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/safe-fetch")>();
return {
...actual,
safeFetch: async (
url: string | URL,
init?: RequestInit,
opts?: Parameters<typeof actual.safeFetch>[2],
) => {
const target = url
.toString()
.replace(
/^https:\/\/personal\.connectapi\.garmin\.(com|cn)/,
fake.base,
);
return actual.safeFetch(target, init, opts);
},
};
});
const USER_ID = "user-garmin";
const DAY_MS = 86_400_000;
function addDays(day: string, n: number): string {
return new Date(Date.parse(`${day}T00:00:00Z`) + n * DAY_MS)
.toISOString()
.slice(0, 10);
}
function daysBetween(from: string, to: string): string[] {
const out: string[] = [];
for (let d = from; d <= to; d = addDays(d, 1)) out.push(d);
return out;
}
function jwt(): string {
const enc = (o: object) =>
Buffer.from(JSON.stringify(o)).toString("base64url");
const exp = Math.floor(Date.now() / 1000) + 24 * 3600;
return `${enc({ alg: "none" })}.${enc({ exp })}.sig`;
}
const TOKEN = jwt();
/** The made-up history, generated per request. */
function answer(path: string, q: URLSearchParams): unknown {
const inHistory = (d: string) => d >= fake.historyFrom && d <= fake.today;
if (path === "/usersummary-service/v2/dailysummaries") {
return {
data: daysBetween(q.get("startDate")!, q.get("endDate")!).map((d) =>
inHistory(d)
? {
calendarDate: d,
includesWellnessData: true,
movement: {
steps: { value: 9000, distanceInMeters: 7000 },
floorsAscended: { value: 5 },
},
calories: { burnedActive: 450 },
heartRate: { restingValue: 54 },
stress: { avgLevel: 28 },
bodyBattery: { maxValue: 82 },
}
: { calendarDate: d, includesWellnessData: false },
),
};
}
if (path === "/sleep-service/sleep/dailySleepSummariesByDate") {
return daysBetween(q.get("startDate")!, q.get("endDate")!).map((d) =>
inHistory(d)
? {
calendarDate: d,
sleepTimeSeconds: 25_200,
sleepEndTimestampGMT: Date.parse(`${d}T05:30:00Z`),
deepSleepSeconds: 5_400,
lightSleepSeconds: 13_800,
remSleepSeconds: 6_000,
awakeSleepSeconds: 900,
averageSpO2Value: 95,
averageRespirationValue: 14,
sleepScores: { overall: { value: 77 } },
}
: { calendarDate: null, sleepTimeSeconds: null },
);
}
if (path.startsWith("/hrv-service/hrv/daily/")) {
const [, , , , from, to] = path.split("/");
return {
hrvSummaries: daysBetween(from!, to!)
.filter(inHistory)
.map((d) => ({ calendarDate: d, lastNightAvg: 44 })),
};
}
if (path.startsWith("/weight-service/weight/range/")) {
const [, , , , from, to] = path.split("/");
const weighDay = fake.today;
return {
dailyWeightSummaries:
weighDay >= from! && weighDay <= to!
? [
{
summaryDate: weighDay,
allWeightMetrics: [
{
samplePk: 501,
timestampGMT: Date.parse(`${weighDay}T06:00:00Z`),
weight: 75_500,
bodyFat: 19,
},
],
},
]
: [],
};
}
if (path === "/activitylist-service/activities/search/activities") {
const runDay = addDays(fake.today, -2);
const start = q.get("startDate")!;
const end = q.get("endDate")!;
if (Number(q.get("start")) > 0) return [];
return runDay >= start && runDay <= end
? [
{
activityId: 777,
startTimeGMT: `${runDay} 06:00:00`,
activityType: { typeKey: "running" },
duration: 1800,
distance: 5000,
calories: 350,
},
]
: [];
}
if (path.startsWith("/periodichealth-service/menstrualcycle/summary/")) {
const day = path.split("/").pop()!;
const cycleStart = fake.cycleStart;
return day >= cycleStart
? { startDate: cycleStart, periodLength: 4, predictedCycle: false }
: {};
}
return null;
}
let server: Server;
function readBody(req: IncomingMessage): Promise<void> {
return new Promise((resolve) => {
req.on("data", () => {});
req.on("end", () => resolve());
});
}
beforeAll(async () => {
server = createServer(async (req, res) => {
await readBody(req);
const u = new URL(req.url!, "http://fake");
fake.requests.push(`${req.method} ${u.pathname}${u.search}`);
const auth = req.headers.authorization ?? "";
if (u.pathname.startsWith("/personalapitoken-service/")) {
if (auth !== `ApiKey ${fake.key}`) {
res.writeHead(401).end();
return;
}
res.writeHead(200, { "content-type": "text/plain" }).end(TOKEN);
return;
}
const n = fake.dataCalls++;
if (fake.dataUnauthorized > 0) {
fake.dataUnauthorized--;
res.writeHead(401).end();
return;
}
if (auth !== `Bearer ${TOKEN}`) {
res.writeHead(401).end();
return;
}
if (n === fake.rateLimitAt) {
fake.rateLimitHit =
fake.requests.filter((r) =>
r.includes("/usersummary-service/v2/dailysummaries"),
).length - 1;
res.writeHead(429, { "retry-after": "900" }).end();
return;
}
const body = answer(u.pathname, u.searchParams);
if (body === null) {
res.writeHead(404).end();
return;
}
res
.writeHead(200, { "content-type": "application/json" })
.end(JSON.stringify(body));
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
fake.base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
async function connectUser() {
const { encrypt } = await import("@/lib/crypto");
await getPrismaClient().user.update({
where: { id: USER_ID },
data: {
garminApiKeyEncrypted: encrypt(fake.key),
garminRegion: "global",
garminConnectedAt: new Date(),
},
});
}
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
cookieJar.clear();
fake.requests.length = 0;
fake.sends.length = 0;
fake.dataUnauthorized = 0;
fake.rateLimitAt = -1;
fake.dataCalls = 0;
fake.rateLimitHit = -1;
fake.key = "made-up-garmin-key-0001";
fake.today = new Date().toISOString().slice(0, 10);
fake.historyFrom = addDays(fake.today, -20);
fake.cycleStart = addDays(fake.today, -15);
const { clearGarminTokenCache } = await import("@/lib/garmin/client");
clearGarminTokenCache();
await prisma.user.create({
data: {
id: USER_ID,
username: "garmin-user",
email: "garmin@example.test",
timezone: "UTC",
gender: "FEMALE",
},
});
const session = await prisma.session.create({
data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) },
});
cookieJar.set("healthlog_session", session.id);
});
async function garminRowCounts() {
const rows = await getPrismaClient().measurement.groupBy({
by: ["type"],
where: { userId: USER_ID, source: "GARMIN", deletedAt: null },
_count: { _all: true },
});
return Object.fromEntries(rows.map((r) => [r.type, r._count._all]));
}
describe("Garmin sync", () => {
it("writes every resource under source GARMIN and stamps the ledger", async () => {
await connectUser();
const { syncUserGarmin } = await import("@/lib/garmin/sync");
const result = await syncUserGarmin(USER_ID, { lookbackDays: 6 });
expect(result.failed).toBe(false);
const counts = await garminRowCounts();
// Seven days (today and six back), each with its daily and nightly rows.
expect(counts).toMatchObject({
ACTIVITY_STEPS: 7,
WALKING_RUNNING_DISTANCE: 7,
FLIGHTS_CLIMBED: 7,
ACTIVE_ENERGY_BURNED: 7,
RESTING_HEART_RATE: 7,
STRESS_LEVEL: 7,
BODY_BATTERY: 7,
SLEEP_DURATION: 28,
SLEEP_SCORE: 7,
OXYGEN_SATURATION: 7,
RESPIRATORY_RATE: 7,
HRV_RMSSD: 7,
WEIGHT: 1,
BODY_FAT: 1,
});
const prisma = getPrismaClient();
const weight = await prisma.measurement.findFirst({
where: { userId: USER_ID, type: "WEIGHT" },
});
expect(weight).toMatchObject({ source: "GARMIN", value: 75.5, unit: "kg" });
const stages = await prisma.measurement.findMany({
where: { userId: USER_ID, type: "SLEEP_DURATION" },
select: { sleepStage: true },
distinct: ["sleepStage"],
});
expect(stages.map((s) => s.sleepStage).sort()).toEqual([
"AWAKE",
"CORE",
"DEEP",
"REM",
]);
const workouts = await prisma.workout.findMany({
where: { userId: USER_ID },
});
expect(workouts).toHaveLength(1);
expect(workouts[0]).toMatchObject({
source: "GARMIN",
externalId: "777",
sportType: "running",
durationSec: 1800,
});
const cycleDays = await prisma.cycleDayLog.findMany({
where: { userId: USER_ID },
orderBy: { date: "asc" },
});
expect(cycleDays.map((d) => [d.date, d.source, d.flow])).toEqual(
[0, 1, 2, 3].map((i) => [addDays(fake.cycleStart, i), "GARMIN", "LIGHT"]),
);
const ledger = await prisma.integrationStatus.findFirst({
where: { userId: USER_ID, integration: "garmin" },
});
expect(ledger?.state).toBe("connected");
// One exchange for the whole run.
expect(
fake.requests.filter((r) => r.includes("personalapitoken")),
).toHaveLength(1);
});
it("writes no second row on a re-sync", async () => {
await connectUser();
const { syncUserGarmin } = await import("@/lib/garmin/sync");
await syncUserGarmin(USER_ID, { lookbackDays: 6 });
const before = await getPrismaClient().measurement.count({
where: { userId: USER_ID },
});
await syncUserGarmin(USER_ID, { lookbackDays: 6 });
const after = await getPrismaClient().measurement.count({
where: { userId: USER_ID },
});
expect(before).toBeGreaterThan(0);
expect(after).toBe(before);
expect(
await getPrismaClient().workout.count({ where: { userId: USER_ID } }),
).toBe(1);
expect(
await getPrismaClient().cycleDayLog.count({ where: { userId: USER_ID } }),
).toBe(4);
});
it("never overwrites a cycle day the person logged", async () => {
await connectUser();
await getPrismaClient().cycleDayLog.create({
data: {
userId: USER_ID,
date: addDays(fake.cycleStart, 1),
flow: "HEAVY",
},
});
const { syncUserGarmin } = await import("@/lib/garmin/sync");
await syncUserGarmin(USER_ID, { lookbackDays: 2 });
const own = await getPrismaClient().cycleDayLog.findFirst({
where: { userId: USER_ID, date: addDays(fake.cycleStart, 1) },
});
expect(own).toMatchObject({ source: "MANUAL", flow: "HEAVY" });
});
it("re-exchanges once on a refused token, then parks the key", async () => {
await connectUser();
fake.dataUnauthorized = 2;
const { syncUserGarmin } = await import("@/lib/garmin/sync");
await expect(
syncUserGarmin(USER_ID, { lookbackDays: 2 }),
).rejects.toThrow();
expect(
fake.requests.filter((r) => r.includes("personalapitoken")),
).toHaveLength(2);
// The two refused data calls and nothing after them.
expect(fake.dataCalls).toBe(2);
const ledger = await getPrismaClient().integrationStatus.findFirst({
where: { userId: USER_ID, integration: "garmin" },
});
expect(ledger?.state).toBe("error_reauth");
// The hourly cohort skips a parked key instead of asking Garmin again.
const before = fake.requests.length;
const skipped = await syncUserGarmin(USER_ID, { skipWhenParked: true });
expect(skipped).toEqual({ imported: 0, failed: false });
expect(fake.requests.length).toBe(before);
});
it("stops at a 429 and records a transient failure", async () => {
await connectUser();
fake.rateLimitAt = 1; // the sleep summaries, after the daily summaries
const { syncUserGarmin } = await import("@/lib/garmin/sync");
const result = await syncUserGarmin(USER_ID, { lookbackDays: 2 });
expect(result.failed).toBe(true);
// Daily summaries, then the refused sleep read; no HRV, weight or
// activity request, no cycle request.
expect(fake.dataCalls).toBe(2);
expect(fake.requests.some((r) => r.includes("/hrv-service/"))).toBe(false);
const ledger = await getPrismaClient().integrationStatus.findFirst({
where: { userId: USER_ID, integration: "garmin" },
});
expect(ledger?.state).toBe("error_transient");
expect((await garminRowCounts()).ACTIVITY_STEPS).toBe(3);
});
});
describe("Garmin history backfill", () => {
it("walks the history in slices to its start and stamps the marker", async () => {
await connectUser();
fake.historyFrom = addDays(fake.today, -400);
fake.rateLimitAt = 3;
const { runGarminBackfillForUser } =
await import("@/lib/jobs/garmin-backfill");
const prisma = getPrismaClient();
// Slice 0 hits the 429 and sends the next slice after Garmin's wait.
const first = await runGarminBackfillForUser(USER_ID, () => false, 0);
expect(first.continued).toBe(true);
expect(fake.sends.at(-1)).toMatchObject({
queue: "integration-backfill-admission",
payload: { kind: "garmin-backfill", data: { slice: 1 } },
});
let slice = 1;
for (; slice < 20; slice++) {
const user = await prisma.user.findUnique({
where: { id: USER_ID },
select: { garminBackfillCompletedAt: true },
});
if (user?.garminBackfillCompletedAt) break;
await runGarminBackfillForUser(USER_ID, () => false, slice);
}
const user = await prisma.user.findUnique({
where: { id: USER_ID },
select: { garminBackfillCompletedAt: true, garminBackfillProgress: true },
});
expect(user?.garminBackfillCompletedAt).not.toBeNull();
expect(
(user?.garminBackfillProgress as { historyStart?: string }).historyStart,
).toBe(fake.historyFrom);
// Every day of the history has its steps row, once.
const counts = await garminRowCounts();
expect(counts.ACTIVITY_STEPS).toBe(401);
expect(counts.HRV_RMSSD).toBe(401);
expect(counts.SLEEP_DURATION).toBe(401 * 4);
// No daily-summary window was read twice, apart from the one the 429
// interrupted: the next slice resumed exactly there.
const dailyReads = fake.requests.filter((r) =>
r.includes("/usersummary-service/v2/dailysummaries"),
);
expect(dailyReads.length - new Set(dailyReads).size).toBe(1);
const repeated = dailyReads.find((r, i) => dailyReads.indexOf(r) !== i);
expect(dailyReads.indexOf(repeated!)).toBe(fake.rateLimitHit);
});
});
describe("Garmin connect and disconnect", () => {
function post(url: string, body?: unknown) {
return new NextRequest(`http://localhost${url}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
}
it("refuses a key Garmin does not accept and stores nothing", async () => {
const { POST } = await import("@/app/api/garmin/connect/route");
const res = await POST(
post("/api/garmin/connect", { apiKey: "w".repeat(24) }),
);
expect(res.status).toBe(422);
const body = await res.json();
expect(body.meta?.errorCode).toBe("garmin.key_refused");
expect(JSON.stringify(body)).not.toContain("w".repeat(24));
// Tried the global service, then the China one.
expect(
fake.requests.filter((r) => r.includes("personalapitoken")),
).toHaveLength(2);
const user = await getPrismaClient().user.findUnique({
where: { id: USER_ID },
select: { garminApiKeyEncrypted: true },
});
expect(user?.garminApiKeyEncrypted).toBeNull();
});
it("stores the key sealed, then disconnect purges it and keeps the history", async () => {
const prisma = getPrismaClient();
const { POST: connect } = await import("@/app/api/garmin/connect/route");
const res = await connect(
post("/api/garmin/connect", { apiKey: fake.key, validityDays: 90 }),
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data).toMatchObject({
connected: true,
region: "global",
keyExpiry: { state: "ok" },
});
const stored = await prisma.user.findUnique({
where: { id: USER_ID },
select: {
garminApiKeyEncrypted: true,
garminKeyExpiresAt: true,
garminRegion: true,
},
});
expect(stored?.garminApiKeyEncrypted).toBeTruthy();
expect(stored?.garminApiKeyEncrypted).not.toContain(fake.key);
expect(stored?.garminRegion).toBe("global");
const days = (stored!.garminKeyExpiresAt!.getTime() - Date.now()) / DAY_MS;
expect(Math.round(days)).toBe(90);
expect(fake.sends.map((s) => s.queue)).toEqual(
expect.arrayContaining(["garmin-sync", "garmin-backfill"]),
);
const { syncUserGarmin } = await import("@/lib/garmin/sync");
await syncUserGarmin(USER_ID, { lookbackDays: 1 });
const rows = await prisma.measurement.count({ where: { userId: USER_ID } });
expect(rows).toBeGreaterThan(0);
const { POST: disconnect } =
await import("@/app/api/garmin/disconnect/route");
const out = await disconnect();
expect(out.status).toBe(200);
const after = await prisma.user.findUnique({
where: { id: USER_ID },
select: {
garminApiKeyEncrypted: true,
garminRegion: true,
garminConnectedAt: true,
garminKeyExpiresAt: true,
garminBackfillProgress: true,
garminBackfillCompletedAt: true,
},
});
expect(after).toEqual({
garminApiKeyEncrypted: null,
garminRegion: null,
garminConnectedAt: null,
garminKeyExpiresAt: null,
garminBackfillProgress: null,
garminBackfillCompletedAt: null,
});
expect(await prisma.measurement.count({ where: { userId: USER_ID } })).toBe(
rows,
);
const ledger = await prisma.integrationStatus.findFirst({
where: { userId: USER_ID, integration: "garmin" },
});
expect(ledger?.state).toBe("disconnected");
// With no key, a sync is a clean no-op that asks Garmin nothing.
const before = fake.requests.length;
expect(await syncUserGarmin(USER_ID)).toEqual({
imported: 0,
failed: false,
});
expect(fake.requests.length).toBe(before);
});
});