mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Garmin Connect now offers a Personal API: a person creates a key for their own account and any client can buy short-lived tokens with it. HealthLog takes that key in a new Garmin card, checks it with Garmin before storing it sealed on the user row, and polls every hour. - Transport: key exchange with a per-process token cache keyed on the key, one re-exchange on a refused token and then reauth_required, the 429 Retry-After carried to the caller, the China host tried when the global one does not know the key, answers read with a byte cap, all egress through safeFetch to the two fixed hosts. - Sync: daily summaries, sleep summaries, HRV, weigh-ins and the activity search, chunked to each resource's documented range, read one after another and stopped at the first rate limit; today's menstrual cycle summary adds logged period days the person has not logged. - Backfill: a resumable walk through the shared admission lane that learns where the history starts from the daily summaries and the activities, waits out Garmin's rate limit and stamps its marker. - Types: GARMIN source; BODY_BATTERY and STRESS_LEVEL for Garmin's own day scores, kept apart from the computed STRESS_SCORE; TRAINING_READINESS and ACUTE_TRAINING_LOAD for figures a bridge pushes through the ingest token. All four chart on the recovery page and sit on the ladders, units, ranges, labels and report groups like the device-native scores. - Key expiry: learned from a token capped short of a day, else estimated from the validity picked at connect; the card warns a week ahead and asks for a new key once Garmin refuses the old one. - Ledger, health, freshness, watchdog, backup classes, wipe plan, encrypted-column registry, rotation script, OpenAPI and all ten locales carry the new integration. The old "no Garmin connector" note is gone.
448 lines
14 KiB
TypeScript
448 lines
14 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { NextRequest } from "next/server";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type {
|
|
MeasurementSource,
|
|
MeasurementType,
|
|
} from "@/generated/prisma/client";
|
|
import { POST as postCsvImport } from "@/app/api/import/csv/route";
|
|
import { POST as postAppleHealthBatch } from "@/app/api/measurements/batch/route";
|
|
import { POST as postManualMeasurement } from "@/app/api/measurements/route";
|
|
import { hashToken } from "@/lib/auth/hmac";
|
|
import { encrypt } from "@/lib/crypto";
|
|
import { upsertFitbitMeasurements } from "@/lib/fitbit/sync-core";
|
|
import { upsertGoogleHealthMeasurements } from "@/lib/google-health/sync-core";
|
|
import { upsertScoreRow } from "@/lib/insights/score-row";
|
|
import { logMcpMeasurement } from "@/lib/mcp/writes";
|
|
import { logTelegramMeasurement } from "@/lib/measurements/create-from-telegram";
|
|
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
|
|
import { reconcileExternalMeasurement } from "@/lib/measurements/reconcile-external-measurement";
|
|
import { upsertNightscoutEntries } from "@/lib/nightscout/sync";
|
|
import { upsertOuraMeasurements } from "@/lib/oura/sync";
|
|
import { upsertGarminMeasurements } from "@/lib/garmin/sync";
|
|
import { upsertPolarMeasurements } from "@/lib/polar/sync";
|
|
import { syncUserActivity } from "@/lib/withings/sync-activity";
|
|
import { upsertWhoopMeasurements } from "@/lib/whoop/sync-core";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
process.env.ENCRYPTION_KEY ??=
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
// The factory is hoisted before static imports, so it resolves the shared jars
|
|
// inside the mock boundary rather than touching an uninitialised binding.
|
|
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 USER_ID = "measurement-source-write-smoke";
|
|
const MEASURED_AT = new Date("2026-07-20T08:00:00.000Z");
|
|
|
|
type SourceCase = {
|
|
expectedType: MeasurementType;
|
|
expectedExternalId: string | null;
|
|
write: () => Promise<void>;
|
|
};
|
|
|
|
function jsonRequest(url: string, body: unknown): NextRequest {
|
|
return new NextRequest(url, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
const SOURCE_CASES = {
|
|
MANUAL: {
|
|
expectedType: "WEIGHT",
|
|
expectedExternalId: null,
|
|
write: async () => {
|
|
const response = await postManualMeasurement(
|
|
jsonRequest("http://localhost/api/measurements", {
|
|
type: "WEIGHT",
|
|
value: 72.4,
|
|
measuredAt: MEASURED_AT.toISOString(),
|
|
}),
|
|
);
|
|
expect(response.status).toBe(201);
|
|
},
|
|
},
|
|
WITHINGS: {
|
|
expectedType: "ACTIVITY_STEPS",
|
|
expectedExternalId: `withings:activity:${USER_ID}:2026-07-20:steps`,
|
|
write: async () => {
|
|
await getPrismaClient().withingsConnection.create({
|
|
data: {
|
|
userId: USER_ID,
|
|
withingsUserId: "withings-source-smoke",
|
|
accessToken: encrypt("synthetic-access-token"),
|
|
refreshToken: encrypt("synthetic-refresh-token"),
|
|
tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
scope: "user.metrics,user.activity",
|
|
},
|
|
});
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async () => ({
|
|
status: 200,
|
|
json: async () => ({
|
|
status: 0,
|
|
body: {
|
|
activities: [{ date: "2026-07-20", steps: 7_654 }],
|
|
more: false,
|
|
offset: 0,
|
|
},
|
|
}),
|
|
})),
|
|
);
|
|
await syncUserActivity(USER_ID);
|
|
vi.unstubAllGlobals();
|
|
},
|
|
},
|
|
IMPORT: {
|
|
expectedType: "WEIGHT",
|
|
expectedExternalId: "source-smoke-import",
|
|
write: async () => {
|
|
const csv = [
|
|
"type,value,unit,measuredAt,glucoseContext,notes,externalId",
|
|
"WEIGHT,71.8,kg,2026-07-20T08:02:00.000Z,,,source-smoke-import",
|
|
].join("\n");
|
|
const response = await postCsvImport(
|
|
new NextRequest("http://localhost/api/import/csv", {
|
|
method: "POST",
|
|
headers: { "content-type": "text/csv" },
|
|
body: csv,
|
|
}),
|
|
);
|
|
expect(response.status).toBe(200);
|
|
},
|
|
},
|
|
APPLE_HEALTH: {
|
|
expectedType: "WEIGHT",
|
|
expectedExternalId: "source-smoke-apple-health",
|
|
write: async () => {
|
|
const response = await postAppleHealthBatch(
|
|
jsonRequest("http://localhost/api/measurements/batch", {
|
|
entries: [
|
|
{
|
|
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
|
|
value: 72.1,
|
|
unit: "kg",
|
|
startDate: "2026-07-20T08:03:00.000Z",
|
|
endDate: "2026-07-20T08:03:00.000Z",
|
|
externalId: "source-smoke-apple-health",
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(response.status).toBe(200);
|
|
},
|
|
},
|
|
COMPUTED: {
|
|
expectedType: "RECOVERY_SCORE",
|
|
expectedExternalId: "source-smoke-computed:2026-07-20",
|
|
write: async () => {
|
|
await upsertScoreRow(getPrismaClient(), {
|
|
userId: USER_ID,
|
|
type: "RECOVERY_SCORE",
|
|
externalIdPrefix: "source-smoke-computed:",
|
|
score: 74,
|
|
now: new Date("2026-07-21T08:00:00.000Z"),
|
|
});
|
|
},
|
|
},
|
|
WHOOP: {
|
|
expectedType: "HRV_RMSSD",
|
|
expectedExternalId: "source-smoke-whoop:hrv",
|
|
write: async () => {
|
|
await upsertWhoopMeasurements(USER_ID, [
|
|
{
|
|
type: "HRV_RMSSD",
|
|
value: 58,
|
|
unit: "ms",
|
|
measuredAt: new Date("2026-07-20T08:04:00.000Z"),
|
|
externalId: "source-smoke-whoop:hrv",
|
|
},
|
|
]);
|
|
},
|
|
},
|
|
FITBIT: {
|
|
expectedType: "PULSE",
|
|
expectedExternalId: "source-smoke-fitbit:pulse",
|
|
write: async () => {
|
|
await upsertFitbitMeasurements(USER_ID, [
|
|
{
|
|
type: "PULSE",
|
|
value: 67,
|
|
unit: "bpm",
|
|
measuredAt: new Date("2026-07-20T08:05:00.000Z"),
|
|
externalId: "source-smoke-fitbit:pulse",
|
|
},
|
|
]);
|
|
},
|
|
},
|
|
NIGHTSCOUT: {
|
|
expectedType: "BLOOD_GLUCOSE",
|
|
expectedExternalId: "ns:source-smoke-nightscout",
|
|
write: async () => {
|
|
await upsertNightscoutEntries(USER_ID, [
|
|
{
|
|
id: "source-smoke-nightscout",
|
|
sgv: 101,
|
|
date: new Date("2026-07-20T08:06:00.000Z").getTime(),
|
|
},
|
|
]);
|
|
},
|
|
},
|
|
POLAR: {
|
|
expectedType: "HRV_RMSSD",
|
|
expectedExternalId: "source-smoke-polar:hrv",
|
|
write: async () => {
|
|
await upsertPolarMeasurements(USER_ID, [
|
|
{
|
|
type: "HRV_RMSSD",
|
|
value: 61,
|
|
unit: "ms",
|
|
measuredAt: new Date("2026-07-20T08:07:00.000Z"),
|
|
externalId: "source-smoke-polar:hrv",
|
|
},
|
|
]);
|
|
},
|
|
},
|
|
OURA: {
|
|
expectedType: "HRV_RMSSD",
|
|
expectedExternalId: "source-smoke-oura:hrv",
|
|
write: async () => {
|
|
await upsertOuraMeasurements(USER_ID, [
|
|
{
|
|
type: "HRV_RMSSD",
|
|
value: 63,
|
|
unit: "ms",
|
|
measuredAt: new Date("2026-07-20T08:08:00.000Z"),
|
|
externalId: "source-smoke-oura:hrv",
|
|
},
|
|
]);
|
|
},
|
|
},
|
|
GARMIN: {
|
|
expectedType: "BODY_BATTERY",
|
|
expectedExternalId: "daily:2026-07-20:body_battery",
|
|
write: async () => {
|
|
await upsertGarminMeasurements(USER_ID, [
|
|
{
|
|
type: "BODY_BATTERY",
|
|
value: 81,
|
|
unit: "score",
|
|
measuredAt: new Date("2026-07-20T10:00:00.000Z"),
|
|
externalId: "daily:2026-07-20:body_battery",
|
|
},
|
|
]);
|
|
},
|
|
},
|
|
TELEGRAM: {
|
|
expectedType: "WEIGHT",
|
|
expectedExternalId: "telegram:source-smoke",
|
|
write: async () => {
|
|
const result = await logTelegramMeasurement({
|
|
userId: USER_ID,
|
|
type: "WEIGHT",
|
|
rawText: "72.7",
|
|
tz: "UTC",
|
|
externalId: "telegram:source-smoke",
|
|
});
|
|
expect(result.status).toBe("ok");
|
|
},
|
|
},
|
|
MCP: {
|
|
expectedType: "WEIGHT",
|
|
expectedExternalId: `mcp:measure:${createHash("sha256")
|
|
.update("source-smoke-mcp")
|
|
.digest("hex")}`,
|
|
write: async () => {
|
|
const result = await logMcpMeasurement({
|
|
userId: USER_ID,
|
|
type: "WEIGHT",
|
|
value: 72.9,
|
|
measuredAt: new Date("2026-07-20T08:10:00.000Z"),
|
|
idempotencyKey: "source-smoke-mcp",
|
|
});
|
|
expect(result.status).toBe("written");
|
|
},
|
|
},
|
|
GOOGLE_HEALTH: {
|
|
expectedType: "PULSE",
|
|
expectedExternalId: "source-smoke-google-health:pulse",
|
|
write: async () => {
|
|
await upsertGoogleHealthMeasurements(USER_ID, [
|
|
{
|
|
type: "PULSE",
|
|
value: 69,
|
|
unit: "bpm",
|
|
measuredAt: new Date("2026-07-20T08:11:00.000Z"),
|
|
externalId: "source-smoke-google-health:pulse",
|
|
},
|
|
]);
|
|
},
|
|
},
|
|
// Strava's provider sync writes Workout rows only. There is no dedicated
|
|
// Strava Measurement writer, so this case uses the shared external-
|
|
// measurement reconciliation service, the narrowest production write path.
|
|
STRAVA: {
|
|
expectedType: "PULSE",
|
|
expectedExternalId: "source-smoke-strava:pulse",
|
|
write: async () => {
|
|
const verdict = await getPrismaClient().$transaction((tx) =>
|
|
reconcileExternalMeasurement(tx, {
|
|
userId: USER_ID,
|
|
type: "PULSE",
|
|
source: "STRAVA",
|
|
value: 66,
|
|
unit: "bpm",
|
|
measuredAt: new Date("2026-07-20T08:12:00.000Z"),
|
|
externalId: "source-smoke-strava:pulse",
|
|
}),
|
|
);
|
|
expect(verdict.status).toBe("inserted");
|
|
},
|
|
},
|
|
// v1.42 — Health Connect rows enter through the import worker, which writes
|
|
// each sample through the shared external-measurement reconciliation
|
|
// service. Until the worker lands, the case drives that service directly,
|
|
// the same narrowest production path the STRAVA case uses.
|
|
HEALTH_CONNECT: {
|
|
expectedType: "PULSE",
|
|
expectedExternalId: "hc:source-smoke-health-connect",
|
|
write: async () => {
|
|
const verdict = await getPrismaClient().$transaction((tx) =>
|
|
reconcileExternalMeasurement(tx, {
|
|
userId: USER_ID,
|
|
type: "PULSE",
|
|
source: "HEALTH_CONNECT",
|
|
value: 64,
|
|
unit: "bpm",
|
|
measuredAt: new Date("2026-07-20T08:13:00.000Z"),
|
|
externalId: "hc:source-smoke-health-connect",
|
|
}),
|
|
);
|
|
expect(verdict.status).toBe("inserted");
|
|
},
|
|
},
|
|
// v1.38.x — the third-party ingest token. Written through the ordinary
|
|
// single-entry POST, because that IS the production path: the route reads
|
|
// the credential and resolves the source itself. The body names none — one
|
|
// that did would be refused 422.
|
|
EXTERNAL: {
|
|
expectedType: "PULSE",
|
|
expectedExternalId: null,
|
|
write: async () => {
|
|
// The trap in this suite: `beforeEach` seeds a session cookie and the
|
|
// caller resolver is cookie-first, so arming only the `authorization`
|
|
// header would resolve as that session and quietly write MANUAL — the
|
|
// case would then fail on a row it never actually tested. Clear the
|
|
// cookie for the duration, then put it back: this is the last case
|
|
// today, but nothing stops one being appended after it.
|
|
const prisma = getPrismaClient();
|
|
const session = cookieJar.get("healthlog_session");
|
|
cookieJar.clear();
|
|
|
|
const raw = `hlk_external_${"0".repeat(48)}`;
|
|
await prisma.apiToken.create({
|
|
data: {
|
|
userId: USER_ID,
|
|
name: "source-smoke-external",
|
|
tokenHash: hashToken(raw),
|
|
permissions: [MEASUREMENTS_WRITE_SCOPE],
|
|
},
|
|
});
|
|
headerJar.set("authorization", `Bearer ${raw}`);
|
|
|
|
try {
|
|
const response = await postManualMeasurement(
|
|
jsonRequest("http://localhost/api/measurements", {
|
|
type: "PULSE",
|
|
value: 61,
|
|
measuredAt: MEASURED_AT.toISOString(),
|
|
}),
|
|
);
|
|
expect(response.status).toBe(201);
|
|
} finally {
|
|
headerJar.delete("authorization");
|
|
if (session) cookieJar.set("healthlog_session", session);
|
|
}
|
|
},
|
|
},
|
|
} satisfies Record<MeasurementSource, SourceCase>;
|
|
|
|
beforeEach(async () => {
|
|
const prisma = getPrismaClient();
|
|
await truncateAllTables(prisma);
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
await prisma.user.create({
|
|
data: {
|
|
id: USER_ID,
|
|
username: "measurement-source-smoke",
|
|
email: "measurement-source-smoke@example.test",
|
|
timezone: "UTC",
|
|
},
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: {
|
|
userId: USER_ID,
|
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
},
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("MeasurementSource write paths against real Postgres", () => {
|
|
it("commits and rereads exactly one Measurement for every source", async () => {
|
|
const prisma = getPrismaClient();
|
|
const sourceCases = Object.entries(SOURCE_CASES) as Array<
|
|
[MeasurementSource, SourceCase]
|
|
>;
|
|
|
|
for (const [source, sourceCase] of sourceCases) {
|
|
await sourceCase.write();
|
|
|
|
const rows = await prisma.measurement.findMany({
|
|
where: { userId: USER_ID, source },
|
|
});
|
|
expect(rows, `${source} committed row`).toHaveLength(1);
|
|
expect(rows[0]).toMatchObject({
|
|
source,
|
|
type: sourceCase.expectedType,
|
|
externalId: sourceCase.expectedExternalId,
|
|
});
|
|
}
|
|
|
|
expect(sourceCases).toHaveLength(17);
|
|
expect(await prisma.measurement.count({ where: { userId: USER_ID } })).toBe(
|
|
17,
|
|
);
|
|
});
|
|
});
|