test(workouts): pin sport provenance across the provider ingests

Behavioural cases first: the Google mapper keeps the raw `exerciseType`
when the sport falls back to "other", reports a missing one as null
rather than an empty string, and the ingest writes it under
`metadata.googleExerciseType` on both the insert and the update arm — the
update is what a full re-sync goes through, so a row written by an older
build repairs in place. The key is omitted rather than nulled when a
session carries no type, so a re-sync cannot wipe what an earlier one
recorded.

Then the structural guard, because the behavioural ones only cover the
provider that was reported. It freezes the set of sport-label mappers and
the set of files creating Workout rows, and asserts every mapping ingest
carries its raw label into metadata. Fitbit has the same gap and is
excluded on purpose — it ships on its own, small and isolated, per the
standing rule for that provider. The exception is an assertion, not an
omission: when Fitbit gains the write, the guard fails and forces the
entry to move. An exception nobody has to retire is how this gap survived
in the first place.
This commit is contained in:
Marc-André Bombeck committed 2026-07-27 22:45:54 +02:00
1 parent 3db4c33f49
commit 39aaef27b1
3 files changed
+348

No files matched your search

@@ -0,0 +1,252 @@
/**
* Structural guards on workout sport provenance.
*
* Every provider labels a workout in its own vocabulary, and every ingest
* collapses that label onto the canonical `WorkoutSportType` set. The
* collapse is lossy at both ends: an unmapped label lands on `"other"`, and
* `"other"` read back cannot be told apart from a label that genuinely meant
* "other". The rule that keeps it recoverable is one line long — a
* server-side ingest that maps a raw sport label writes that label verbatim
* to `Workout.metadata`.
*
* Google Health broke that rule silently for three releases while the
* comment above its type map asserted the opposite, and a user's workout
* showed as "Other" with the original nowhere in the database. Three sibling
* providers had carried the provenance write the whole time. Nothing
* connected them, so nothing noticed.
*
* These are tripwires, not proofs. A grep cannot show that a metadata key
* holds the right value — only that a registered ingest still writes one, and
* that a new ingest cannot appear without someone editing this file.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join, sep } from "node:path";
import { globSync } from "node:fs";
const SRC = join(process.cwd(), "src");
/** Every non-test `.ts` / `.tsx` under `src/`, minus the generated client. */
function sourceFiles(): string[] {
return globSync("**/*.{ts,tsx}", { cwd: SRC })
.filter(
(p) => !p.startsWith(`generated${sep}`) && !p.startsWith("generated/"),
)
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
.map((p) => p.split(sep).join("/"))
.sort();
}
function read(rel: string): string {
return readFileSync(join(SRC, rel), "utf8");
}
interface ProviderIngest {
/** Human name, for the failure message. */
provider: string;
/** The exported function that resolves the upstream label. */
mapper: string;
/** File declaring the mapper. */
mapperFile: string;
/** File where the raw label is captured alongside the mapped one. */
captureFile: string;
/** Identifier or literal that carries the raw label out of the capture. */
captureField: string;
/** File that builds the `Workout` row written to Postgres. */
writeFile: string;
/** The `metadata` key the raw label is stored under. */
metadataKey: string;
}
/**
* Every server-side workout ingest that maps a raw sport label onto the
* canonical enum, with the provenance write that makes the mapping
* reversible. Adding a provider means adding a row here.
*/
const PROVIDER_INGESTS: ProviderIngest[] = [
{
provider: "Google Health",
mapper: "mapGoogleHealthSportType",
mapperFile: "lib/google-health/mappers.ts",
captureFile: "lib/google-health/mappers.ts",
captureField: "sportTypeRaw",
writeFile: "lib/google-health/sync-workout.ts",
metadataKey: "googleExerciseType",
},
{
provider: "WHOOP",
mapper: "mapWhoopSportType",
mapperFile: "lib/whoop/sport-map.ts",
captureFile: "lib/whoop/sync-workout.ts",
captureField: "sport_name",
writeFile: "lib/whoop/sync-workout.ts",
metadataKey: "whoopSportName",
},
{
provider: "Polar",
mapper: "mapPolarSportType",
mapperFile: "lib/polar/sport-map.ts",
captureFile: "lib/polar/client.ts",
captureField: "detailed_sport_info",
writeFile: "lib/polar/sync-workouts.ts",
metadataKey: "polarSport",
},
{
provider: "Strava",
mapper: "mapStravaSportType",
mapperFile: "lib/strava/sport-map.ts",
captureFile: "lib/strava/client.ts",
captureField: "rawSportType",
writeFile: "lib/strava/sync.ts",
metadataKey: "stravaType",
},
{
provider: "Apple Health export",
mapper: "resolveHkWorkoutSportType",
mapperFile: "lib/measurements/hk-workout-activity-type-map.ts",
captureFile: "lib/measurements/import-apple-health-export.ts",
captureField: "workoutActivityType",
writeFile: "lib/measurements/import-apple-health-export.ts",
metadataKey: "activityType",
},
];
/**
* Fitbit carries the identical defect Google Health just had: an unmapped
* `activityName` collapses to `"other"` and the original is discarded. It is
* excluded here deliberately, not overlooked. The standing rule for this
* repository is that Fitbit changes ship as small isolated releases with
* parity tests against a provider that already works, so it follows this one
* rather than riding along inside it — the two providers share no code, so
* deferring leaves nothing half-wired.
*
* The exception is written as an assertion rather than an omission: when
* Fitbit gains the provenance write, this test fails and forces the entry to
* move up into `PROVIDER_INGESTS`. An exception nobody has to retire is how
* the original gap survived.
*/
const FITBIT_EXCEPTION = {
provider: "Fitbit",
mapper: "mapFitbitSportType",
mapperFile: "lib/fitbit/client.ts",
writeFile: "lib/fitbit/sync-workout.ts",
} as const;
/**
* Exported helpers whose name ends in `SportType` but which never see an
* upstream label — they narrow a value that is already canonical, at read
* time, and have no provenance to preserve.
*/
const READ_TIME_NARROWERS = [
"closedSportType", // lib/ai/coach/workout-evidence.ts — evidence payload
"narrowSportType", // lib/workouts/insight-evidence.ts — insight claims
];
describe("T1 — the set of sport-label mappers is frozen", () => {
it("every exported sport-type resolver is registered", () => {
const found = new Set<string>();
for (const rel of sourceFiles()) {
for (const m of read(rel).matchAll(
/export function (\w*(?:SportType|WorkoutSportType))\s*\(/g,
)) {
found.add(m[1]);
}
}
const registered = [
...PROVIDER_INGESTS.map((p) => p.mapper),
FITBIT_EXCEPTION.mapper,
...READ_TIME_NARROWERS,
].sort();
// A new name here means a new provider vocabulary, which means a new
// provenance obligation. It belongs in a reviewed diff.
expect([...found].sort()).toEqual(registered);
});
it("each registered mapper lives where the registry says it does", () => {
for (const p of [...PROVIDER_INGESTS, FITBIT_EXCEPTION]) {
expect(
read(p.mapperFile).includes(`export function ${p.mapper}`),
`${p.provider}: ${p.mapper} is no longer declared in ${p.mapperFile}`,
).toBe(true);
}
});
});
describe("T2 — the set of files writing Workout rows is frozen", () => {
/**
* Non-mapping writers: they persist a `sportType` that some other layer
* already resolved, so the provenance rule does not reach them.
*/
const NON_MAPPING_WRITERS: Record<string, string> = {
// The iOS / client batch endpoint. The client sends an enum value and MAY
// send its own `metadata`; no server-side label mapping happens here.
"app/api/workouts/batch/route.ts": "client-supplied enum",
// Backup restore replays rows this app previously wrote, provenance and
// all.
"app/api/admin/backups/[id]/restore/route.ts": "restore replay",
};
it("only the registered ingests and the declared non-mappers create Workout rows", () => {
const writers = sourceFiles().filter((rel) =>
/(?:prisma|tx)\.workout\.(?:create|createMany|createManyAndReturn|upsert)\(/.test(
read(rel),
),
);
const expected = [
...PROVIDER_INGESTS.map((p) => p.writeFile),
FITBIT_EXCEPTION.writeFile,
...Object.keys(NON_MAPPING_WRITERS),
].sort();
expect(writers.sort()).toEqual(expected);
});
});
describe("T3 — every mapping ingest preserves the raw label", () => {
it.each(PROVIDER_INGESTS)(
"$provider writes metadata.$metadataKey",
({ provider, captureFile, captureField, writeFile, metadataKey }) => {
expect(
read(captureFile).includes(captureField),
`${provider}: the raw label (${captureField}) is no longer read in ${captureFile}`,
).toBe(true);
const writer = read(writeFile);
expect(
/metadata[,:]/.test(writer),
`${provider}: ${writeFile} writes no metadata onto the Workout row`,
).toBe(true);
// The key itself is declared where the metadata object is built, which
// for two providers is the capture file rather than the writer.
expect(
writer.includes(metadataKey) || read(captureFile).includes(metadataKey),
`${provider}: nothing writes the raw label under metadata.${metadataKey}`,
).toBe(true);
},
);
});
describe("T4 — Fitbit is the one declared exception", () => {
it("still discards its raw activity name, and says so when it stops", () => {
const writer = read(FITBIT_EXCEPTION.writeFile);
expect(
/metadata[,:]/.test(writer),
"Fitbit now writes workout metadata — move it into PROVIDER_INGESTS " +
"with its provenance key and delete FITBIT_EXCEPTION.",
).toBe(false);
});
it("is the only ingest allowed to be non-compliant", () => {
const nonCompliant = [
...PROVIDER_INGESTS.map((p) => p.writeFile),
FITBIT_EXCEPTION.writeFile,
].filter((rel) => !/metadata[,:]/.test(read(rel)));
expect(nonCompliant).toEqual([FITBIT_EXCEPTION.writeFile]);
});
});
@@ -533,6 +533,8 @@ describe("mapWorkout — exercise session", () => {
});
expect(w!.startedAt.toISOString()).toBe("2026-06-03T07:00:00.000Z");
expect(w!.endedAt.toISOString()).toBe("2026-06-03T07:45:00.000Z");
// Provenance rides along even when the mapping succeeded.
expect(w!.sportTypeRaw).toBe("RUNNING");
});
it("falls back to a start-anchored externalId when the resource name is absent", () => {
@@ -563,12 +565,56 @@ describe("mapWorkout — exercise session", () => {
).toBeNull();
});
it("keeps the raw exerciseType when the sport falls back to 'other'", () => {
// The whole point of the provenance field: `sportType` alone cannot say
// whether Google reported a sport the map has never heard of or a genuine
// OTHER_WORKOUT, and the answer is not recoverable from anywhere else.
const w = mapWorkout({
name: "users/me/dataTypes/exercise/dataPoints/kite1",
exercise: {
exerciseType: " KITESURFING ",
interval: {
startTime: "2026-06-04T10:00:00.000Z",
endTime: "2026-06-04T11:00:00.000Z",
},
},
});
expect(w!.sportType).toBe("other");
expect(w!.sportTypeRaw).toBe("KITESURFING");
});
it("reports a missing or blank exerciseType as null, not an empty string", () => {
const noType = mapWorkout({
exercise: {
interval: {
startTime: "2026-06-04T12:00:00.000Z",
endTime: "2026-06-04T12:30:00.000Z",
},
},
});
expect(noType!.sportType).toBe("other");
expect(noType!.sportTypeRaw).toBeNull();
const blankType = mapWorkout({
exercise: {
exerciseType: " ",
interval: {
startTime: "2026-06-04T13:00:00.000Z",
endTime: "2026-06-04T13:30:00.000Z",
},
},
});
expect(blankType!.sportTypeRaw).toBeNull();
});
it("resolves the UPPERCASE ExerciseType enum, with a generic fallback", () => {
expect(mapGoogleHealthSportType("STRENGTH_TRAINING")).toBe("strength");
expect(mapGoogleHealthSportType("HIGH_INTENSITY_INTERVAL_TRAINING")).toBe(
"hiit",
);
expect(mapGoogleHealthSportType("CYCLING")).toBe("cycling");
// Lossy by design — `Workout.metadata.googleExerciseType` is what carries
// the original (see the mapWorkout provenance cases above).
expect(mapGoogleHealthSportType("KITESURFING")).toBe("other");
expect(mapGoogleHealthSportType("")).toBe("other");
});
@@ -47,6 +47,7 @@ const startedAt = new Date("2026-07-19T08:00:00.000Z");
const mapped = {
externalId: "google-1",
sportType: "running",
sportTypeRaw: "RUNNING",
startedAt,
endedAt: new Date("2026-07-19T09:00:00.000Z"),
durationSec: 3600,
@@ -124,3 +125,52 @@ describe("syncUserWorkout — exact inserted identity", () => {
await expect(syncUserWorkout("user-1")).resolves.toBe(1);
});
});
describe("syncUserWorkout — raw activity-type provenance", () => {
it("writes the raw exerciseType to metadata on insert", async () => {
createManyAndReturn.mockResolvedValue([{ id: "new-workout", startedAt }]);
mapWorkout.mockReturnValue({ ...mapped, sportTypeRaw: "KITESURFING" });
await expect(syncUserWorkout("user-1")).resolves.toBe(1);
expect(createManyAndReturn).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
metadata: { googleExerciseType: "KITESURFING" },
}),
}),
);
});
it("repairs an existing row in place, because the update reuses the payload", async () => {
// A row synced before the provenance write existed has no metadata; the
// update arm is what a full re-sync goes through, so it has to carry the
// key too or the repair path advertised to users does nothing.
createManyAndReturn.mockResolvedValue([]);
mapWorkout.mockReturnValue({ ...mapped, sportTypeRaw: "KITESURFING" });
await expect(syncUserWorkout("user-1")).resolves.toBe(1);
expect(update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
metadata: { googleExerciseType: "KITESURFING" },
}),
}),
);
});
it("omits the key entirely when the session carried no type", async () => {
// Omitted, not null: an update writing `metadata: null` would wipe what an
// earlier sync recorded.
createManyAndReturn.mockResolvedValue([]);
mapWorkout.mockReturnValue({ ...mapped, sportTypeRaw: null });
await expect(syncUserWorkout("user-1")).resolves.toBe(1);
const insertData = createManyAndReturn.mock.calls[0][0].data;
const updateData = update.mock.calls[0][0].data;
expect("metadata" in insertData).toBe(false);
expect("metadata" in updateData).toBe(false);
});
});