mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Weigh-ins: Garmin's document has no samplePk and gives kilograms, the live service sends a samplePk and grams. A weigh-in now takes its id from samplePk or else its own GMT instant, reads each mass by magnitude (above 1000 is grams), and is written whole or not at all: a missing weight, a missing instant or one implausible value drops every row of it, counted on the wide event by reason. Refused resources: a lasting 4xx on one resource (a cycle summary for an account that tracks no cycle) is noted once on the progress column, counts as done for the backfill, is skipped by the hourly sync and is listed in the card's details. It no longer holds the backfill open or turns the connection red. The cycle leg never parks the connection, whatever it is refused with. Backfill: weigh-ins walk back to the start of Garmin Connect on their own, so history before the first watch day and a scale-only account arrive. Progress and the completion stamp are written only while the connection the slice started on is still the stored one. Key: connect sends the key to the one service the person picks (Garmin Connect or Garmin Connect China), never to both. A parked key is asked about again once a day with a single token exchange and reconnected when Garmin accepts it. The key's end is read from the token's own iat and exp, an estimate the key outlived is cleared after an accepted exchange, and the validity can be corrected from the card's menu (PATCH /api/garmin/key-expiry, with the day the key was created) without pasting it again. Sync: an incremental run reaches back to the last successful sync, up to a year, so an outage longer than the 30-day window leaves no gap; a night Garmin no longer reports (deleted, or turned into a nap) is retired. Card: connect errors are translated from meta.errorCode in every locale. Types: BODY_BATTERY, STRESS_LEVEL, TRAINING_READINESS and ACUTE_TRAINING_LOAD get source ladders of their own (the device or the bridge first, then the hand-entered sources in their usual order), join the ranked types and the provenance picker, and the recovery-empty description names them.
348 lines
12 KiB
TypeScript
348 lines
12 KiB
TypeScript
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` (and `iat`), epoch seconds. */
|
|
function jwt(expSeconds: number, nonce = "a", iatSeconds?: number): string {
|
|
const enc = (o: object) =>
|
|
Buffer.from(JSON.stringify(o)).toString("base64url");
|
|
const claims =
|
|
iatSeconds === undefined
|
|
? { exp: expSeconds, n: nonce }
|
|
: { exp: expSeconds, iat: iatSeconds, n: nonce };
|
|
return `${enc({ alg: "none" })}.${enc(claims)}.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("reads the token's lifetime from its own claims, not the local clock", async () => {
|
|
// A regular day-long token, read by a clock 20 hours ahead of Garmin's:
|
|
// exp minus now says four hours, exp minus iat says a full day.
|
|
const iat = Math.floor((NOW - 20 * HOUR) / 1000);
|
|
const exp = iat + 24 * 3600;
|
|
const g = fakeGarmin({ token: [tokenResponse(jwt(exp, "a", iat))] });
|
|
const token = await exchangeGarminKey("k".repeat(20), "global", {
|
|
fetch: g.fetch,
|
|
now: () => NOW,
|
|
});
|
|
expect(token.keyExpiresAt).toBeNull();
|
|
// A token Garmin cut short at the key's end, with its own iat.
|
|
const iat2 = Math.floor(NOW / 1000);
|
|
const g2 = fakeGarmin({
|
|
token: [tokenResponse(jwt(iat2 + 3 * 3600, "b", iat2))],
|
|
});
|
|
const capped = await exchangeGarminKey("k".repeat(20), "global", {
|
|
fetch: g2.fetch,
|
|
now: () => NOW,
|
|
});
|
|
expect(capped.keyExpiresAt).toBe((iat2 + 3 * 3600) * 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(s.proven).toBe(true);
|
|
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([]);
|
|
});
|
|
});
|