Files
HealthLog/src/lib/oura/__tests__/client.test.ts
T
Marc-André Bombeck 9c63d3ce20 chore(release): v1.28.25 — every integration held to the same standard
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.
2026-07-11 01:54:12 +02:00

527 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
});
});