mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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.
401 lines
12 KiB
TypeScript
401 lines
12 KiB
TypeScript
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");
|
||
});
|
||
});
|