From ed63a3bf32ec7bd7bb757d4da5ae91d971afed15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:56:21 +0200 Subject: [PATCH] test(workouts): pin enriched list and detail envelopes --- .../__tests__/canonical-dedup.test.ts | 36 +++++++ src/app/api/workouts/__tests__/detail.test.ts | 97 +++++++++++++++++++ 2 files changed, 133 insertions(+) diff --git a/src/app/api/workouts/__tests__/canonical-dedup.test.ts b/src/app/api/workouts/__tests__/canonical-dedup.test.ts index 5136b23f8..4fcc2c191 100644 --- a/src/app/api/workouts/__tests__/canonical-dedup.test.ts +++ b/src/app/api/workouts/__tests__/canonical-dedup.test.ts @@ -83,6 +83,7 @@ interface FakeWorkoutRow { avgHeartRate: number | null; maxHeartRate: number | null; totalEnergyKcal: number | null; + metadata: Record | null; createdAt: Date; } @@ -105,6 +106,7 @@ function makeRow( avgHeartRate: 145, maxHeartRate: 170, totalEnergyKcal: 320, + metadata: null, createdAt: start, }; } @@ -147,6 +149,40 @@ describe("GET /api/workouts — canonical dedup", () => { expect(body.data.meta.droppedDuplicates).toBe(1); }); + it("backfills missing Apple HR aggregates from its WHOOP twin", async () => { + const apple = { + ...makeRow("w-apple", "APPLE_HEALTH", "2026-05-15T07:00:00Z"), + avgHeartRate: null, + maxHeartRate: null, + metadata: { hkVersion: "1" }, + }; + const whoop = { + ...makeRow("w-whoop", "WHOOP", "2026-05-15T07:00:30Z"), + avgHeartRate: 148, + maxHeartRate: 181, + metadata: { + zoneDurations: { zone_two_milli: 120_000 }, + whoopWorkoutStrain: 12.3, + }, + }; + vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ + apple, + whoop, + ] as never); + + const res = await GET(makeRequest()); + const body = await res.json(); + + expect(body.data.workouts).toHaveLength(1); + expect(body.data.workouts[0]).toMatchObject({ + id: "w-apple", + source: "APPLE_HEALTH", + avgHr: 148, + maxHr: 181, + }); + expect(body.data.meta.droppedDuplicates).toBe(1); + }); + it("returns the most recent workouts first", async () => { // The canonical picker re-sorts its output to startedAt ASC (its rollup // contract), so the list route must re-establish newest-first AFTER the diff --git a/src/app/api/workouts/__tests__/detail.test.ts b/src/app/api/workouts/__tests__/detail.test.ts index 5730e4d17..28981227b 100644 --- a/src/app/api/workouts/__tests__/detail.test.ts +++ b/src/app/api/workouts/__tests__/detail.test.ts @@ -153,6 +153,103 @@ describe("GET /api/workouts/{id}", () => { expect(body.data.route).toBeNull(); }); + it("enriches the Apple winner with only matched WHOOP HR and zones", async () => { + const routeGeometry = { + type: "LineString", + coordinates: [ + [11.0, 49.0], + [11.01, 49.01], + ], + }; + vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({ + ...BASE_ROW, + avgHeartRate: null, + maxHeartRate: null, + metadata: { hkVersion: "1", owner: "apple" }, + route: { + id: "wr-apple", + geometry: routeGeometry, + sampleTimestamps: null, + createdAt: BASE_ROW.createdAt, + }, + } as never); + vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ + { + id: "w-1", + source: "APPLE_HEALTH", + startedAt: BASE_ROW.startedAt, + sportType: "RUNNING", + avgHeartRate: null, + maxHeartRate: null, + metadata: { hkVersion: "1", owner: "apple" }, + }, + { + id: "w-whoop", + source: "WHOOP", + startedAt: new Date(BASE_ROW.startedAt.getTime() + 30_000), + sportType: "RUNNING", + avgHeartRate: 148, + maxHeartRate: 181, + metadata: { + zoneDurations: { + zone_one_milli: 60_000, + zone_two_milli: 300_000, + }, + whoopWorkoutStrain: 12.3, + }, + }, + ] as never); + + const res = await GET(makeRequest(), makeParams("w-1")); + const body = await res.json(); + + expect(body.data).toMatchObject({ + id: "w-1", + source: "APPLE_HEALTH", + canonicalId: "w-1", + avgHr: 148, + maxHr: 181, + route: { geometry: routeGeometry }, + metadata: { hkVersion: "1", owner: "apple" }, + }); + expect(body.data.metadata).not.toHaveProperty("whoopWorkoutStrain"); + expect(body.data.metadata).not.toHaveProperty("zoneDurations"); + expect(body.data.zones.model).toBe("whoop"); + expect(body.data.hrSeries).toBeNull(); + }); + + it("does not overwrite the canonical winner's existing HR values", async () => { + vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce( + BASE_ROW as never, + ); + vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ + { + id: "w-1", + source: "APPLE_HEALTH", + startedAt: BASE_ROW.startedAt, + sportType: "RUNNING", + avgHeartRate: BASE_ROW.avgHeartRate, + maxHeartRate: BASE_ROW.maxHeartRate, + metadata: BASE_ROW.metadata, + }, + { + id: "w-whoop", + source: "WHOOP", + startedAt: new Date(BASE_ROW.startedAt.getTime() + 30_000), + sportType: "RUNNING", + avgHeartRate: 155, + maxHeartRate: 190, + metadata: null, + }, + ] as never); + + const res = await GET(makeRequest(), makeParams("w-1")); + const body = await res.json(); + + expect(body.data.avgHr).toBe(145); + expect(body.data.maxHr).toBe(170); + }); + it("returns 401 without a session", async () => { vi.mocked(getSession).mockResolvedValueOnce(null); const res = await GET(makeRequest(), makeParams("w-1"));