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
This commit is contained in:
Marc-André Bombeck committed 2026-07-24 10:10:05 +02:00
1 parent 8bb0719c3a
commit aa850acab8
8 files changed
+788 -5

No files matched your search

@@ -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");
});
});
@@ -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<string, string>;
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();
});
});
+87
View File
@@ -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);
});
});
+102
View File
@@ -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();
});
});
+67
View File
@@ -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");
});
});
@@ -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<typeof import("@/lib/integrations/status")>()),
recordSyncFailure,
}));
vi.mock("../client", async (importOriginal) => {
const actual = await importOriginal<typeof import("../client")>();
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();
});
});
-1
View File
@@ -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" },
+6 -4
View File
@@ -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