mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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.
399 lines
12 KiB
TypeScript
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();
|
|
});
|
|
});
|