diff --git a/src/__tests__/workout-sport-provenance-guard.test.ts b/src/__tests__/workout-sport-provenance-guard.test.ts new file mode 100644 index 000000000..ea9eca33f --- /dev/null +++ b/src/__tests__/workout-sport-provenance-guard.test.ts @@ -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(); + 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 = { + // 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]); + }); +}); diff --git a/src/lib/google-health/__tests__/mappers.test.ts b/src/lib/google-health/__tests__/mappers.test.ts index 252653e3b..2eacc6d0a 100644 --- a/src/lib/google-health/__tests__/mappers.test.ts +++ b/src/lib/google-health/__tests__/mappers.test.ts @@ -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"); }); diff --git a/src/lib/google-health/__tests__/sync-workout-insert-identity.test.ts b/src/lib/google-health/__tests__/sync-workout-insert-identity.test.ts index e09c5fdfe..a0a61a56c 100644 --- a/src/lib/google-health/__tests__/sync-workout-insert-identity.test.ts +++ b/src/lib/google-health/__tests__/sync-workout-insert-identity.test.ts @@ -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); + }); +});