Files
HealthLog/src/lib/oura/__tests__/client.test.ts
T
Marc-André Bombeck fca1260b20 fix(oura): exchange and refresh tokens at Oura's current token endpoint
Apps from Oura's current developer portal receive codes from
moi.ouraring.com, and the documented api.ouraring.com/oauth/token answers
those codes with 400 invalid_request, so connecting failed and every
collection read came back 401. Token requests now go to
moi.ouraring.com/oauth/v2/ext/oauth-token first. A code works once, so the
documented endpoint is never asked first: a code whose callback names the
current server goes nowhere else, and only an older app's code or refresh
token gets one retry there after a 400, 401 or 404. The callback checks the
RFC 9207 iss parameter when present and refuses a foreign issuer.
2026-10-11 12:34:33 +02:00

795 lines
25 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_LEGACY_TOKEN_URL,
OURA_OAUTH_SCOPE,
OURA_OAUTH_TOKEN_URL,
RESILIENCE_LEVELS,
derivePeriodDaysFromCyclePhases,
exchangeCode,
fetchCardiovascularAge,
fetchDailyCyclePhases,
fetchReadiness,
fetchResilience,
fetchVo2Max,
getAuthorizationUrl,
getOuraCredentials,
isAcceptedOuraIssuer,
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,
);
});
// A 2xx carrying an empty / non-JSON body used to be cast straight to the
// token type, so callers dereferenced null and got an unclassified
// TypeError. It must surface as a classified transient integration error.
it("classifies a 2xx with an empty body instead of casting null", async () => {
installFetchMock([{ status: 200, body: null }]);
await expect(exchangeCode("code", CREDS)).rejects.toMatchObject({
classification: "transient",
reason: "empty_token_body",
});
});
it("classifies a 2xx with a non-object body on refresh", async () => {
installFetchMock([{ status: 200, body: "not-json" }]);
await expect(refreshAccessToken("r1", CREDS)).rejects.toBeInstanceOf(
OuraApiError,
);
});
});
/**
* Oura moved its authorization server: apps from the current developer
* portal get codes from moi.ouraring.com, which the documented
* api.ouraring.com token endpoint refuses with 400 invalid_request. A code
* works once, so the live endpoint is always asked first.
*/
describe("token endpoint order", () => {
const MOI_ISS = "https://moi.ouraring.com/oauth/v2/ext/oauth-anonymous";
const OK = {
status: 200,
body: { access_token: "a", refresh_token: "r", expires_in: 86400 },
};
const urls = (mock: ReturnType<typeof installFetchMock>) =>
mock.mock.calls.map(([url]) => url);
it("exchanges a code at the live endpoint, sending the secret in the body", async () => {
const fetchMock = installFetchMock([OK]);
await exchangeCode("code", CREDS, MOI_ISS);
expect(OURA_OAUTH_TOKEN_URL).toBe(
"https://moi.ouraring.com/oauth/v2/ext/oauth-token",
);
expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]);
const body = String(fetchMock.mock.calls[0]![1]!.body);
expect(body).toContain("grant_type=authorization_code");
expect(body).toContain("client_id=cid");
expect(body).toContain("client_secret=csecret");
});
it("never hands a code from the current server to the legacy endpoint", async () => {
const fetchMock = installFetchMock([
{ status: 400, body: { error: "invalid_request" } },
OK,
]);
await expect(exchangeCode("code", CREDS, MOI_ISS)).rejects.toMatchObject({
upstreamError: "invalid_request",
});
expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]);
expect(urls(fetchMock)).not.toContain(OURA_OAUTH_LEGACY_TOKEN_URL);
});
it("asks the legacy endpoint only after the live one refused an older app's code", async () => {
const fetchMock = installFetchMock([
{ status: 401, body: { error: "invalid_client" } },
OK,
]);
const t = await exchangeCode("code", CREDS);
expect(t.access_token).toBe("a");
expect(urls(fetchMock)).toEqual([
OURA_OAUTH_TOKEN_URL,
OURA_OAUTH_LEGACY_TOKEN_URL,
]);
});
it("does not try the legacy endpoint once the live one accepted the code", async () => {
const fetchMock = installFetchMock([OK]);
await exchangeCode("code", CREDS);
expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]);
});
it("refreshes at the live endpoint first", async () => {
const fetchMock = installFetchMock([OK]);
await refreshAccessToken("r1", CREDS);
expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]);
});
it.each([
[400, { error: "invalid_request" }],
[404, null],
])(
"falls back to the legacy endpoint on a %i from the live one",
async (status, body) => {
const fetchMock = installFetchMock([{ status, body }, OK]);
const t = await refreshAccessToken("r1", CREDS);
expect(t.refresh_token).toBe("r");
expect(urls(fetchMock)).toEqual([
OURA_OAUTH_TOKEN_URL,
OURA_OAUTH_LEGACY_TOKEN_URL,
]);
},
);
it("reports the live endpoint's refusal when both refuse", async () => {
installFetchMock([
{ status: 400, body: { error: "invalid_grant" } },
{ status: 400, body: { error: "invalid_request" } },
]);
await expect(refreshAccessToken("dead", CREDS)).rejects.toMatchObject({
upstreamError: "invalid_grant",
});
});
it("does not fall back on a server error or a rate limit", async () => {
for (const status of [429, 503]) {
const fetchMock = installFetchMock([{ status }, OK]);
await expect(refreshAccessToken("r1", CREDS)).rejects.toBeInstanceOf(
OuraApiError,
);
expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]);
vi.unstubAllGlobals();
}
});
});
describe("isAcceptedOuraIssuer", () => {
it("accepts no issuer and Oura's own servers", () => {
expect(isAcceptedOuraIssuer(null)).toBe(true);
expect(
isAcceptedOuraIssuer(
"https://moi.ouraring.com/oauth/v2/ext/oauth-anonymous",
),
).toBe(true);
expect(isAcceptedOuraIssuer("https://cloud.ouraring.com")).toBe(true);
});
it("refuses anything else", () => {
for (const iss of [
"https://evil.example/oauth",
"http://moi.ouraring.com/oauth",
"https://moi.ouraring.com.evil.example/",
"not a url",
"",
]) {
expect(isAcceptedOuraIssuer(iss), iss).toBe(false);
}
});
});
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");
});
});
describe("fetchDailyCyclePhases", () => {
it("reads the daily_cycle_phases collection path", async () => {
const fetchMock = installFetchMock([
{
status: 200,
body: {
data: [{ id: "1", day: "2026-06-10", phase: "follicular" }],
next_token: null,
},
},
]);
const r = await fetchDailyCyclePhases("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_cycle_phases");
});
it("propagates a 403 (the undocumented / scope-gated case) as an OuraApiError", async () => {
installFetchMock([{ status: 403, body: { detail: "forbidden" } }]);
await expect(
fetchDailyCyclePhases("tok", {
startDate: "2026-06-01",
endDate: "2026-06-10",
}),
).rejects.toBeInstanceOf(OuraApiError);
});
});
describe("derivePeriodDaysFromCyclePhases", () => {
it("marks a literal 'menstrual' phase day directly", () => {
const days = derivePeriodDaysFromCyclePhases([
{ day: "2026-06-05", phase: "menstrual" },
{ day: "2026-06-10", phase: "follicular" },
]);
expect(days).toEqual(["2026-06-05"]);
});
it("marks the single day a luteal phase transitions into a follicular phase", () => {
const days = derivePeriodDaysFromCyclePhases([
{ day: "2026-06-08", phase: "luteal" },
{ day: "2026-06-09", phase: "luteal" },
{ day: "2026-06-10", phase: "follicular" },
{ day: "2026-06-11", phase: "follicular" },
]);
expect(days).toEqual(["2026-06-10"]);
});
it("does NOT mark a luteal→follicular transition across a gap in the series", () => {
const days = derivePeriodDaysFromCyclePhases([
{ day: "2026-06-08", phase: "luteal" },
// 2026-06-09 missing — not calendar-adjacent.
{ day: "2026-06-10", phase: "follicular" },
]);
expect(days).toEqual([]);
});
it("is order-independent — sorts records by day before scanning", () => {
const days = derivePeriodDaysFromCyclePhases([
{ day: "2026-06-10", phase: "follicular" },
{ day: "2026-06-09", phase: "luteal" },
]);
expect(days).toEqual(["2026-06-10"]);
});
it("ignores an unrecognised / missing phase string (never guesses)", () => {
const days = derivePeriodDaysFromCyclePhases([
{ day: "2026-06-08", phase: "luteal" },
{ day: "2026-06-09", phase: "unknown_future_phase" },
{ day: "2026-06-10", phase: "follicular" },
]);
// The unrecognised phase still updates the "previous phase" tracker (to
// a value that is neither luteal nor follicular), so the 06-09→06-10
// step is no longer a luteal→follicular transition and nothing fires.
expect(days).toEqual([]);
});
it("returns [] for an empty or all-ovulatory/all-luteal series (no transition, no direct hit)", () => {
expect(derivePeriodDaysFromCyclePhases([])).toEqual([]);
expect(
derivePeriodDaysFromCyclePhases([
{ day: "2026-06-08", phase: "luteal" },
{ day: "2026-06-09", phase: "luteal" },
]),
).toEqual([]);
});
it("skips records with a missing/empty day string", () => {
const days = derivePeriodDaysFromCyclePhases([
{ day: "", phase: "menstrual" },
{ day: "2026-06-10", phase: "menstrual" },
] as never);
expect(days).toEqual(["2026-06-10"]);
});
});
describe("date-only records in the user's zone", () => {
it.each([
["Pacific/Tongatapu", "2026-05-09T23:00:00.000Z"],
["Pacific/Auckland", "2026-05-10T00:00:00.000Z"],
["America/New_York", "2026-05-10T16:00:00.000Z"],
["Europe/Berlin", "2026-05-10T10:00:00.000Z"],
])("anchors a date-only record at local noon in %s", (tz, iso) => {
const [spo2] = mapDailySpo2(
{ day: "2026-05-10", spo2_percentage: { average: 97 } } as never,
tz,
);
expect(spo2!.measuredAt.toISOString()).toBe(iso);
const [resilience] = mapResilience(
{ day: "2026-05-10", level: "solid" } as never,
tz,
);
expect(resilience!.measuredAt.toISOString()).toBe(iso);
});
});