mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
Platform-wide hardening: the failure classes found live this week hunted down across every integration. Fitbit carried the same tombstone wedge fixed for Google — probe now matches soft-deleted rows and the update resurrects them; the comment describing a partial unique index that never existed is corrected. Sleep segment ids are stable across re-scoring for Withings (session+segment-start), WHOOP/Polar (one segment per stage, no positional index) and Oura (run-start keyed), each with a session-scoped sweep that soft-deletes rows a re-score orphaned — so legacy and affected nights heal as they are re-read. One platform rule for deleted rows: source-owned readings resurrect on re-import (Withings, WHOOP, Oura, Polar, Nightscout, mood webhook, CSV re-imports, Apple stats day-totals); Apple sample-grain deletions stick per the paired-client contract, and the mood bulk route's tombstone match is now a true no-op reported as duplicate. Import counts reconcile against actual createMany counts. Google lifecycle: a dead token registers on the cycle's hard-fail ledger, so an expired connection parks instead of stamping success hourly; backfill and sleep-repair markers stamp only after a clean pass (pg-boss retries are now real); failed measurement writes hold the watermark; no-op updates are skipped; mapping.md tells the current truth. Scale: doctor-report reads only consumed columns and enabled sections; exports and backups keyset-paginate; achievements tally via SQL buckets; series charts day-bucket dense kinds beyond 90 days; BP pairing is a linear two-pointer; distinct-type scans run server-side; the graded-series cold fallback is bounded; the fitbit defer tracker loops instead of spreading.
527 lines
16 KiB
TypeScript
527 lines
16 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
|
||
import {
|
||
OURA_OAUTH_SCOPE,
|
||
RESILIENCE_LEVELS,
|
||
exchangeCode,
|
||
fetchCardiovascularAge,
|
||
fetchReadiness,
|
||
fetchResilience,
|
||
fetchVo2Max,
|
||
getAuthorizationUrl,
|
||
getOuraCredentials,
|
||
mapCardiovascularAge,
|
||
mapDailyActivity,
|
||
mapDailySleep,
|
||
mapDailySpo2,
|
||
mapReadiness,
|
||
mapResilience,
|
||
mapSleep,
|
||
mapVo2Max,
|
||
refreshAccessToken,
|
||
type OuraCardiovascularAge,
|
||
type OuraDailyActivity,
|
||
type OuraDailySleep,
|
||
type OuraDailySpo2,
|
||
type OuraReadiness,
|
||
type OuraResilience,
|
||
type OuraSleep,
|
||
type OuraVo2Max,
|
||
} from "../client";
|
||
import { OuraApiError } from "../response-classifier";
|
||
|
||
const CREDS = { clientId: "cid", clientSecret: "csecret" };
|
||
|
||
function installFetchMock(pages: Array<{ status: number; body?: unknown }>) {
|
||
let i = 0;
|
||
const fetchMock = vi.fn(async (...args: [string, RequestInit?]) => {
|
||
void args;
|
||
const page = pages[Math.min(i, pages.length - 1)]!;
|
||
i += 1;
|
||
return { status: page.status, json: async () => page.body ?? null };
|
||
});
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
return fetchMock;
|
||
}
|
||
|
||
beforeEach(() => {
|
||
process.env.OURA_CLIENT_ID = "env-cid";
|
||
process.env.OURA_CLIENT_SECRET = "env-secret";
|
||
process.env.NEXT_PUBLIC_APP_URL = "https://app.example";
|
||
delete process.env.OURA_REDIRECT_URI;
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
describe("getOuraCredentials", () => {
|
||
it("reads env", () => {
|
||
expect(getOuraCredentials()).toEqual({
|
||
clientId: "env-cid",
|
||
clientSecret: "env-secret",
|
||
});
|
||
});
|
||
it("null when unconfigured", () => {
|
||
delete process.env.OURA_CLIENT_SECRET;
|
||
expect(getOuraCredentials()).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("getAuthorizationUrl", () => {
|
||
it("builds the Oura authorize URL", () => {
|
||
const url = getAuthorizationUrl("signed", CREDS);
|
||
expect(url).toContain("cloud.ouraring.com/oauth/authorize");
|
||
expect(url).toContain("scope=daily+personal");
|
||
expect(url).toContain("state=signed");
|
||
});
|
||
});
|
||
|
||
describe("exchangeCode / refreshAccessToken", () => {
|
||
it("exchanges a code for an access + refresh token", async () => {
|
||
installFetchMock([
|
||
{
|
||
status: 200,
|
||
body: { access_token: "a", refresh_token: "r", expires_in: 86400 },
|
||
},
|
||
]);
|
||
const t = await exchangeCode("code", CREDS);
|
||
expect(t.access_token).toBe("a");
|
||
expect(t.refresh_token).toBe("r");
|
||
});
|
||
|
||
it("rotates tokens on refresh", async () => {
|
||
const fetchMock = installFetchMock([
|
||
{
|
||
status: 200,
|
||
body: { access_token: "a2", refresh_token: "r2", expires_in: 86400 },
|
||
},
|
||
]);
|
||
const t = await refreshAccessToken("r1", CREDS);
|
||
expect(t.refresh_token).toBe("r2");
|
||
const [, init] = fetchMock.mock.calls[0]!;
|
||
expect(init!.body).toContain("grant_type=refresh_token");
|
||
});
|
||
|
||
it("throws OuraApiError on a 400 invalid_grant", async () => {
|
||
installFetchMock([{ status: 400, body: { error: "invalid_grant" } }]);
|
||
await expect(refreshAccessToken("dead", CREDS)).rejects.toBeInstanceOf(
|
||
OuraApiError,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("fetchReadiness pagination", () => {
|
||
it("walks next_token across pages", async () => {
|
||
installFetchMock([
|
||
{
|
||
status: 200,
|
||
body: {
|
||
data: [{ id: "1", day: "2026-06-09", score: 70 }],
|
||
next_token: "abc",
|
||
},
|
||
},
|
||
{
|
||
status: 200,
|
||
body: {
|
||
data: [{ id: "2", day: "2026-06-10", score: 80 }],
|
||
next_token: null,
|
||
},
|
||
},
|
||
]);
|
||
const r = await fetchReadiness("tok", {
|
||
startDate: "2026-06-01",
|
||
endDate: "2026-06-10",
|
||
});
|
||
expect(r).toHaveLength(2);
|
||
});
|
||
});
|
||
|
||
describe("mapReadiness", () => {
|
||
it("maps score -> RECOVERY_SCORE", () => {
|
||
const r: OuraReadiness = { id: "1", day: "2026-06-10", score: 84 };
|
||
const mapped = mapReadiness(r);
|
||
expect(mapped.find((m) => m.type === "RECOVERY_SCORE")).toMatchObject({
|
||
value: 84,
|
||
fieldTag: "recovery",
|
||
});
|
||
});
|
||
it("maps temperature_deviation -> BODY_TEMPERATURE_DEVIATION (signed °C)", () => {
|
||
const r: OuraReadiness = {
|
||
id: "1",
|
||
day: "2026-06-10",
|
||
score: 70,
|
||
temperature_deviation: -0.42,
|
||
};
|
||
const mapped = mapReadiness(r);
|
||
const dev = mapped.find((m) => m.type === "BODY_TEMPERATURE_DEVIATION");
|
||
expect(dev).toMatchObject({
|
||
value: -0.42,
|
||
unit: "celsius",
|
||
fieldTag: "temp_deviation",
|
||
});
|
||
});
|
||
it("emits the temperature deviation even when the score is absent", () => {
|
||
const mapped = mapReadiness({
|
||
id: "1",
|
||
day: "2026-06-10",
|
||
score: null,
|
||
temperature_deviation: 0.3,
|
||
});
|
||
expect(mapped).toHaveLength(1);
|
||
expect(mapped[0].type).toBe("BODY_TEMPERATURE_DEVIATION");
|
||
});
|
||
it("skips a record with no score and no temperature", () => {
|
||
expect(mapReadiness({ id: "1", day: "2026-06-10", score: null })).toEqual(
|
||
[],
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("mapSleep", () => {
|
||
it("falls back to stage totals (no hypnogram) and record-scopes the externalId", () => {
|
||
const s: OuraSleep = {
|
||
id: "rec-9",
|
||
day: "2026-06-10",
|
||
light_sleep_duration: 3600,
|
||
deep_sleep_duration: 1800,
|
||
rem_sleep_duration: 5400,
|
||
awake_time: 600,
|
||
efficiency: 91,
|
||
average_hrv: 48,
|
||
lowest_heart_rate: 49,
|
||
average_breath: 14.5,
|
||
};
|
||
const mapped = mapSleep(s);
|
||
expect(mapped.find((m) => m.sleepStage === "CORE")?.value).toBe(60);
|
||
expect(mapped.find((m) => m.sleepStage === "AWAKE")?.value).toBe(10);
|
||
expect(mapped.find((m) => m.type === "SLEEP_EFFICIENCY")?.value).toBe(91);
|
||
expect(mapped.find((m) => m.type === "HRV_RMSSD")?.value).toBe(48);
|
||
expect(mapped.find((m) => m.type === "RESTING_HEART_RATE")?.value).toBe(49);
|
||
expect(mapped.find((m) => m.type === "RESPIRATORY_RATE")?.value).toBe(14.5);
|
||
// B2 — every sleep row is record-scoped so a nap never overwrites the main.
|
||
expect(mapped.every((m) => m.externalId?.startsWith("sleep:rec-9:"))).toBe(
|
||
true,
|
||
);
|
||
});
|
||
|
||
it("emits a real timed timeline from sleep_phase_5_min (reconstructed=false)", () => {
|
||
// 2× deep (1), 3× light (2), 1× rem (3), 1× awake (4) = 7 five-min intervals.
|
||
const s: OuraSleep = {
|
||
id: "rec-T",
|
||
day: "2026-06-10",
|
||
bedtime_start: "2026-06-09T23:00:00.000Z",
|
||
bedtime_end: "2026-06-10T07:00:00.000Z",
|
||
sleep_phase_5_min: "1122234",
|
||
};
|
||
const mapped = mapSleep(s).filter((m) => m.type === "SLEEP_DURATION");
|
||
// Four contiguous runs → four segments, in onset order.
|
||
expect(mapped.map((m) => m.sleepStage)).toEqual([
|
||
"DEEP",
|
||
"CORE",
|
||
"REM",
|
||
"AWAKE",
|
||
]);
|
||
// Run lengths × 5 min: 2→10, 3→15, 1→5, 1→5.
|
||
expect(mapped.map((m) => m.value)).toEqual([10, 15, 5, 5]);
|
||
// Each segment stamped at its own END instant relative to bedtime_start.
|
||
const onset = new Date("2026-06-09T23:00:00.000Z").getTime();
|
||
expect(mapped[0].measuredAt.getTime()).toBe(onset + 10 * 60_000);
|
||
expect(mapped[1].measuredAt.getTime()).toBe(onset + 25 * 60_000);
|
||
// Distinct, record-scoped externalIds keyed on each run's own START
|
||
// instant (no collapse). NOT a positional run index: a revised hypnogram
|
||
// re-segments the night, so an index shifted every following run onto the
|
||
// wrong row and left phantom tail rows the night-total double-counted.
|
||
const ids = mapped.map((m) => m.externalId);
|
||
expect(new Set(ids).size).toBe(ids.length);
|
||
expect(ids[0]).toBe("sleep:rec-T:seg:2026-06-09T23:00:00.000Z");
|
||
expect(ids[1]).toBe("sleep:rec-T:seg:2026-06-09T23:10:00.000Z");
|
||
});
|
||
|
||
it("keeps IDENTICAL externalIds for unchanged runs across a revised hypnogram", () => {
|
||
// The same night re-scored: Oura re-classifies the SECOND run's digits,
|
||
// splitting one run into two — every later run's index would have
|
||
// shifted (+1) under the retired run-indexed key, minting fresh ids. The
|
||
// start-keyed ids of the UNCHANGED first and last runs must survive; the
|
||
// re-segmented middle mints new ids and the record-scoped sweep clears
|
||
// what the revision orphaned.
|
||
const base = {
|
||
id: "rec-T",
|
||
day: "2026-06-10",
|
||
bedtime_start: "2026-06-09T23:00:00.000Z",
|
||
bedtime_end: "2026-06-10T07:00:00.000Z",
|
||
};
|
||
const idsOf = (hyp: string) =>
|
||
mapSleep({ ...base, sleep_phase_5_min: hyp })
|
||
.filter((m) => m.type === "SLEEP_DURATION")
|
||
.map((m) => m.externalId);
|
||
|
||
const first = idsOf("1122234");
|
||
const revised = idsOf("1121234"); // run "222" split into "2","1","2"
|
||
|
||
// First run (digits 0-1) and the tail runs that keep their start offsets
|
||
// are identical across the revision.
|
||
expect(revised[0]).toBe(first[0]);
|
||
expect(revised.at(-1)).toBe(first.at(-1));
|
||
// The re-segmented middle mints ids keyed on the new run starts — all
|
||
// still under the record's `sleep:rec-T:seg:` sweep prefix.
|
||
expect(revised.every((id) => id!.startsWith("sleep:rec-T:seg:"))).toBe(
|
||
true,
|
||
);
|
||
});
|
||
|
||
it("ignores an empty / malformed hypnogram and uses stage totals", () => {
|
||
const s: OuraSleep = {
|
||
id: "rec-E",
|
||
day: "2026-06-10",
|
||
bedtime_start: "2026-06-09T23:00:00.000Z",
|
||
sleep_phase_5_min: "",
|
||
deep_sleep_duration: 1800,
|
||
};
|
||
const mapped = mapSleep(s).filter((m) => m.type === "SLEEP_DURATION");
|
||
expect(mapped).toHaveLength(1);
|
||
expect(mapped[0].sleepStage).toBe("DEEP");
|
||
expect(mapped[0].fieldTag).toBe("sleep_deep");
|
||
});
|
||
});
|
||
|
||
describe("mapDailyActivity", () => {
|
||
it("maps steps, active energy, and equivalent walking distance (m)", () => {
|
||
const a: OuraDailyActivity = {
|
||
id: "1",
|
||
day: "2026-06-10",
|
||
steps: 9001,
|
||
active_calories: 412,
|
||
equivalent_walking_distance: 6543,
|
||
};
|
||
const mapped = mapDailyActivity(a);
|
||
expect(mapped.find((m) => m.type === "ACTIVITY_STEPS")?.value).toBe(9001);
|
||
expect(mapped.find((m) => m.type === "ACTIVE_ENERGY_BURNED")?.value).toBe(
|
||
412,
|
||
);
|
||
const dist = mapped.find((m) => m.type === "WALKING_RUNNING_DISTANCE");
|
||
expect(dist).toMatchObject({
|
||
value: 6543,
|
||
unit: "m",
|
||
fieldTag: "distance",
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("mapDailySleep", () => {
|
||
it("maps the Sleep Score -> SLEEP_SCORE", () => {
|
||
const d: OuraDailySleep = { id: "1", day: "2026-06-10", score: 82 };
|
||
const mapped = mapDailySleep(d);
|
||
expect(mapped).toHaveLength(1);
|
||
expect(mapped[0]).toMatchObject({
|
||
type: "SLEEP_SCORE",
|
||
value: 82,
|
||
unit: "score",
|
||
fieldTag: "sleep_score",
|
||
});
|
||
});
|
||
it("skips a record with no score", () => {
|
||
expect(mapDailySleep({ id: "1", day: "2026-06-10", score: null })).toEqual(
|
||
[],
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("mapDailySpo2", () => {
|
||
it("maps the average SpO2 -> OXYGEN_SATURATION", () => {
|
||
const s: OuraDailySpo2 = {
|
||
id: "1",
|
||
day: "2026-06-10",
|
||
spo2_percentage: { average: 96.8 },
|
||
};
|
||
const mapped = mapDailySpo2(s);
|
||
expect(mapped).toHaveLength(1);
|
||
expect(mapped[0]).toMatchObject({
|
||
type: "OXYGEN_SATURATION",
|
||
value: 96.8,
|
||
unit: "%",
|
||
fieldTag: "spo2",
|
||
});
|
||
});
|
||
it("skips a record with no average", () => {
|
||
expect(
|
||
mapDailySpo2({
|
||
id: "1",
|
||
day: "2026-06-10",
|
||
spo2_percentage: { average: null },
|
||
}),
|
||
).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe("fetchVo2Max", () => {
|
||
it("reads the camel-cased vO2_max collection path", async () => {
|
||
const fetchMock = installFetchMock([
|
||
{
|
||
status: 200,
|
||
body: {
|
||
data: [{ id: "1", day: "2026-06-10", vo2_max: 47.3 }],
|
||
next_token: null,
|
||
},
|
||
},
|
||
]);
|
||
const r = await fetchVo2Max("tok", {
|
||
startDate: "2026-06-01",
|
||
endDate: "2026-06-10",
|
||
});
|
||
expect(r).toHaveLength(1);
|
||
const [url] = fetchMock.mock.calls[0]!;
|
||
expect(url).toContain("/v2/usercollection/vO2_max");
|
||
});
|
||
});
|
||
|
||
describe("mapVo2Max", () => {
|
||
it("maps vo2_max -> VO2_MAX (mL/(kg·min))", () => {
|
||
const v: OuraVo2Max = { id: "1", day: "2026-06-10", vo2_max: 47.3 };
|
||
const mapped = mapVo2Max(v);
|
||
expect(mapped).toHaveLength(1);
|
||
expect(mapped[0]).toMatchObject({
|
||
type: "VO2_MAX",
|
||
value: 47.3,
|
||
unit: "mL/(kg·min)",
|
||
fieldTag: "vo2_max",
|
||
});
|
||
});
|
||
it("skips a record with no positive value", () => {
|
||
expect(mapVo2Max({ id: "1", day: "2026-06-10", vo2_max: 0 })).toEqual([]);
|
||
expect(mapVo2Max({ id: "1", day: "2026-06-10", vo2_max: null })).toEqual(
|
||
[],
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("fetchCardiovascularAge", () => {
|
||
it("reads the daily_cardiovascular_age collection path", async () => {
|
||
const fetchMock = installFetchMock([
|
||
{
|
||
status: 200,
|
||
body: {
|
||
data: [{ id: "1", day: "2026-06-10", vascular_age: 39 }],
|
||
next_token: null,
|
||
},
|
||
},
|
||
]);
|
||
const r = await fetchCardiovascularAge("tok", {
|
||
startDate: "2026-06-01",
|
||
endDate: "2026-06-10",
|
||
});
|
||
expect(r).toHaveLength(1);
|
||
const [url] = fetchMock.mock.calls[0]!;
|
||
expect(url).toContain("/v2/usercollection/daily_cardiovascular_age");
|
||
});
|
||
});
|
||
|
||
describe("mapCardiovascularAge", () => {
|
||
it("maps vascular_age -> VASCULAR_AGE (years)", () => {
|
||
const c: OuraCardiovascularAge = { day: "2026-06-10", vascular_age: 39 };
|
||
const mapped = mapCardiovascularAge(c);
|
||
expect(mapped).toHaveLength(1);
|
||
expect(mapped[0]).toMatchObject({
|
||
type: "VASCULAR_AGE",
|
||
value: 39,
|
||
unit: "years",
|
||
fieldTag: "vascular_age",
|
||
});
|
||
});
|
||
it("skips a record with no positive value", () => {
|
||
expect(
|
||
mapCardiovascularAge({ day: "2026-06-10", vascular_age: 0 }),
|
||
).toEqual([]);
|
||
expect(
|
||
mapCardiovascularAge({ day: "2026-06-10", vascular_age: null }),
|
||
).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe("fetchResilience", () => {
|
||
it("reads the daily_resilience collection path", async () => {
|
||
const fetchMock = installFetchMock([
|
||
{
|
||
status: 200,
|
||
body: {
|
||
data: [{ id: "1", day: "2026-06-10", level: "solid" }],
|
||
next_token: null,
|
||
},
|
||
},
|
||
]);
|
||
const r = await fetchResilience("tok", {
|
||
startDate: "2026-06-01",
|
||
endDate: "2026-06-10",
|
||
});
|
||
expect(r).toHaveLength(1);
|
||
const [url] = fetchMock.mock.calls[0]!;
|
||
expect(url).toContain("/v2/usercollection/daily_resilience");
|
||
});
|
||
});
|
||
|
||
describe("mapResilience", () => {
|
||
it("ordinal-encodes all five levels (limited=1 … exceptional=5)", () => {
|
||
const expected: Array<[string, number]> = [
|
||
["limited", 1],
|
||
["adequate", 2],
|
||
["solid", 3],
|
||
["strong", 4],
|
||
["exceptional", 5],
|
||
];
|
||
for (const [level, ordinal] of expected) {
|
||
const mapped = mapResilience({ day: "2026-06-10", level });
|
||
expect(mapped).toHaveLength(1);
|
||
expect(mapped[0]).toMatchObject({
|
||
type: "RESILIENCE",
|
||
value: ordinal,
|
||
unit: "level",
|
||
fieldTag: "resilience",
|
||
});
|
||
}
|
||
});
|
||
|
||
it("RESILIENCE_LEVELS is the source of truth for the encoding", () => {
|
||
expect(RESILIENCE_LEVELS).toEqual({
|
||
limited: 1,
|
||
adequate: 2,
|
||
solid: 3,
|
||
strong: 4,
|
||
exceptional: 5,
|
||
});
|
||
});
|
||
|
||
it("is case-insensitive on the level string", () => {
|
||
const mapped = mapResilience({ day: "2026-06-10", level: "STRONG" });
|
||
expect(mapped).toHaveLength(1);
|
||
expect(mapped[0]).toMatchObject({ type: "RESILIENCE", value: 4 });
|
||
});
|
||
|
||
it("skips an unknown / missing level (no row, never coerced to 0)", () => {
|
||
const cases: OuraResilience[] = [
|
||
{ day: "2026-06-10", level: "extraordinary" },
|
||
{ day: "2026-06-10", level: "" },
|
||
{ day: "2026-06-10", level: null },
|
||
{ day: "2026-06-10" },
|
||
];
|
||
for (const c of cases) {
|
||
expect(mapResilience(c)).toEqual([]);
|
||
}
|
||
});
|
||
|
||
it("anchors the date-only row at noon UTC so it round-trips the day", () => {
|
||
// Noon, not midnight: a UTC-midnight anchor double-shifts the calendar
|
||
// day for west-of-UTC users when the read path re-buckets via userDayKey.
|
||
const mapped = mapResilience({ day: "2026-06-10", level: "adequate" });
|
||
expect(mapped[0]?.measuredAt.toISOString()).toBe(
|
||
"2026-06-10T12:00:00.000Z",
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("OURA_OAUTH_SCOPE", () => {
|
||
it("requests daily + personal", () => {
|
||
expect(OURA_OAUTH_SCOPE).toBe("daily personal");
|
||
});
|
||
});
|