mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
fix(oura): ask for cycle phases over the user's own days, like the other collections
This commit is contained in:
1 parent
3cbfe7f7f9
commit
ab31c9d89c
4 files changed
+42
-22
No files matched your search
@@ -35,6 +35,8 @@ vi.mock("../client", async (importOriginal) => {
|
||||
|
||||
import { syncUserOuraCyclePhases } from "../cycle-sync";
|
||||
|
||||
const WINDOW = { startDate: "2026-09-20", endDate: "2026-09-27" };
|
||||
|
||||
function uniqueViolation() {
|
||||
const err = new Error("Unique constraint failed") as Error & {
|
||||
code: string;
|
||||
@@ -52,7 +54,7 @@ beforeEach(() => {
|
||||
|
||||
describe("syncUserOuraCyclePhases", () => {
|
||||
it("no-ops without touching the DB when the collection is empty", async () => {
|
||||
const created = await syncUserOuraCyclePhases("u1", "tok", 7);
|
||||
const created = await syncUserOuraCyclePhases("u1", "tok", WINDOW);
|
||||
expect(created).toBe(0);
|
||||
expect(isCycleAvailableMock).not.toHaveBeenCalled();
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
@@ -63,7 +65,7 @@ describe("syncUserOuraCyclePhases", () => {
|
||||
{ day: "2026-06-08", phase: "luteal" },
|
||||
{ day: "2026-06-09", phase: "luteal" },
|
||||
]);
|
||||
const created = await syncUserOuraCyclePhases("u1", "tok", 7);
|
||||
const created = await syncUserOuraCyclePhases("u1", "tok", WINDOW);
|
||||
expect(created).toBe(0);
|
||||
expect(isCycleAvailableMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -73,7 +75,7 @@ describe("syncUserOuraCyclePhases", () => {
|
||||
{ day: "2026-06-05", phase: "menstrual" },
|
||||
]);
|
||||
isCycleAvailableMock.mockResolvedValue(false);
|
||||
const created = await syncUserOuraCyclePhases("u1", "tok", 7);
|
||||
const created = await syncUserOuraCyclePhases("u1", "tok", WINDOW);
|
||||
expect(created).toBe(0);
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -82,7 +84,7 @@ describe("syncUserOuraCyclePhases", () => {
|
||||
fetchCyclePhasesMock.mockResolvedValue([
|
||||
{ day: "2026-06-05", phase: "menstrual" },
|
||||
]);
|
||||
const created = await syncUserOuraCyclePhases("u1", "tok", 7);
|
||||
const created = await syncUserOuraCyclePhases("u1", "tok", WINDOW);
|
||||
expect(created).toBe(1);
|
||||
expect(createMock).toHaveBeenCalledWith({
|
||||
data: {
|
||||
@@ -100,7 +102,7 @@ describe("syncUserOuraCyclePhases", () => {
|
||||
{ day: "2026-06-05", phase: "menstrual" },
|
||||
]);
|
||||
createMock.mockRejectedValue(uniqueViolation());
|
||||
const created = await syncUserOuraCyclePhases("u1", "tok", 7);
|
||||
const created = await syncUserOuraCyclePhases("u1", "tok", WINDOW);
|
||||
expect(created).toBe(0);
|
||||
});
|
||||
|
||||
@@ -112,7 +114,7 @@ describe("syncUserOuraCyclePhases", () => {
|
||||
createMock
|
||||
.mockRejectedValueOnce(uniqueViolation()) // 06-05 already logged (manual)
|
||||
.mockResolvedValueOnce({ id: "cdl-2" }); // 06-06 genuinely empty
|
||||
const created = await syncUserOuraCyclePhases("u1", "tok", 7);
|
||||
const created = await syncUserOuraCyclePhases("u1", "tok", WINDOW);
|
||||
expect(created).toBe(1);
|
||||
expect(createMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -125,7 +127,7 @@ describe("syncUserOuraCyclePhases", () => {
|
||||
createMock
|
||||
.mockRejectedValueOnce(new Error("connection reset"))
|
||||
.mockResolvedValueOnce({ id: "cdl-2" });
|
||||
const created = await syncUserOuraCyclePhases("u1", "tok", 7);
|
||||
const created = await syncUserOuraCyclePhases("u1", "tok", WINDOW);
|
||||
// The failing day is skipped (warned, not thrown); the healthy sibling
|
||||
// still writes.
|
||||
expect(created).toBe(1);
|
||||
@@ -133,7 +135,7 @@ describe("syncUserOuraCyclePhases", () => {
|
||||
|
||||
it("propagates the Oura fetch error to the caller (the sync layer decides how to swallow it)", async () => {
|
||||
fetchCyclePhasesMock.mockRejectedValue(new Error("403 forbidden"));
|
||||
await expect(syncUserOuraCyclePhases("u1", "tok", 7)).rejects.toThrow(
|
||||
await expect(syncUserOuraCyclePhases("u1", "tok", WINDOW)).rejects.toThrow(
|
||||
"403 forbidden",
|
||||
);
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
@@ -144,7 +146,7 @@ describe("syncUserOuraCyclePhases", () => {
|
||||
{ day: "2026-06-05", phase: "menstrual" },
|
||||
]);
|
||||
findUserMock.mockResolvedValue(null);
|
||||
await syncUserOuraCyclePhases("u1", "tok", 7);
|
||||
await syncUserOuraCyclePhases("u1", "tok", WINDOW);
|
||||
expect(createMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ tz: null }) }),
|
||||
);
|
||||
@@ -155,7 +157,7 @@ describe("syncUserOuraCyclePhases", () => {
|
||||
{ day: "2026-06-08", phase: "luteal" },
|
||||
{ day: "2026-06-09", phase: "follicular" },
|
||||
]);
|
||||
const created = await syncUserOuraCyclePhases("u1", "tok", 7);
|
||||
const created = await syncUserOuraCyclePhases("u1", "tok", WINDOW);
|
||||
expect(created).toBe(1);
|
||||
expect(createMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
onTestFinished,
|
||||
vi,
|
||||
} from "vitest";
|
||||
|
||||
const {
|
||||
getConnMock,
|
||||
@@ -842,6 +850,14 @@ describe("syncUserOura — window resolution", () => {
|
||||
});
|
||||
|
||||
it("passes the resolved window to the cycle-phases leg too", async () => {
|
||||
// Pinned where the user's date and the UTC date differ (01:30 in Berlin,
|
||||
// 23:30 UTC the day before), so a leg that cuts its window on UTC days
|
||||
// asks Oura for a different range and the assertion below catches it.
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-09-26T23:30:00Z"));
|
||||
onTestFinished(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
getConnMock.mockResolvedValue(CONN);
|
||||
findFirstMock.mockResolvedValue({
|
||||
measuredAt: new Date(Date.now() - 10 * DAY_MS),
|
||||
|
||||
@@ -75,14 +75,13 @@ function isUniqueViolation(err: unknown): boolean {
|
||||
export async function syncUserOuraCyclePhases(
|
||||
userId: string,
|
||||
accessToken: string,
|
||||
lookbackDays: number,
|
||||
window: { startDate: string; endDate: string },
|
||||
): Promise<number> {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getTime() - lookbackDays * 24 * 60 * 60 * 1000);
|
||||
const records = await fetchDailyCyclePhases(accessToken, {
|
||||
startDate: start.toISOString().slice(0, 10),
|
||||
endDate: now.toISOString().slice(0, 10),
|
||||
});
|
||||
// The window is the measurement sync's own, cut on the user's calendar days.
|
||||
// Deriving it here from UTC dates made the two legs ask Oura for different
|
||||
// days whenever the user's date and the UTC date differ (every evening west
|
||||
// of UTC, every early morning east of it).
|
||||
const records = await fetchDailyCyclePhases(accessToken, window);
|
||||
if (records.length === 0) return 0;
|
||||
|
||||
const periodDays = derivePeriodDaysFromCyclePhases(records);
|
||||
|
||||
@@ -509,11 +509,14 @@ export async function syncUserOura(
|
||||
try {
|
||||
const freshConn = await getOuraConnection(userId);
|
||||
if (freshConn) {
|
||||
await syncUserOuraCyclePhases(
|
||||
userId,
|
||||
freshConn.accessToken,
|
||||
lookbackDays,
|
||||
const cycleToday = userDayKey(
|
||||
new Date(),
|
||||
await resolveUserTimezone(userId),
|
||||
);
|
||||
await syncUserOuraCyclePhases(userId, freshConn.accessToken, {
|
||||
startDate: shiftDateKey(cycleToday, -lookbackDays),
|
||||
endDate: cycleToday,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
|
||||
Reference in new issue
Block a user