test(workouts): pin the workout detail and delete before moving them into services

A parity walk over GET and DELETE /api/workouts/{id} on workouts landed through the batch ingest: the canonical row, the compact body, a hidden twin, a ride, another account's row, a missing one and the module switched off; a synced, a missing, another account's and a hand-entered workout with its record for the delete.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 13:57:18 +02:00
1 parent ab15096a6a
commit 32dec72564
12 files changed
+1059

No files matched your search

@@ -0,0 +1,70 @@
{
"status": 409,
"body": {
"data": null,
"error": "Only a workout entered by hand can be deleted here",
"meta": {
"errorCode": "workout.delete.synced_source",
"requestId": "<requestId>"
}
},
"events": [
{
"status": 409,
"action": {
"name": "workout.delete",
"entity_type": "workout",
"entity_id": "<id:1>"
},
"meta": {
"outcome": "synced_source",
"source": "APPLE_HEALTH"
},
"warnings": null,
"error": null
}
],
"effects": [],
"stored": {
"workouts": [
{
"id": "<id:2>",
"source": "APPLE_HEALTH",
"externalId": "hk-run-0",
"sportType": "running"
},
{
"id": "<id:3>",
"source": "MANUAL",
"externalId": "manual:walk-1",
"sportType": "walking"
},
{
"id": "<id:1>",
"source": "APPLE_HEALTH",
"externalId": "hk-ride-1",
"sportType": "cycling"
},
{
"id": "<id:4>",
"source": "APPLE_HEALTH",
"externalId": "hk-run-1",
"sportType": "running"
},
{
"id": "<id:5>",
"source": "MANUAL",
"externalId": "manual:run-twin",
"sportType": "running"
}
],
"records": [
{
"metricSlot": "longest_distance_run",
"value": 3000,
"externalId": "manual:walk-1",
"source": "MANUAL"
}
]
}
}
@@ -0,0 +1,21 @@
{
"status": 404,
"body": {
"data": null,
"error": "Workout not found",
"meta": {
"errorCode": "http.not_found",
"requestId": "<requestId>"
}
},
"events": [
{
"status": 404,
"action": null,
"meta": null,
"warnings": null,
"error": null
}
],
"effects": []
}
@@ -0,0 +1,63 @@
{
"status": 404,
"body": {
"data": null,
"error": "Workout not found",
"meta": {
"errorCode": "http.not_found",
"requestId": "<requestId>"
}
},
"events": [
{
"status": 404,
"action": null,
"meta": null,
"warnings": null,
"error": null
}
],
"effects": [],
"stored": {
"workouts": [
{
"id": "<id:1>",
"source": "APPLE_HEALTH",
"externalId": "hk-run-0",
"sportType": "running"
},
{
"id": "<id:2>",
"source": "MANUAL",
"externalId": "manual:walk-1",
"sportType": "walking"
},
{
"id": "<id:3>",
"source": "APPLE_HEALTH",
"externalId": "hk-ride-1",
"sportType": "cycling"
},
{
"id": "<id:4>",
"source": "APPLE_HEALTH",
"externalId": "hk-run-1",
"sportType": "running"
},
{
"id": "<id:5>",
"source": "MANUAL",
"externalId": "manual:run-twin",
"sportType": "running"
}
],
"records": [
{
"metricSlot": "longest_distance_run",
"value": 3000,
"externalId": "manual:walk-1",
"source": "MANUAL"
}
]
}
}
@@ -0,0 +1,65 @@
{
"status": 200,
"body": {
"data": {
"deleted": true
},
"error": null
},
"events": [
{
"status": 200,
"action": {
"name": "workout.delete",
"entity_type": "workout",
"entity_id": "<id:1>"
},
"meta": {
"outcome": "deleted",
"personalRecordsRemoved": 1
},
"warnings": null,
"error": null
}
],
"effects": [
{
"effect": "enqueuePrDetection",
"args": [
"<id:2>",
{
"silent": true
}
]
}
],
"stored": {
"workouts": [
{
"id": "<id:3>",
"source": "APPLE_HEALTH",
"externalId": "hk-run-0",
"sportType": "running"
},
{
"id": "<id:4>",
"source": "APPLE_HEALTH",
"externalId": "hk-ride-1",
"sportType": "cycling"
},
{
"id": "<id:5>",
"source": "APPLE_HEALTH",
"externalId": "hk-run-1",
"sportType": "running"
},
{
"id": "<id:6>",
"source": "MANUAL",
"externalId": "manual:run-twin",
"sportType": "running"
}
],
"records": []
}
}
@@ -0,0 +1,182 @@
{
"status": 200,
"body": {
"data": {
"id": "<id:1>",
"sportType": "running",
"startedAt": "2026-10-14T06:30:00.000Z",
"endedAt": "2026-10-14T06:46:00.000Z",
"dayKey": "2026-10-14",
"durationSec": 960,
"distanceM": 3200,
"activeEnergyKcal": 260,
"avgHr": 146,
"maxHr": 158,
"minHr": null,
"storedAvgHr": 146,
"storedMaxHr": 158,
"stepCount": null,
"elevationM": 6,
"pauseDurationSec": null,
"source": "APPLE_HEALTH",
"externalId": "hk-run-1",
"metadata": null,
"route": {
"geometry": {
"type": "LineString",
"coordinates": [
[
7.2162,
51.4818,
98
],
[
7.2262,
51.4868,
104
],
[
7.2383,
51.4929,
101
]
]
},
"sampleTimestamps": [
{
"t": "2026-10-14T06:30:00.000Z",
"hr": 120
},
{
"t": "2026-10-14T06:38:00.000Z",
"hr": 141
},
{
"t": "2026-10-14T06:46:00.000Z",
"hr": 150
}
]
},
"samples": {
"sampleCount": 4,
"samples": [
{
"t": "2026-10-14T06:30:00.000Z",
"hr": 121
},
{
"t": "2026-10-14T06:35:00.000Z",
"hr": 142
},
{
"t": "2026-10-14T06:40:00.000Z",
"hr": 151
},
{
"t": "2026-10-14T06:45:00.000Z",
"hr": 147
}
]
},
"hrSeries": {
"source": "workout_series",
"bucketSec": 5,
"points": [
{
"tSec": 0,
"mean": 121,
"min": 121,
"max": 121
},
{
"tSec": 300,
"mean": 142,
"min": 142,
"max": 142
},
{
"tSec": 600,
"mean": 151,
"min": 151,
"max": 151
},
{
"tSec": 900,
"mean": 147,
"min": 147,
"max": 147
}
],
"envelope": false
},
"zones": {
"model": "tanaka",
"hrMax": 176,
"zones": [
{
"zone": 1,
"lowBpm": 88,
"highBpm": 106,
"seconds": 0
},
{
"zone": 2,
"lowBpm": 106,
"highBpm": 123,
"seconds": 5
},
{
"zone": 3,
"lowBpm": 123,
"highBpm": 141,
"seconds": 0
},
{
"zone": 4,
"lowBpm": 141,
"highBpm": 158,
"seconds": 15
},
{
"zone": 5,
"lowBpm": 158,
"highBpm": null,
"seconds": 0
}
]
},
"splits": null,
"sportContext": {
"count": 1,
"avgDurationSec": 1800,
"avgDistanceM": 5000,
"avgAvgHr": 140
},
"aiInsight": null,
"ai": {
"available": false,
"reason": "no_provider",
"onDeviceAllowed": true
},
"canonicalId": "<id:1>",
"previousWorkoutId": "<id:2>"
},
"error": null
},
"events": [
{
"status": 200,
"action": {
"name": "workouts.detail.hr_series"
},
"meta": {
"workoutId": "<id:1>",
"source": "workout_series",
"points": 4
},
"warnings": null,
"error": null
}
],
"effects": []
}
@@ -0,0 +1,152 @@
{
"status": 200,
"body": {
"data": {
"id": "<id:1>",
"sportType": "running",
"startedAt": "2026-10-14T06:30:00.000Z",
"endedAt": "2026-10-14T06:46:00.000Z",
"dayKey": "2026-10-14",
"durationSec": 960,
"distanceM": 3200,
"activeEnergyKcal": 260,
"avgHr": 146,
"maxHr": 158,
"minHr": null,
"storedAvgHr": 146,
"storedMaxHr": 158,
"stepCount": null,
"elevationM": 6,
"pauseDurationSec": null,
"source": "APPLE_HEALTH",
"externalId": "hk-run-1",
"metadata": null,
"route": {
"geometry": {
"type": "LineString",
"coordinates": [
[
7.2162,
51.4818,
98
],
[
7.2262,
51.4868,
104
],
[
7.2383,
51.4929,
101
]
]
},
"sampleTimestamps": null
},
"samples": {
"sampleCount": 4,
"samples": null
},
"hrSeries": {
"source": "workout_series",
"bucketSec": 5,
"points": [
{
"tSec": 0,
"mean": 121,
"min": 121,
"max": 121
},
{
"tSec": 300,
"mean": 142,
"min": 142,
"max": 142
},
{
"tSec": 600,
"mean": 151,
"min": 151,
"max": 151
},
{
"tSec": 900,
"mean": 147,
"min": 147,
"max": 147
}
],
"envelope": false
},
"zones": {
"model": "tanaka",
"hrMax": 176,
"zones": [
{
"zone": 1,
"lowBpm": 88,
"highBpm": 106,
"seconds": 0
},
{
"zone": 2,
"lowBpm": 106,
"highBpm": 123,
"seconds": 5
},
{
"zone": 3,
"lowBpm": 123,
"highBpm": 141,
"seconds": 0
},
{
"zone": 4,
"lowBpm": 141,
"highBpm": 158,
"seconds": 15
},
{
"zone": 5,
"lowBpm": 158,
"highBpm": null,
"seconds": 0
}
]
},
"splits": null,
"sportContext": {
"count": 1,
"avgDurationSec": 1800,
"avgDistanceM": 5000,
"avgAvgHr": 140
},
"aiInsight": null,
"ai": {
"available": false,
"reason": "no_provider",
"onDeviceAllowed": true
},
"canonicalId": "<id:1>",
"previousWorkoutId": "<id:2>"
},
"error": null
},
"events": [
{
"status": 200,
"action": {
"name": "workouts.detail.hr_series"
},
"meta": {
"workoutId": "<id:1>",
"source": "workout_series",
"points": 4
},
"warnings": null,
"error": null
}
],
"effects": []
}
@@ -0,0 +1,62 @@
{
"status": 200,
"body": {
"data": {
"id": "<id:1>",
"sportType": "running",
"startedAt": "2026-10-14T06:30:20.000Z",
"endedAt": "2026-10-14T06:46:20.000Z",
"dayKey": "2026-10-14",
"durationSec": 960,
"distanceM": 3150,
"activeEnergyKcal": null,
"avgHr": 146,
"maxHr": 158,
"minHr": null,
"storedAvgHr": null,
"storedMaxHr": null,
"stepCount": null,
"elevationM": null,
"pauseDurationSec": null,
"source": "MANUAL",
"externalId": "manual:run-twin",
"metadata": null,
"route": null,
"samples": null,
"hrSeries": null,
"zones": null,
"splits": null,
"sportContext": {
"count": 1,
"avgDurationSec": 1800,
"avgDistanceM": 5000,
"avgAvgHr": 140
},
"aiInsight": null,
"ai": {
"available": false,
"reason": "no_provider",
"onDeviceAllowed": true
},
"canonicalId": "<id:2>",
"previousWorkoutId": "<id:3>"
},
"error": null
},
"events": [
{
"status": 200,
"action": {
"name": "workouts.detail.hr_series"
},
"meta": {
"workoutId": "<id:1>",
"source": "none",
"points": 0
},
"warnings": null,
"error": null
}
],
"effects": []
}
@@ -0,0 +1,57 @@
{
"status": 200,
"body": {
"data": {
"id": "<id:1>",
"sportType": "cycling",
"startedAt": "2026-10-12T16:00:00.000Z",
"endedAt": "2026-10-12T17:00:00.000Z",
"dayKey": "2026-10-12",
"durationSec": 3600,
"distanceM": 24000,
"activeEnergyKcal": null,
"avgHr": null,
"maxHr": null,
"minHr": null,
"storedAvgHr": null,
"storedMaxHr": null,
"stepCount": null,
"elevationM": null,
"pauseDurationSec": null,
"source": "APPLE_HEALTH",
"externalId": "hk-ride-1",
"metadata": null,
"route": null,
"samples": null,
"hrSeries": null,
"zones": null,
"splits": null,
"sportContext": null,
"aiInsight": null,
"ai": {
"available": false,
"reason": "no_provider",
"onDeviceAllowed": true
},
"canonicalId": "<id:1>",
"previousWorkoutId": null
},
"error": null
},
"events": [
{
"status": 200,
"action": {
"name": "workouts.detail.hr_series"
},
"meta": {
"workoutId": "<id:1>",
"source": "none",
"points": 0
},
"warnings": null,
"error": null
}
],
"effects": []
}
@@ -0,0 +1,25 @@
{
"status": 404,
"body": {
"data": null,
"error": "Workout not found",
"meta": {
"errorCode": "http.not_found",
"requestId": "<requestId>"
}
},
"events": [
{
"status": 404,
"action": {
"name": "workouts.detail"
},
"meta": {
"workoutId": "<id:1>"
},
"warnings": null,
"error": null
}
],
"effects": []
}
@@ -0,0 +1,25 @@
{
"status": 404,
"body": {
"data": null,
"error": "Workout not found",
"meta": {
"errorCode": "http.not_found",
"requestId": "<requestId>"
}
},
"events": [
{
"status": 404,
"action": {
"name": "workouts.detail"
},
"meta": {
"workoutId": "cmissingworkout000000000"
},
"warnings": null,
"error": null
}
],
"effects": []
}
@@ -0,0 +1,26 @@
{
"status": 403,
"body": {
"data": null,
"error": "Module \"workouts\" is not enabled",
"meta": {
"errorCode": "module.disabled",
"module": "workouts",
"requestId": "<requestId>"
}
},
"events": [
{
"status": 403,
"action": {
"name": "workouts.detail"
},
"meta": {
"workoutId": "<id:1>"
},
"warnings": null,
"error": null
}
],
"effects": []
}
@@ -0,0 +1,311 @@
/**
* Parity for `GET` and `DELETE /api/workouts/{id}` while their logic moves
* into `src/lib/workouts/`.
*
* Workouts land through the real batch ingest (a HealthKit run with its
* heart-rate series and a kept track, the same run entered by hand, an
* earlier run, a ride), then the detail read walks the cases it tells apart:
* the canonical row, its hidden twin, the compact body, another account's
* row, and the module switched off. The delete walk covers a hand-entered
* workout with the record it set, a synced workout and a missing one. Each
* snapshot holds the response, the wide event, the recorded queue tail and,
* for the deletes, the workouts and records read back.
*/
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { DELETE, GET } from "@/app/api/workouts/[id]/route";
import { POST as postBatch } from "@/app/api/workouts/batch/route";
import { callRoute, type RouteAuth } from "../../../kit/call-route";
import { makeSession, makeUser } from "../../../kit/factories";
import {
effectRecorder,
eventFacts,
expectParity,
stable,
wideEventRecorder,
} from "../../../kit/parity";
import { getPrismaClient, truncateAllTables } from "../../setup";
vi.mock("@/lib/logging/transports", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/logging/transports")>();
const { wideEventRecorder: recorder } = await import("../../../kit/parity");
return {
...actual,
emitIfSampled: (event: (typeof recorder.events)[number]) => {
recorder.events.push(event);
},
};
});
vi.mock("@/lib/arrivals/emit-shared", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/arrivals/emit-shared")>();
const { effectRecorder } = await import("../../../kit/parity");
return {
...actual,
emitDataArrival: async (...args: unknown[]) => {
effectRecorder.record("emitDataArrival", args);
},
};
});
vi.mock("@/lib/jobs/pr-detection", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/jobs/pr-detection")>();
const { effectRecorder } = await import("../../../kit/parity");
return {
...actual,
enqueuePrDetection: async (...args: unknown[]) => {
effectRecorder.record("enqueuePrDetection", args);
},
};
});
const prisma = getPrismaClient();
const NOW = new Date("2026-10-15T12:00:00.000Z");
let userId: string;
let owner: RouteAuth;
let stranger: RouteAuth;
const ids: Record<string, string> = {};
const SERIES = [
{ t: "2026-10-14T06:30:00.000Z", hr: 121 },
{ t: "2026-10-14T06:35:00.000Z", hr: 142 },
{ t: "2026-10-14T06:40:00.000Z", hr: 151 },
{ t: "2026-10-14T06:45:00.000Z", hr: 147 },
];
const TRACK = {
geometry: {
type: "LineString",
coordinates: [
[7.2162, 51.4818, 98],
[7.2262, 51.4868, 104],
[7.2383, 51.4929, 101],
],
},
sampleTimestamps: [
{ t: "2026-10-14T06:30:00.000Z", hr: 120 },
{ t: "2026-10-14T06:38:00.000Z", hr: 141 },
{ t: "2026-10-14T06:46:00.000Z", hr: 150 },
],
};
function workout(
externalId: string,
start: string,
minutes: number,
extra: Record<string, unknown> = {},
) {
const startedAt = new Date(start);
return {
sportType: "running",
startedAt: startedAt.toISOString(),
endedAt: new Date(startedAt.getTime() + minutes * 60_000).toISOString(),
source: "APPLE_HEALTH",
externalId,
...extra,
};
}
async function idOf(externalId: string): Promise<string> {
const row = await prisma.workout.findFirstOrThrow({
where: { userId, externalId },
select: { id: true },
});
return row.id;
}
async function stored() {
const [workouts, records] = await Promise.all([
prisma.workout.findMany({
where: { userId },
orderBy: [{ startedAt: "asc" }, { source: "asc" }],
select: { id: true, source: true, externalId: true, sportType: true },
}),
prisma.personalRecord.findMany({
where: { userId },
orderBy: { metricSlot: "asc" },
select: { metricSlot: true, value: true, externalId: true, source: true },
}),
]);
return { workouts, records };
}
async function step(
name: string,
call: {
method: "GET" | "DELETE";
id: string;
query?: Record<string, string>;
auth?: RouteAuth;
readBack?: boolean;
},
): Promise<void> {
wideEventRecorder.take();
effectRecorder.take();
const res = await callRoute(call.method === "GET" ? GET : DELETE, {
method: call.method,
route: "/api/workouts/{id}",
params: { id: call.id },
query: call.query,
auth: call.auth ?? owner,
});
const events = wideEventRecorder.take();
expect(events.length).toBeGreaterThan(0);
await expectParity(
`workout-detail.${name}`,
stable(
{
status: res.status,
body: res.body,
events: events.map(eventFacts),
effects: effectRecorder.take(),
...(call.readBack ? { stored: await stored() } : {}),
},
{ since: NOW },
),
);
}
beforeAll(async () => {
vi.useFakeTimers({ toFake: ["Date"], now: NOW });
await truncateAllTables(prisma);
const user = await makeUser({
timezone: "Europe/Berlin",
dateOfBirth: new Date("1980-05-01T00:00:00.000Z"),
storeWorkoutRoutes: true,
});
userId = user.id;
owner = await makeSession(user);
stranger = await makeSession(await makeUser({ timezone: "UTC" }));
const res = await callRoute(postBatch, {
method: "POST",
route: "/api/workouts/batch",
auth: owner,
body: {
workouts: [
workout("hk-run-1", "2026-10-14T06:30:00.000Z", 16, {
totalDistanceM: 3_200,
totalEnergyKcal: 260,
avgHeartRate: 146,
maxHeartRate: 158,
samples: SERIES,
route: TRACK,
}),
workout("hk-run-0", "2026-10-07T06:30:00.000Z", 30, {
totalDistanceM: 5_000,
avgHeartRate: 140,
}),
workout("hk-ride-1", "2026-10-12T16:00:00.000Z", 60, {
sportType: "cycling",
totalDistanceM: 24_000,
}),
workout("manual:walk-1", "2026-10-10T18:00:00.000Z", 40, {
sportType: "walking",
source: "MANUAL",
totalDistanceM: 3_000,
}),
],
},
});
expect(res.status).toBe(200);
// The same run typed in by hand: a twin the canonical picker hides.
const twin = await prisma.workout.create({
data: {
userId,
sportType: "running",
startedAt: new Date("2026-10-14T06:30:20.000Z"),
endedAt: new Date("2026-10-14T06:46:20.000Z"),
durationSec: 960,
source: "MANUAL",
externalId: "manual:run-twin",
totalDistanceM: 3_150,
},
});
ids.twin = twin.id;
ids.run = await idOf("hk-run-1");
ids.ride = await idOf("hk-ride-1");
ids.walk = await idOf("manual:walk-1");
await prisma.personalRecord.create({
data: {
userId,
metricType: "WALKING_RUNNING_DISTANCE",
metricSlot: "longest_distance_run",
direction: "MAX",
value: 3_000,
unit: "m",
achievedAt: new Date("2026-10-10T18:00:00.000Z"),
source: "MANUAL",
externalId: "manual:walk-1",
},
});
});
afterAll(() => {
vi.useRealTimers();
});
describe("GET /api/workouts/{id} parity", () => {
it("the detail reads", async () => {
await step("get-01-canonical", { method: "GET", id: ids.run });
await step("get-02-compact", {
method: "GET",
id: ids.run,
query: { compact: "1" },
});
await step("get-03-hidden-twin", { method: "GET", id: ids.twin });
await step("get-04-ride", { method: "GET", id: ids.ride });
await step("get-05-other-account", {
method: "GET",
id: ids.run,
auth: stranger,
});
await step("get-06-missing", {
method: "GET",
id: "cmissingworkout000000000",
});
});
it("the module switched off", async () => {
await prisma.user.update({
where: { id: userId },
data: { modulePreferencesJson: { workouts: false } },
});
await step("get-07-module-off", { method: "GET", id: ids.run });
await prisma.user.update({
where: { id: userId },
data: { modulePreferencesJson: {} },
});
});
});
describe("DELETE /api/workouts/{id} parity", () => {
it("a synced workout, a missing one, another account's", async () => {
await step("delete-01-synced", {
method: "DELETE",
id: ids.ride,
readBack: true,
});
await step("delete-02-missing", {
method: "DELETE",
id: "cmissingworkout000000000",
});
await step("delete-03-other-account", {
method: "DELETE",
id: ids.walk,
auth: stranger,
readBack: true,
});
});
it("a hand-entered workout and the record it set", async () => {
await step("delete-04-manual", {
method: "DELETE",
id: ids.walk,
readBack: true,
});
});
});