Files
HealthLog/src/lib/validations/__tests__/workout.test.ts
T
Marc-André Bombeck 9aa0a3597a fix(api): constrain client-writable sources and batch timestamps
Narrow the workout create schema's source to the client-writable set
(MANUAL, APPLE_HEALTH), matching the measurement write surfaces, so a
client cannot forge a server-owned source into the cross-source picker.
Apply the single-entry plausibility bound to the measurement batch
timestamps so a backdated or future-dated row can no longer slip past
the batch path. Regenerate the OpenAPI contract.
2026-07-01 23:45:08 +02:00

399 lines
12 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
createBatchWorkoutSchema,
createWorkoutSchema,
geoJsonLineStringSchema,
MAX_ROUTE_POINTS,
MAX_WORKOUT_HR_SAMPLES,
MAX_WORKOUTS_PER_BATCH,
workoutHrSamplesSchema,
workoutRouteSamplesSchema,
workoutSportTypeEnum,
type CreateWorkoutInput,
} from "../workout";
describe("workoutSportTypeEnum", () => {
it("accepts every documented sport", () => {
for (const sport of [
"walking",
"running",
"cycling",
"swimming",
"hiit",
"other",
]) {
expect(workoutSportTypeEnum.parse(sport)).toBe(sport);
}
});
it("rejects an unknown sport", () => {
expect(() => workoutSportTypeEnum.parse("teleportation")).toThrow();
});
});
describe("geoJsonLineStringSchema", () => {
it("accepts a valid GeoJSON LineString with lon/lat pairs", () => {
const parsed = geoJsonLineStringSchema.parse({
type: "LineString",
coordinates: [
[11.077, 49.452],
[11.078, 49.453],
],
});
expect(parsed.type).toBe("LineString");
expect(parsed.coordinates).toHaveLength(2);
});
it("accepts coordinates with an altitude component", () => {
const parsed = geoJsonLineStringSchema.parse({
type: "LineString",
coordinates: [
[11.077, 49.452, 320.5],
[11.078, 49.453, 322.1],
],
});
expect(parsed.coordinates[0]).toHaveLength(3);
});
it("rejects a Point geometry", () => {
expect(() =>
geoJsonLineStringSchema.parse({
type: "Point",
coordinates: [11.077, 49.452],
}),
).toThrow();
});
it("rejects a single-point LineString", () => {
expect(() =>
geoJsonLineStringSchema.parse({
type: "LineString",
coordinates: [[11.077, 49.452]],
}),
).toThrow(/at least 2 points/);
});
it("rejects out-of-bounds longitude / latitude", () => {
expect(() =>
geoJsonLineStringSchema.parse({
type: "LineString",
coordinates: [
[200, 49.452],
[201, 49.453],
],
}),
).toThrow();
expect(() =>
geoJsonLineStringSchema.parse({
type: "LineString",
coordinates: [
[11.077, 200],
[11.078, 201],
],
}),
).toThrow();
});
});
describe("workoutRouteSamplesSchema", () => {
it("accepts an array of timestamp + optional speed/hr entries", () => {
const parsed = workoutRouteSamplesSchema.parse([
{ t: "2026-05-14T07:00:00.000Z", speedMs: 3.2, hr: 142 },
{ t: "2026-05-14T07:00:05.000Z" },
]);
expect(parsed).toHaveLength(2);
});
it("rejects negative speed", () => {
expect(() =>
workoutRouteSamplesSchema.parse([
{ t: "2026-05-14T07:00:00.000Z", speedMs: -1 },
]),
).toThrow();
});
});
describe("workoutHrSamplesSchema", () => {
it("accepts a route-independent HR series with optional channels", () => {
const parsed = workoutHrSamplesSchema.parse([
{ t: "2026-05-14T07:00:00.000Z", hr: 142 },
{ t: "2026-05-14T07:00:05.000Z", hr: 145, speedMs: 3.1 },
{ t: "2026-05-14T07:00:10.000Z", hr: 150, power: 220, cadence: 88 },
]);
expect(parsed).toHaveLength(3);
});
it("accepts a bare-timestamp entry (sparse series)", () => {
expect(
workoutHrSamplesSchema.parse([{ t: "2026-05-14T07:00:00.000Z" }]),
).toHaveLength(1);
});
it("rejects an empty series", () => {
expect(() => workoutHrSamplesSchema.parse([])).toThrow();
});
it("rejects an out-of-range heart rate", () => {
expect(() =>
workoutHrSamplesSchema.parse([{ t: "2026-05-14T07:00:00.000Z", hr: 5 }]),
).toThrow();
});
it("rejects a series above the per-workout sample cap", () => {
const oversized = Array.from(
{ length: MAX_WORKOUT_HR_SAMPLES + 1 },
(_v, i) => ({
t: new Date(Date.UTC(2026, 4, 14, 7, 0, 0) + i * 1000).toISOString(),
hr: 140,
}),
);
expect(() => workoutHrSamplesSchema.parse(oversized)).toThrow(
/sample.*cap/i,
);
});
it("accepts a series exactly at the cap", () => {
const atCap = Array.from({ length: MAX_WORKOUT_HR_SAMPLES }, (_v, i) => ({
t: new Date(Date.UTC(2026, 4, 14, 7, 0, 0) + i * 1000).toISOString(),
hr: 140,
}));
expect(workoutHrSamplesSchema.parse(atCap)).toHaveLength(
MAX_WORKOUT_HR_SAMPLES,
);
});
});
describe("createWorkoutSchema", () => {
const minimalRun: Record<string, unknown> = {
sportType: "running",
startedAt: "2026-05-14T06:30:00.000Z",
endedAt: "2026-05-14T07:15:00.000Z",
};
it("accepts a minimal workout payload", () => {
const parsed = createWorkoutSchema.parse(minimalRun);
expect(parsed.sportType).toBe("running");
expect(parsed.startedAt).toBeInstanceOf(Date);
expect(parsed.endedAt).toBeInstanceOf(Date);
expect(parsed.source).toBe("MANUAL");
});
it("accepts an Apple-Health-shaped HKWorkout payload", () => {
const input: Record<string, unknown> = {
sportType: "running",
startedAt: "2026-05-14T06:30:00.000Z",
endedAt: "2026-05-14T07:15:00.000Z",
totalEnergyKcal: 412.3,
totalDistanceM: 7_800,
avgHeartRate: 154,
maxHeartRate: 178,
minHeartRate: 92,
source: "APPLE_HEALTH",
externalId: "B5F8-...-A3",
metadata: { HKAverageMETs: 8.4, sourceBundleId: "com.apple.health" },
route: {
geometry: {
type: "LineString",
coordinates: [
[11.077, 49.452, 320.5],
[11.078, 49.453, 322.1],
],
},
sampleTimestamps: [
{ t: "2026-05-14T06:30:00.000Z", speedMs: 3.2, hr: 142 },
{ t: "2026-05-14T06:30:05.000Z", speedMs: 3.3, hr: 144 },
],
},
};
const parsed: CreateWorkoutInput = createWorkoutSchema.parse(input);
expect(parsed.source).toBe("APPLE_HEALTH");
expect(parsed.totalDistanceM).toBe(7_800);
expect(parsed.route?.geometry.coordinates).toHaveLength(2);
});
it("rejects an unknown sportType", () => {
expect(() =>
createWorkoutSchema.parse({ ...minimalRun, sportType: "fartlek" }),
).toThrow();
});
it("rejects a server-owned source a client cannot forge", () => {
// The client-writable set is exactly {MANUAL, APPLE_HEALTH}. WHOOP /
// FITBIT rows are minted server-side by the sync workers; letting a
// client attribute a row to them would pollute the per-source canonical
// workout picker with rows the integration never produced.
for (const forged of ["WHOOP", "FITBIT", "WITHINGS", "COMPUTED"]) {
expect(() =>
createWorkoutSchema.parse({ ...minimalRun, source: forged }),
).toThrow();
}
});
it("rejects a max heart rate below the resting floor", () => {
expect(() =>
createWorkoutSchema.parse({ ...minimalRun, maxHeartRate: 5 }),
).toThrow();
});
it("rejects a route geometry that exceeds the per-route point cap", () => {
// One past the cap — schema MUST reject. The cap exists so a single
// pathological workout can't blow the 5 MB request-body ceiling on
// its own; a desync between the schema cap and the route route's
// expected-size accounting would silently degrade ingest.
const coords: [number, number][] = [];
for (let i = 0; i < MAX_ROUTE_POINTS + 1; i++) {
coords.push([11 + i * 1e-6, 49 + i * 1e-6]);
}
expect(() =>
createWorkoutSchema.parse({
...minimalRun,
route: {
geometry: { type: "LineString", coordinates: coords },
},
}),
).toThrow(/20000-point cap/);
});
it("rejects a workout whose endedAt is before startedAt", () => {
// Regression for the W16b ingest gate: a reversed pair produced a
// negative `durationSec` that downstream consumers (PR detector,
// weekly report) treated as a real zero — a fastest-5km PR of
// zero seconds would lock in until manual cleanup. The schema
// refuses the row at parse time.
expect(() =>
createWorkoutSchema.parse({
...minimalRun,
startedAt: "2026-05-14T07:15:00.000Z",
endedAt: "2026-05-14T06:30:00.000Z",
}),
).toThrow(/endedAt must be strictly after startedAt/);
});
it("rejects a zero-duration workout (endedAt === startedAt)", () => {
expect(() =>
createWorkoutSchema.parse({
...minimalRun,
startedAt: "2026-05-14T06:30:00.000Z",
endedAt: "2026-05-14T06:30:00.000Z",
}),
).toThrow(/endedAt must be strictly after startedAt/);
});
it("rejects a route whose sampleTimestamps length mismatches coordinates", () => {
// Cross-field invariant: per-sample HR / speed must line up against
// the parallel coordinate index, so a desynced pair silently
// poisons downstream analytics. Hard-fail at parse time so the iOS
// mapper surfaces the problem immediately.
expect(() =>
createWorkoutSchema.parse({
...minimalRun,
route: {
geometry: {
type: "LineString",
coordinates: [
[11.077, 49.452],
[11.078, 49.453],
[11.079, 49.454],
],
},
sampleTimestamps: [
{ t: "2026-05-14T06:30:00.000Z" },
{ t: "2026-05-14T06:30:05.000Z" },
// missing third entry — desync the parser MUST catch
],
},
}),
).toThrow(/sampleTimestamps length must match/);
});
it("accepts a route when sampleTimestamps length equals coordinates length", () => {
const parsed = createWorkoutSchema.parse({
...minimalRun,
route: {
geometry: {
type: "LineString",
coordinates: [
[11.077, 49.452],
[11.078, 49.453],
],
},
sampleTimestamps: [
{ t: "2026-05-14T06:30:00.000Z", speedMs: 3.2, hr: 142 },
{ t: "2026-05-14T06:30:05.000Z", speedMs: 3.3, hr: 144 },
],
},
});
expect(parsed.route?.sampleTimestamps).toHaveLength(2);
});
it("accepts a route-independent HR series on an indoor workout (no route)", () => {
const parsed = createWorkoutSchema.parse({
sportType: "strength",
startedAt: "2026-05-14T06:30:00.000Z",
endedAt: "2026-05-14T07:15:00.000Z",
source: "APPLE_HEALTH",
externalId: "indoor-1",
samples: [
{ t: "2026-05-14T06:30:00.000Z", hr: 110 },
{ t: "2026-05-14T06:30:05.000Z", hr: 118 },
],
});
expect(parsed.route).toBeUndefined();
expect(parsed.samples).toHaveLength(2);
});
it("accepts both a GPS route and a canonical HR series on one workout", () => {
const parsed = createWorkoutSchema.parse({
...minimalRun,
route: {
geometry: {
type: "LineString",
coordinates: [
[11.077, 49.452],
[11.078, 49.453],
],
},
},
samples: [{ t: "2026-05-14T06:30:00.000Z", hr: 142 }],
});
expect(parsed.route?.geometry.coordinates).toHaveLength(2);
expect(parsed.samples).toHaveLength(1);
});
});
describe("createBatchWorkoutSchema", () => {
const minimalRun = (id: string) => ({
sportType: "running",
startedAt: "2026-05-14T06:30:00.000Z",
endedAt: "2026-05-14T07:15:00.000Z",
externalId: id,
});
it("accepts a single-workout batch", () => {
const parsed = createBatchWorkoutSchema.parse({
workouts: [minimalRun("hk-uuid-001")],
});
expect(parsed.workouts).toHaveLength(1);
});
it("accepts a batch at the cap (100 workouts)", () => {
const workouts = Array.from({ length: MAX_WORKOUTS_PER_BATCH }, (_, i) =>
minimalRun(`hk-uuid-${i}`),
);
const parsed = createBatchWorkoutSchema.parse({ workouts });
expect(parsed.workouts).toHaveLength(MAX_WORKOUTS_PER_BATCH);
});
it("rejects a batch above the cap", () => {
const workouts = Array.from(
{ length: MAX_WORKOUTS_PER_BATCH + 1 },
(_, i) => minimalRun(`hk-uuid-${i}`),
);
expect(() => createBatchWorkoutSchema.parse({ workouts })).toThrow();
});
it("rejects an empty batch", () => {
expect(() => createBatchWorkoutSchema.parse({ workouts: [] })).toThrow();
});
});