From aa850acab8d9f4cbc317f3f1ed253a2b7ede2d0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 24 Jul 2026 10:10:05 +0200 Subject: [PATCH] test(polar): cover exercise sync, mapper parity, and the shared-ledger legs Parity suite proves a Polar exercise and an equivalent Strava activity map to a downstream-indistinguishable Workout row (sport bucket, start instant, duration, distance, calories, HR, null-vs-zero). Regression guard asserts the exercises request hits GET /v3/exercises with no userId path segment. Dedup ladder tests mirror the Strava suite (overwrite-in-place, Apple+Polar twin collapses, Polar above Strava). Composite test proves a failure in one Polar leg leaves the other leg's result intact on the shared ledger. Defensive-shape tests cover the four flagged assumptions: numeric-vs-string id, UTC-offset composition (positive and negative), bare-array-vs-paged body, and a 403 scope error as a soft error. Refs #568 --- .../__tests__/polar-sync-legs.test.ts | 52 ++++ .../polar/__tests__/client-exercises.test.ts | 260 ++++++++++++++++++ src/lib/polar/__tests__/dedup.test.ts | 87 ++++++ src/lib/polar/__tests__/parity.test.ts | 102 +++++++ src/lib/polar/__tests__/sport-map.test.ts | 67 +++++ src/lib/polar/__tests__/sync-workouts.test.ts | 214 ++++++++++++++ src/lib/polar/sport-map.ts | 1 - src/lib/polar/sync-workouts.ts | 10 +- 8 files changed, 788 insertions(+), 5 deletions(-) create mode 100644 src/lib/jobs/reminder/__tests__/polar-sync-legs.test.ts create mode 100644 src/lib/polar/__tests__/client-exercises.test.ts create mode 100644 src/lib/polar/__tests__/dedup.test.ts create mode 100644 src/lib/polar/__tests__/parity.test.ts create mode 100644 src/lib/polar/__tests__/sport-map.test.ts create mode 100644 src/lib/polar/__tests__/sync-workouts.test.ts diff --git a/src/lib/jobs/reminder/__tests__/polar-sync-legs.test.ts b/src/lib/jobs/reminder/__tests__/polar-sync-legs.test.ts new file mode 100644 index 000000000..65ab44cd4 --- /dev/null +++ b/src/lib/jobs/reminder/__tests__/polar-sync-legs.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { syncUserPolar, syncUserPolarWorkouts } = vi.hoisted(() => ({ + syncUserPolar: vi.fn(), + syncUserPolarWorkouts: vi.fn(), +})); + +vi.mock("@/lib/polar/sync", () => ({ syncUserPolar })); +vi.mock("@/lib/polar/sync-workouts", () => ({ syncUserPolarWorkouts })); + +import { syncUserPolarLegs } from "../polar-sync"; + +beforeEach(() => { + vi.clearAllMocks(); + syncUserPolar.mockResolvedValue(0); + syncUserPolarWorkouts.mockResolvedValue(0); +}); + +describe("syncUserPolarLegs — composite independence on the shared polar ledger", () => { + it("runs both legs and returns the summed count when both pass", async () => { + syncUserPolar.mockResolvedValue(5); + syncUserPolarWorkouts.mockResolvedValue(2); + await expect(syncUserPolarLegs("u1")).resolves.toBe(7); + expect(syncUserPolar).toHaveBeenCalledWith("u1"); + expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1"); + }); + + it("still attempts the workout leg when the vitals leg fails (vice-versa isolation)", async () => { + syncUserPolar.mockRejectedValue(new Error("vitals down")); + syncUserPolarWorkouts.mockResolvedValue(3); + // The vitals error is rethrown (attributable), but only AFTER the workout + // leg was attempted — a vitals failure never starves the workout leg. + await expect(syncUserPolarLegs("u1")).rejects.toThrow("vitals down"); + expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1"); + }); + + it("leaves the vitals leg's result intact when the workout leg fails", async () => { + syncUserPolar.mockResolvedValue(4); + syncUserPolarWorkouts.mockRejectedValue(new Error("workouts down")); + // The vitals leg ran to completion (its own success already recorded on + // the shared ledger); the workout error is surfaced without undoing it. + await expect(syncUserPolarLegs("u1")).rejects.toThrow("workouts down"); + expect(syncUserPolar).toHaveBeenCalledWith("u1"); + }); + + it("rethrows the FIRST (vitals) error when both legs fail", async () => { + syncUserPolar.mockRejectedValue(new Error("vitals first")); + syncUserPolarWorkouts.mockRejectedValue(new Error("workouts second")); + await expect(syncUserPolarLegs("u1")).rejects.toThrow("vitals first"); + expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1"); + }); +}); diff --git a/src/lib/polar/__tests__/client-exercises.test.ts b/src/lib/polar/__tests__/client-exercises.test.ts new file mode 100644 index 000000000..71d9ed423 --- /dev/null +++ b/src/lib/polar/__tests__/client-exercises.test.ts @@ -0,0 +1,260 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + combinePolarStart, + fetchExercises, + mapExercise, + normaliseExerciseList, + parseIsoDuration, + type PolarExercise, +} from "../client"; +import { PolarApiError } from "../response-classifier"; + +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; +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("fetchExercises — request path", () => { + // #568 regression guard: the exercises collection GET carries NO userId in + // the path (the user is identified by the Bearer token), exactly the shape + // the v1.31.2 vitals-endpoint fix established. Assert the URL literally. + it("hits GET /v3/exercises with Bearer auth and no userId in the path", async () => { + const fetchMock = installFetchMock([{ status: 204 }]); + await fetchExercises("tok"); + expect(fetchMock).toHaveBeenCalledWith( + "https://www.polaraccesslink.com/v3/exercises", + expect.objectContaining({ method: "GET" }), + ); + const url = fetchMock.mock.calls[0]![0]; + expect(url).toBe("https://www.polaraccesslink.com/v3/exercises"); + expect(url).not.toContain("/users/"); + const [, init] = fetchMock.mock.calls[0]!; + const headers = init!.headers as Record; + expect(headers.Authorization).toBe("Bearer tok"); + }); + + it("returns [] on a 204 No Content", async () => { + installFetchMock([{ status: 204 }]); + expect(await fetchExercises("tok")).toEqual([]); + }); + + it("returns [] on a non-JSON 200 body", async () => { + installFetchMock([{ status: 200, body: null }]); + expect(await fetchExercises("tok")).toEqual([]); + }); + + it("classifies a 401 as a reauth-required PolarApiError", async () => { + installFetchMock([{ status: 401 }]); + await expect(fetchExercises("tok")).rejects.toMatchObject({ + classification: "reauth_required", + httpStatus: 401, + }); + }); + + it("classifies a 5xx as transient", async () => { + installFetchMock([{ status: 503 }]); + await expect(fetchExercises("tok")).rejects.toMatchObject({ + classification: "transient", + }); + }); + + // Assumption #4: a scope gap on exercises surfaces as a 403, which the shared + // classifier lifts to reauth_required — a soft/recoverable integration error + // the workout sync records rather than hard-crashing the whole Polar sync. + it("classifies a 403 scope error as a soft reauth-required error, not a crash", async () => { + installFetchMock([{ status: 403 }]); + const err = await fetchExercises("tok").catch((e) => e); + expect(err).toBeInstanceOf(PolarApiError); + expect(err.classification).toBe("reauth_required"); + expect(err.httpStatus).toBe(403); + }); +}); + +describe("normaliseExerciseList — pagination-absence tolerance (assumption #3)", () => { + const one = { id: 1 }; + it("accepts a bare array", () => { + expect(normaliseExerciseList([one])).toEqual([one]); + }); + it("accepts a paged envelope under `exercises`", () => { + expect(normaliseExerciseList({ exercises: [one], page: 1 })).toEqual([one]); + }); + it("accepts alternate envelope keys (data / items)", () => { + expect(normaliseExerciseList({ data: [one] })).toEqual([one]); + expect(normaliseExerciseList({ items: [one] })).toEqual([one]); + }); + it("returns [] for an unrecognised shape", () => { + expect(normaliseExerciseList({ nope: [one] })).toEqual([]); + expect(normaliseExerciseList(null)).toEqual([]); + expect(normaliseExerciseList(42)).toEqual([]); + }); +}); + +describe("fetchExercises — body shape (assumption #3)", () => { + it("unwraps a paged { exercises: [...] } envelope", async () => { + installFetchMock([{ status: 200, body: { exercises: [{ id: "a" }] } }]); + const r = await fetchExercises("tok"); + expect(r).toHaveLength(1); + expect(r[0]!.id).toBe("a"); + }); + it("returns the bare top-level array", async () => { + installFetchMock([{ status: 200, body: [{ id: "a" }, { id: "b" }] }]); + expect(await fetchExercises("tok")).toHaveLength(2); + }); +}); + +describe("parseIsoDuration", () => { + it("parses hours/minutes/seconds", () => { + expect(parseIsoDuration("PT2H44M30S")).toBe(2 * 3600 + 44 * 60 + 30); + expect(parseIsoDuration("PT45M")).toBe(2700); + expect(parseIsoDuration("PT30S")).toBe(30); + expect(parseIsoDuration("PT1H")).toBe(3600); + }); + it("parses fractional seconds and days", () => { + expect(parseIsoDuration("PT1M2.5S")).toBe(63); // rounded + expect(parseIsoDuration("P1DT2H")).toBe(86400 + 7200); + }); + it("returns 0 for malformed / empty input", () => { + expect(parseIsoDuration("nonsense")).toBe(0); + expect(parseIsoDuration("")).toBe(0); + expect(parseIsoDuration(null)).toBe(0); + expect(parseIsoDuration(undefined)).toBe(0); + }); +}); + +describe("combinePolarStart — UTC-offset composition (assumption #2)", () => { + it("applies a POSITIVE offset to a naive local wall-clock", () => { + // 07:00 local at UTC+2 → 05:00 UTC. + const d = combinePolarStart("2026-06-10T07:00:00", 120)!; + expect(d.toISOString()).toBe("2026-06-10T05:00:00.000Z"); + }); + it("applies a NEGATIVE offset to a naive local wall-clock", () => { + // 07:00 local at UTC-5 → 12:00 UTC. + const d = combinePolarStart("2026-06-10T07:00:00", -300)!; + expect(d.toISOString()).toBe("2026-06-10T12:00:00.000Z"); + }); + it("treats a missing offset as UTC wall-clock", () => { + const d = combinePolarStart("2026-06-10T07:00:00", null)!; + expect(d.toISOString()).toBe("2026-06-10T07:00:00.000Z"); + }); + it("uses a zone-qualified start directly and does NOT re-apply the offset", () => { + // Already carries +02:00 → 05:00 UTC; the offset field must be ignored. + const d = combinePolarStart("2026-06-10T07:00:00+02:00", 120)!; + expect(d.toISOString()).toBe("2026-06-10T05:00:00.000Z"); + }); + it("handles a trailing-Z start directly", () => { + const d = combinePolarStart("2026-06-10T07:00:00Z", 120)!; + expect(d.toISOString()).toBe("2026-06-10T07:00:00.000Z"); + }); + it("returns null for a missing / unparseable start", () => { + expect(combinePolarStart(null, 0)).toBeNull(); + expect(combinePolarStart("", 0)).toBeNull(); + expect(combinePolarStart("not-a-date", 0)).toBeNull(); + }); +}); + +describe("mapExercise", () => { + const full: PolarExercise = { + id: 987654321, + start_time: "2026-06-10T07:00:00", + start_time_utc_offset: 120, + duration: "PT45M", + sport: "RUNNING", + detailed_sport_info: "TRAIL_RUNNING", + calories: 512, + distance: 9200.5, + heart_rate: { average: 148, maximum: 172 }, + training_load: 145.2, + device: "Polar Vantage", + device_id: "dev-1", + }; + + it("maps a full exercise into the exact Workout row shape", () => { + const row = mapExercise(full)!; + expect(row.externalId).toBe("987654321"); + expect(row.sportType).toBe("running"); + expect(row.startedAt.toISOString()).toBe("2026-06-10T05:00:00.000Z"); + expect(row.durationSec).toBe(2700); + expect(row.endedAt.toISOString()).toBe("2026-06-10T05:45:00.000Z"); + expect(row.totalEnergyKcal).toBe(512); + expect(row.totalDistanceM).toBe(9200.5); + expect(row.avgHeartRate).toBe(148); + expect(row.maxHeartRate).toBe(172); + expect(row.elevationM).toBeNull(); + expect(row.metadata).toMatchObject({ + polarSport: "RUNNING", + polarDetailedSportInfo: "TRAIL_RUNNING", + polarDevice: "Polar Vantage", + polarDeviceId: "dev-1", + trainingLoad: 145.2, + startTimeUtcOffset: 120, + }); + }); + + // Assumption #1: the id may arrive as a string OR a number — coerce both to a + // stable string externalId, never parse it. + it("coerces a numeric id and a string id to the same stable externalId", () => { + expect(mapExercise({ ...full, id: 42 })!.externalId).toBe("42"); + expect(mapExercise({ ...full, id: "42" })!.externalId).toBe("42"); + expect(mapExercise({ ...full, id: "abc-123" })!.externalId).toBe("abc-123"); + }); + + it("nulls optional numeric fields when absent", () => { + const row = mapExercise({ + id: "x", + start_time: "2026-06-10T07:00:00", + start_time_utc_offset: 0, + duration: "PT30M", + sport: "STRENGTH_TRAINING", + })!; + expect(row.totalEnergyKcal).toBeNull(); + expect(row.totalDistanceM).toBeNull(); + expect(row.avgHeartRate).toBeNull(); + expect(row.maxHeartRate).toBeNull(); + expect(row.sportType).toBe("strength"); + }); + + it("treats a malformed duration as 0 and never lands endedAt before startedAt", () => { + const row = mapExercise({ ...full, duration: "garbage" })!; + expect(row.durationSec).toBe(0); + expect(row.endedAt.getTime()).toBe(row.startedAt.getTime()); + }); + + it("skips an exercise with no id", () => { + expect(mapExercise({ ...full, id: null })).toBeNull(); + expect(mapExercise({ ...full, id: undefined })).toBeNull(); + }); + + it("skips an exercise with an unparseable start", () => { + expect(mapExercise({ ...full, start_time: "nope" })).toBeNull(); + expect(mapExercise({ ...full, start_time: null })).toBeNull(); + }); + + it("drops negative numerics to null (non-negative guard)", () => { + const row = mapExercise({ + ...full, + calories: -5, + distance: -1, + heart_rate: { average: -1, maximum: -1 }, + })!; + expect(row.totalEnergyKcal).toBeNull(); + expect(row.totalDistanceM).toBeNull(); + expect(row.avgHeartRate).toBeNull(); + expect(row.maxHeartRate).toBeNull(); + }); +}); diff --git a/src/lib/polar/__tests__/dedup.test.ts b/src/lib/polar/__tests__/dedup.test.ts new file mode 100644 index 000000000..0352b40be --- /dev/null +++ b/src/lib/polar/__tests__/dedup.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; + +import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; +import { DEFAULT_WORKOUT_SOURCE_PRIORITY } from "@/lib/sources/pick-canonical-workout"; +import { DEFAULT_SOURCE_PRIORITY } from "@/lib/validations/source-priority"; + +/** + * v1.32.15 — Polar rides the SAME source-agnostic workout dedup engine as every + * other source: placing POLAR on the ladders is all that is needed for an + * Apple-Watch + Polar twin of one run to collapse to a single canonical row. + * No Polar-specific dedup path exists (nor should it). + */ +const D = (iso: string) => new Date(iso); + +describe("POLAR workout dedup — read-time canonical picker", () => { + it("collapses an Apple-Health + Polar twin of the same run to the device row", () => { + const rows = [ + { + startedAt: D("2026-07-01T06:30:00Z"), + sportType: "running", + source: "APPLE_HEALTH" as const, + }, + // Same run captured by the Polar watch a minute later — same sport, same + // 5-minute slot. + { + startedAt: D("2026-07-01T06:31:00Z"), + sportType: "running", + source: "POLAR" as const, + }, + ]; + const canonical = pickCanonicalWorkoutRows(rows); + expect(canonical).toHaveLength(1); + // Apple Health (phone-aggregated) outranks Polar in the steps ladder. + expect(canonical[0].source).toBe("APPLE_HEALTH"); + }); + + it("keeps a Polar-only run (no competing source) untouched", () => { + const rows = [ + { + startedAt: D("2026-07-02T18:00:00Z"), + sportType: "cycling", + source: "POLAR" as const, + }, + ]; + const canonical = pickCanonicalWorkoutRows(rows); + expect(canonical).toHaveLength(1); + expect(canonical[0].source).toBe("POLAR"); + }); + + it("ranks POLAR above STRAVA when both cover the same workout", () => { + const rows = [ + { + startedAt: D("2026-07-03T07:00:30Z"), + sportType: "running", + source: "STRAVA" as const, + }, + { + startedAt: D("2026-07-03T07:00:00Z"), + sportType: "running", + source: "POLAR" as const, + }, + ]; + const canonical = pickCanonicalWorkoutRows(rows); + expect(canonical).toHaveLength(1); + // Polar is a device-native HR capture; Strava is often a re-upload. + expect(canonical[0].source).toBe("POLAR"); + }); +}); + +describe("POLAR sits in the workout source ladders", () => { + it("is present in the workout default ladder, below WITHINGS, above STRAVA", () => { + const polar = DEFAULT_WORKOUT_SOURCE_PRIORITY.indexOf("POLAR"); + const withings = DEFAULT_WORKOUT_SOURCE_PRIORITY.indexOf("WITHINGS"); + const strava = DEFAULT_WORKOUT_SOURCE_PRIORITY.indexOf("STRAVA"); + expect(polar).toBeGreaterThan(withings); + expect(polar).toBeLessThan(strava); + }); + + it("is present in the `steps` ladder the read picker resolves against, above STRAVA", () => { + const steps = DEFAULT_SOURCE_PRIORITY.steps; + const polar = steps.indexOf("POLAR"); + const apple = steps.indexOf("APPLE_HEALTH"); + const strava = steps.indexOf("STRAVA"); + expect(polar).toBeGreaterThan(apple); + expect(polar).toBeLessThan(strava); + }); +}); diff --git a/src/lib/polar/__tests__/parity.test.ts b/src/lib/polar/__tests__/parity.test.ts new file mode 100644 index 000000000..4dbae7f36 --- /dev/null +++ b/src/lib/polar/__tests__/parity.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; + +import { mapExercise, type PolarExercise } from "../client"; +import { + mapActivity as mapStravaActivity, + type StravaSummaryActivity, + type StravaDetailedActivity, +} from "@/lib/strava/client"; + +/** + * Sensitive-cohort parity guard (mirrors the google-parity precedent): the SAME + * logical workout fed through the Polar mapper and the Strava mapper must land + * as a downstream-indistinguishable `Workout` row — same canonical sport + * bucket, same units (metres / kcal / seconds), same HR, same start instant, + * same null-vs-zero conventions. This pins the contract "a Polar row is + * indistinguishable downstream from any other provider's row". + * + * The logical workout: a 45-minute road run starting 2026-07-01T06:30:00Z, + * 10 000 m, 500 kcal, avg HR 150, max HR 175. + */ +const START_ISO = "2026-07-01T06:30:00Z"; +const DURATION_SEC = 45 * 60; +const DISTANCE_M = 10_000; +const CALORIES = 500; +const AVG_HR = 150; +const MAX_HR = 175; + +const polar: PolarExercise = { + id: 12345, + // Naive local wall-clock + zero offset === the same UTC instant as START_ISO. + start_time: "2026-07-01T06:30:00", + start_time_utc_offset: 0, + duration: "PT45M", + sport: "RUNNING", + detailed_sport_info: "ROAD_RUNNING", + calories: CALORIES, + distance: DISTANCE_M, + heart_rate: { average: AVG_HR, maximum: MAX_HR }, +}; + +const stravaSummary: StravaSummaryActivity = { + id: 67890, + sport_type: "Run", + start_date: START_ISO, + moving_time: DURATION_SEC, + elapsed_time: DURATION_SEC, + distance: DISTANCE_M, + average_heartrate: AVG_HR, + max_heartrate: MAX_HR, +}; +const stravaDetail: StravaDetailedActivity = { + ...stravaSummary, + calories: CALORIES, +}; + +describe("Polar ↔ Strava mapper parity", () => { + const p = mapExercise(polar)!; + const s = mapStravaActivity(stravaSummary, stravaDetail)!; + + it("produces the same canonical sport bucket", () => { + expect(p.sportType).toBe("running"); + expect(p.sportType).toBe(s.sportType); + }); + + it("produces the same start instant", () => { + expect(p.startedAt.getTime()).toBe(Date.parse(START_ISO)); + expect(p.startedAt.getTime()).toBe(s.startedAt.getTime()); + }); + + it("produces the same duration in seconds and a non-inverted window", () => { + expect(p.durationSec).toBe(DURATION_SEC); + expect(p.durationSec).toBe(s.durationSec); + expect(p.endedAt.getTime()).toBeGreaterThanOrEqual(p.startedAt.getTime()); + expect(s.endedAt.getTime()).toBeGreaterThanOrEqual(s.startedAt.getTime()); + }); + + it("produces the same distance in metres", () => { + expect(p.totalDistanceM).toBe(DISTANCE_M); + expect(p.totalDistanceM).toBe(s.totalDistanceM); + }); + + it("produces the same energy in kcal", () => { + expect(p.totalEnergyKcal).toBe(CALORIES); + expect(p.totalEnergyKcal).toBe(s.totalEnergyKcal); + }); + + it("produces the same heart-rate fields", () => { + expect(p.avgHeartRate).toBe(AVG_HR); + expect(p.maxHeartRate).toBe(MAX_HR); + expect(p.avgHeartRate).toBe(s.avgHeartRate); + expect(p.maxHeartRate).toBe(s.maxHeartRate); + }); + + it("shares the null-vs-absent convention for a field neither summary carries", () => { + // Polar has no elevation on the exercise summary → null; a Strava activity + // with no elevation gain also maps elevation to null. Both use `null`, not + // 0, for "not present". + expect(p.elevationM).toBeNull(); + const sNoElev = mapStravaActivity(stravaSummary, stravaDetail)!; + expect(sNoElev.elevationM).toBeNull(); + }); +}); diff --git a/src/lib/polar/__tests__/sport-map.test.ts b/src/lib/polar/__tests__/sport-map.test.ts new file mode 100644 index 000000000..2d3bb61bc --- /dev/null +++ b/src/lib/polar/__tests__/sport-map.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import { + POLAR_SPORT_TABLE, + mapPolarSportType, + normalisePolarSportKey, +} from "../sport-map"; +import { workoutSportTypeEnum } from "@/lib/validations/workout"; + +describe("POLAR_SPORT_TABLE", () => { + it("maps every table entry into a canonical WorkoutSportType", () => { + const valid = new Set(workoutSportTypeEnum.options as readonly string[]); + for (const row of POLAR_SPORT_TABLE) { + expect(valid.has(row.canonical)).toBe(true); + } + }); + + it("has no duplicate normalised keys", () => { + const keys = POLAR_SPORT_TABLE.map((r) => normalisePolarSportKey(r.name)); + expect(new Set(keys).size).toBe(keys.length); + }); +}); + +describe("mapPolarSportType", () => { + it("prefers detailed_sport_info over the coarse sport", () => { + // Coarse says cycling, detailed says trail running — detailed wins. + expect(mapPolarSportType("TRAIL_RUNNING", "CYCLING")).toBe("running"); + }); + + it("falls back to the coarse sport when detailed is absent", () => { + expect(mapPolarSportType(null, "CYCLING")).toBe("cycling"); + expect(mapPolarSportType(undefined, "SWIMMING")).toBe("swimming"); + expect(mapPolarSportType("", "STRENGTH_TRAINING")).toBe("strength"); + }); + + it("falls back to the coarse sport when detailed is unrecognised", () => { + // An unknown granular value still gets a chance at the coarse mapping. + expect(mapPolarSportType("SOME_NEW_POLAR_SPORT", "RUNNING")).toBe( + "running", + ); + }); + + it("defaults to other for an unknown pair", () => { + expect(mapPolarSportType("NOPE", "ALSO_NOPE")).toBe("other"); + expect(mapPolarSportType(null, null)).toBe("other"); + expect(mapPolarSportType(undefined, undefined)).toBe("other"); + }); + + it("applies the documented bucket judgment calls consistently with Strava", () => { + // Paddle → rowing, racket → tennis, functional/climbing → crossTraining, + // mobility → mindAndBody, e-bike → cycling, snow → other. + expect(mapPolarSportType("KAYAKING", null)).toBe("rowing"); + expect(mapPolarSportType("PADEL", null)).toBe("tennis"); + expect(mapPolarSportType("FUNCTIONAL_TRAINING", null)).toBe( + "crossTraining", + ); + expect(mapPolarSportType("CLIMBING", null)).toBe("crossTraining"); + expect(mapPolarSportType("PILATES", null)).toBe("mindAndBody"); + expect(mapPolarSportType("E_BIKE", null)).toBe("cycling"); + expect(mapPolarSportType("ALPINE_SKIING", null)).toBe("other"); + }); + + it("is resilient to casing / separator variants", () => { + expect(mapPolarSportType("trail running", null)).toBe("running"); + expect(mapPolarSportType("Trail-Running", null)).toBe("running"); + }); +}); diff --git a/src/lib/polar/__tests__/sync-workouts.test.ts b/src/lib/polar/__tests__/sync-workouts.test.ts new file mode 100644 index 000000000..12b6d3fd1 --- /dev/null +++ b/src/lib/polar/__tests__/sync-workouts.test.ts @@ -0,0 +1,214 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { PolarWorkoutRow } from "../client"; + +const { + createManyAndReturn, + update, + emitInsertedWorkoutArrival, + getPolarConnection, + fetchExercises, + recordSyncFailure, +} = vi.hoisted(() => ({ + createManyAndReturn: vi.fn(), + update: vi.fn(), + emitInsertedWorkoutArrival: vi.fn(async () => {}), + getPolarConnection: vi.fn(), + fetchExercises: vi.fn(), + recordSyncFailure: vi.fn(async () => {}), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { workout: { createManyAndReturn, update } }, +})); +vi.mock("@/lib/arrivals/workout-emit", () => ({ + emitInsertedWorkoutArrival, +})); +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: () => ({ addWarning: vi.fn(), addExternalCall: vi.fn() }), +})); +vi.mock("../credentials", () => ({ getPolarConnection })); +vi.mock("@/lib/integrations/status", async (importOriginal) => ({ + ...(await importOriginal()), + recordSyncFailure, +})); +vi.mock("../client", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, fetchExercises }; +}); + +import { syncUserPolarWorkouts, upsertPolarWorkouts } from "../sync-workouts"; +import { PolarApiError } from "../response-classifier"; + +const CONN = { accessToken: "tok", polarUserId: "42" }; + +const startedAt = new Date("2026-07-19T08:00:00.000Z"); +const row: PolarWorkoutRow = { + externalId: "polar-1", + sportType: "running", + startedAt, + endedAt: new Date("2026-07-19T09:00:00.000Z"), + durationSec: 3600, + totalEnergyKcal: 500, + totalDistanceM: 10_000, + avgHeartRate: 145, + maxHeartRate: 170, + elevationM: null, + metadata: {}, +}; + +beforeEach(() => { + vi.clearAllMocks(); + update.mockResolvedValue({ id: "existing" }); + getPolarConnection.mockResolvedValue(CONN); + fetchExercises.mockResolvedValue([]); +}); + +describe("upsertPolarWorkouts — exact inserted identity", () => { + it("emits the exact row returned by the insert statement, tagged 'polar'", async () => { + const inserted = { id: "new-workout", startedAt }; + createManyAndReturn.mockResolvedValue([inserted]); + + await expect(upsertPolarWorkouts("user-1", [row])).resolves.toBe(1); + + expect(emitInsertedWorkoutArrival).toHaveBeenCalledWith( + "user-1", + inserted, + "polar", + ); + expect(update).not.toHaveBeenCalled(); + }); + + it("updates a duplicate (userId, POLAR, externalId) in place without emitting", async () => { + createManyAndReturn.mockResolvedValue([]); + + await expect(upsertPolarWorkouts("user-1", [row])).resolves.toBe(1); + + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + userId_source_externalId: { + userId: "user-1", + source: "POLAR", + externalId: "polar-1", + }, + }, + data: expect.objectContaining({ sportType: "running", startedAt }), + }), + ); + expect(emitInsertedWorkoutArrival).not.toHaveBeenCalled(); + }); + + it("logs a failing row, continues the batch, and rethrows the FIRST error", async () => { + const second = { ...row, externalId: "polar-2" }; + createManyAndReturn + .mockRejectedValueOnce(new Error("boom-1")) + .mockResolvedValueOnce([{ id: "new-2", startedAt }]); + + await expect(upsertPolarWorkouts("user-1", [row, second])).rejects.toThrow( + "boom-1", + ); + // The second row was still attempted after the first failed. + expect(createManyAndReturn).toHaveBeenCalledTimes(2); + }); + + it("no-ops on an empty batch", async () => { + await expect(upsertPolarWorkouts("user-1", [])).resolves.toBe(0); + expect(createManyAndReturn).not.toHaveBeenCalled(); + }); +}); + +describe("syncUserPolarWorkouts", () => { + it("no-ops cleanly for an unconnected user", async () => { + getPolarConnection.mockResolvedValue(null); + await expect(syncUserPolarWorkouts("u1")).resolves.toBe(0); + expect(fetchExercises).not.toHaveBeenCalled(); + expect(createManyAndReturn).not.toHaveBeenCalled(); + }); + + it("fetches, maps, and upserts exercises as source POLAR", async () => { + fetchExercises.mockResolvedValue([ + { + id: 987, + start_time: "2026-07-19T10:00:00", + start_time_utc_offset: 0, + duration: "PT1H", + sport: "RUNNING", + detailed_sport_info: "ROAD_RUNNING", + calories: 500, + distance: 10000, + heart_rate: { average: 145, maximum: 170 }, + }, + ]); + createManyAndReturn.mockResolvedValue([{ id: "w1", startedAt }]); + + await expect(syncUserPolarWorkouts("u1")).resolves.toBe(1); + const arg = createManyAndReturn.mock.calls[0]![0]; + expect(arg.data.source).toBe("POLAR"); + expect(arg.data.externalId).toBe("987"); + expect(arg.data.sportType).toBe("running"); + }); + + it("skips an unmappable exercise without throwing", async () => { + fetchExercises.mockResolvedValue([{ id: null }, { id: "x" }]); + createManyAndReturn.mockResolvedValue([{ id: "w", startedAt }]); + // Only the valid one is upserted; the id-less one is skipped. + // The valid row has no start_time → also skipped, so nothing is written. + await expect(syncUserPolarWorkouts("u1")).resolves.toBe(0); + expect(createManyAndReturn).not.toHaveBeenCalled(); + }); + + it("records a failure on a fetch error and rethrows, never masking with success", async () => { + fetchExercises.mockRejectedValue( + new PolarApiError({ + verb: "fetchExercises", + classification: "transient", + httpStatus: 503, + reason: "http_503", + }), + ); + await expect(syncUserPolarWorkouts("u1")).rejects.toBeInstanceOf( + PolarApiError, + ); + expect(recordSyncFailure).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "u1", + integration: "polar", + kind: "transient", + errorCode: "503", + }), + ); + }); + + // Assumption #4: a 403 scope gap is a soft/recoverable ledger error, not a + // hard crash — recorded as reauth_required and rethrown for cohort isolation. + it("records a 403 scope error as a soft reauth_required failure", async () => { + fetchExercises.mockRejectedValue( + new PolarApiError({ + verb: "fetchExercises", + classification: "reauth_required", + httpStatus: 403, + reason: "http_403", + }), + ); + await expect(syncUserPolarWorkouts("u1")).rejects.toBeInstanceOf( + PolarApiError, + ); + expect(recordSyncFailure).toHaveBeenCalledWith( + expect.objectContaining({ + integration: "polar", + kind: "reauth_required", + errorCode: "403", + }), + ); + }); + + it("never records success on the shared ledger (vitals leg owns the single success write)", async () => { + const status = await import("@/lib/integrations/status"); + const spy = vi.spyOn(status, "recordSyncSuccess"); + fetchExercises.mockResolvedValue([]); + await syncUserPolarWorkouts("u1"); + expect(spy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/polar/sport-map.ts b/src/lib/polar/sport-map.ts index 6fccb39e9..aee8b68ee 100644 --- a/src/lib/polar/sport-map.ts +++ b/src/lib/polar/sport-map.ts @@ -73,7 +73,6 @@ export const POLAR_SPORT_TABLE: readonly PolarSportTableRow[] = [ { name: "MOUNTAIN_BIKING", canonical: "cycling" }, { name: "GRAVEL_CYCLING", canonical: "cycling" }, { name: "E_BIKE", canonical: "cycling" }, - { name: "EBIKE", canonical: "cycling" }, { name: "HANDCYCLING", canonical: "cycling" }, // ── walking ── { name: "WALKING", canonical: "walking" }, diff --git a/src/lib/polar/sync-workouts.ts b/src/lib/polar/sync-workouts.ts index 5e6b6a887..5893e31d8 100644 --- a/src/lib/polar/sync-workouts.ts +++ b/src/lib/polar/sync-workouts.ts @@ -31,11 +31,10 @@ import { prisma } from "@/lib/db"; import { emitInsertedWorkoutArrival } from "@/lib/arrivals/workout-emit"; import { annotate, getEvent } from "@/lib/logging/context"; -import { recordSyncFailure } from "@/lib/integrations/status"; +import { recordSyncFailure, toFailureKind } from "@/lib/integrations/status"; import { fetchExercises, mapExercise, type PolarWorkoutRow } from "./client"; import { getPolarConnection } from "./credentials"; -import { PolarApiError } from "./response-classifier"; -import { classifyPolarFailure } from "./sync"; +import { PolarApiError, classifyPolarError } from "./response-classifier"; async function recordPolarWorkoutFailure( userId: string, @@ -44,7 +43,10 @@ async function recordPolarWorkoutFailure( await recordSyncFailure({ userId, integration: "polar", - kind: classifyPolarFailure(err), + // Same classification the vitals leg uses (`classifyPolarFailure` in + // `./sync`); resolved directly off the shared classifier so this leg does + // not have to import the heavier vitals-sync module. + kind: toFailureKind(classifyPolarError(err)), message: err instanceof Error ? err.message : String(err), errorCode: err instanceof PolarApiError && err.httpStatus != null