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 = { 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 = { 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(); }); });