mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user