Files
HealthLog/src/lib/oura/__tests__/client.test.ts
T
Marc-André Bombeck 72b1d1ef8a feat(oura): ingest the vO2_max and daily_stress collections
The Oura v2 client fetched sleep, readiness, activity, daily sleep, and
daily SpO2 but skipped the dedicated vO2_max collection and daily_stress.
An Oura user got no VO2max from any path even though the enum, the
cardio-fitness page, fitness-age, norms, and the LOINC code already exist.

Add fetchVo2Max + mapVo2Max (vo2_max -> VO2_MAX, mL/(kg·min)) and
fetchDailyStress + mapDailyStress. Oura exposes no headline stress score,
only minutes in high stress vs high recovery, so derive a bounded 0-100
STRESS_SCORE from the high-stress share of the day's charged time. Both
collections wire into the existing reactive-refresh sync tail; no
migration, no schema change, no iOS-contract impact.
2026-06-20 12:10:04 +02:00

401 lines
12 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,
exchangeCode,
fetchReadiness,
fetchVo2Max,
getAuthorizationUrl,
getOuraCredentials,
mapDailyActivity,
mapDailySleep,
mapDailySpo2,
mapDailyStress,
mapReadiness,
mapSleep,
mapVo2Max,
refreshAccessToken,
type OuraDailyActivity,
type OuraDailySleep,
type OuraDailySpo2,
type OuraDailyStress,
type OuraReadiness,
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, segment-indexed externalIds (no collapse).
const ids = mapped.map((m) => m.externalId);
expect(new Set(ids).size).toBe(ids.length);
expect(ids[0]).toBe("sleep:rec-T:seg:0");
});
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("mapDailyStress", () => {
it("derives a 0-100 STRESS_SCORE from the high-stress share of charged time", () => {
const s: OuraDailyStress = {
id: "1",
day: "2026-06-10",
stress_high: 90,
recovery_high: 30,
};
const mapped = mapDailyStress(s);
expect(mapped).toHaveLength(1);
// 90 / (90 + 30) = 75 %.
expect(mapped[0]).toMatchObject({
type: "STRESS_SCORE",
value: 75,
unit: "score",
fieldTag: "stress",
});
});
it("treats a missing recovery field as zero recovery", () => {
const mapped = mapDailyStress({
id: "1",
day: "2026-06-10",
stress_high: 60,
});
expect(mapped[0]?.value).toBe(100);
});
it("skips a day with no charged minutes (no signal)", () => {
expect(
mapDailyStress({
id: "1",
day: "2026-06-10",
stress_high: 0,
recovery_high: 0,
}),
).toEqual([]);
});
});
describe("OURA_OAUTH_SCOPE", () => {
it("requests daily + personal", () => {
expect(OURA_OAUTH_SCOPE).toBe("daily personal");
});
});