Files
HealthLog/tests/integration/workout-batch-route-enrich.test.ts
T
Marc-André Bombeck 7b4c67b372 feat(workouts): attach a route to a stored workout that has none
The workout batch kept `route` only for entries it inserted. An entry
matching a stored workout reported `duplicate` and its track was dropped,
so turning on `storeWorkoutRoutes` only helped workouts recorded from then
on.

A matching entry that carries a route now attaches it when the stored
workout has no track and the account keeps tracks, the same way the
heart-rate series enrichment works: the planner probes for a stored
track, the write seals it like an insert does, and the entry reports
`enriched` (once, also when it attaches series and track together). The
climb derived from the track fills an empty `elevationM`, as it would on
insert. A workout that already has a track keeps it, and with routes off
nothing is written.

Track rows now go through `createManyAndReturn` on the unique
`workoutId`, so a track that lands between the probe and the write wins
and this entry settles as a duplicate instead of failing or adding a
second row.
2026-11-05 16:04:08 +01:00

378 lines
13 KiB
TypeScript

/**
* Integration suite for the track enrichment path of `POST /api/workouts/batch`,
* against a real Postgres in a testcontainer.
*
* The case: an account turned `storeWorkoutRoutes` on after its workouts were
* stored without tracks. A later re-post of the SAME `(source, externalId)`
* carrying a `route` attaches the track to the stored workout, sealed the way
* an insert seals it, and reports the entry as `enriched`.
*
* What the suite pins:
* - The attach happens once, sealed, and reports `enriched`; the climb
* fills the empty `elevationM` and no other column of the workout moves
* - The readers (detail, list) and the backup see the attached track by
* construction
* - A workout that already holds a track keeps it and reports `duplicate`
* - With the opt-in off, nothing is written
* - An entry attaching both series and track reports one `enriched`
* - Two concurrent batches for the same workout leave one track row and
* both answer 200
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
process.env.API_TOKEN_HMAC_KEY ??=
"test-hmac-key-workout-route-enrich-integration-32-bytes-min-12345";
process.env.ENCRYPTION_KEY =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const TEST_USER_ID = "user-workout-route-enrich-test";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
await getPrismaClient().user.create({
data: {
id: TEST_USER_ID,
username: "workout-route-enrich",
email: "workout-route-enrich@example.test",
},
});
const session = await getPrismaClient().session.create({
data: {
userId: TEST_USER_ID,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
});
interface BatchResponse {
processed: number;
inserted: number;
duplicates: number;
entries: Array<{ index: number; status: string; reason?: string }>;
}
type Geometry = { type: "LineString"; coordinates: number[][] };
const TRACK: Geometry = {
type: "LineString",
coordinates: [
[11.077, 49.452, 300],
[11.078, 49.453, 312],
[11.079, 49.454, 325],
],
};
const OTHER_TRACK: Geometry = {
type: "LineString",
coordinates: [
[7.21, 51.48, 90],
[7.22, 51.49, 95],
],
};
const SERIES = [
{ t: "2026-05-14T06:30:00.000Z", hr: 121 },
{ t: "2026-05-14T06:30:05.000Z", hr: 128 },
];
function workout(
externalId: string,
extra: { route?: { geometry: Geometry }; samples?: unknown } = {},
) {
return {
sportType: "running",
startedAt: "2026-05-14T06:30:00.000Z",
endedAt: "2026-05-14T07:15:00.000Z",
source: "APPLE_HEALTH",
externalId,
...extra,
};
}
async function post(workouts: unknown[]): Promise<{
status: number;
data: BatchResponse;
}> {
const { POST } = await import("@/app/api/workouts/batch/route");
const res = await POST(
new NextRequest("http://localhost/api/workouts/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ workouts }),
}),
);
const json = (await res.json()) as { data: BatchResponse };
return { status: res.status, data: json.data };
}
async function keepRoutes(on: boolean): Promise<void> {
await getPrismaClient().user.update({
where: { id: TEST_USER_ID },
data: { storeWorkoutRoutes: on },
});
}
/** A workout stored before the account kept tracks. */
async function storeWithoutTrack(externalId: string): Promise<string> {
const first = await post([workout(externalId)]);
expect(first.data.entries[0]?.status).toBe("inserted");
const row = await getPrismaClient().workout.findFirstOrThrow({
where: { userId: TEST_USER_ID, externalId },
include: { route: true },
});
expect(row.route).toBeNull();
return row.id;
}
async function decrypt(workoutId: string): Promise<unknown> {
const { decryptRouteGeometry } =
await import("@/lib/workouts/route-geometry-cipher");
const route = await getPrismaClient().workoutRoute.findUniqueOrThrow({
where: { workoutId },
});
return decryptRouteGeometry(route.geometryEncrypted!);
}
describe("POST /api/workouts/batch — track enrichment (real Postgres)", () => {
it("attaches a sealed track to a stored workout without one and reports enriched", async () => {
const id = await storeWithoutTrack("hk-route-enrich-001");
const before = await getPrismaClient().workout.findUniqueOrThrow({
where: { id },
});
await keepRoutes(true);
const second = await post([
workout("hk-route-enrich-001", { route: { geometry: TRACK } }),
]);
expect(second.status).toBe(200);
expect(second.data.entries).toEqual([{ index: 0, status: "enriched" }]);
expect(second.data.inserted).toBe(0);
expect(second.data.duplicates).toBe(1);
// Sealed only, exactly like an insert: the readable column is SQL NULL,
// the ciphertext holds no coordinate, and it opens to what was sent.
const onDisk = await getPrismaClient().$queryRaw<
{ readable_is_null: boolean; leaks: boolean }[]
>`
SELECT geometry IS NULL AS readable_is_null,
position(convert_to('49.452', 'UTF8') IN geometry_encrypted) > 0
AS leaks
FROM workout_routes WHERE workout_id = ${id}`;
expect(onDisk).toEqual([{ readable_is_null: true, leaks: false }]);
expect(await decrypt(id)).toEqual(TRACK);
// The climb fills the empty column (300 → 312 → 325 m); nothing else
// on the workout row moves.
const after = await getPrismaClient().workout.findUniqueOrThrow({
where: { id },
});
expect(after.elevationM).toBe(25);
const { elevationM: _b, updatedAt: _bu, ...restBefore } = before;
const { elevationM: _a, updatedAt: _au, ...restAfter } = after;
expect(restAfter).toEqual(restBefore);
// A third post is a no-op: the track is there now.
const third = await post([
workout("hk-route-enrich-001", { route: { geometry: OTHER_TRACK } }),
]);
expect(third.data.entries).toEqual([{ index: 0, status: "duplicate" }]);
expect(await decrypt(id)).toEqual(TRACK);
});
it("serves the attached track to the readers and the climb to the backup", async () => {
const id = await storeWithoutTrack("hk-route-enrich-readers");
await keepRoutes(true);
await post([
workout("hk-route-enrich-readers", { route: { geometry: TRACK } }),
]);
const { GET: detail } = await import("@/app/api/workouts/[id]/route");
const detailRes = await detail(
new NextRequest(`http://localhost/api/workouts/${id}?compact=1`),
{ params: Promise.resolve({ id }) },
);
expect(detailRes.status).toBe(200);
const detailBody = (await detailRes.json()) as {
data: { route: { geometry: unknown } | null };
};
expect(detailBody.data.route?.geometry).toEqual(TRACK);
const { GET: list } = await import("@/app/api/workouts/route");
const listRes = await list(
new NextRequest("http://localhost/api/workouts"),
);
expect(listRes.status).toBe(200);
const listBody = (await listRes.json()) as {
data: { workouts: Array<{ id: string; hasRoute: boolean }> };
};
expect(listBody.data.workouts.find((w) => w.id === id)?.hasRoute).toBe(
true,
);
// Tracks do not travel in the account backup (its manifest says so);
// the climb they filled does, on the workout row.
const { buildWorkoutsBackupSlice } =
await import("@/lib/export/workouts-backup");
const slice = await buildWorkoutsBackupSlice(
getPrismaClient(),
TEST_USER_ID,
true,
);
expect(slice.workouts.find((w) => w.id === id)?.elevationM).toBe(25);
});
it("keeps a stored track and reports duplicate", async () => {
await keepRoutes(true);
const first = await post([
workout("hk-route-enrich-kept", { route: { geometry: TRACK } }),
]);
expect(first.data.entries[0]?.status).toBe("inserted");
const row = await getPrismaClient().workout.findFirstOrThrow({
where: { externalId: "hk-route-enrich-kept" },
include: { route: true },
});
const second = await post([
workout("hk-route-enrich-kept", { route: { geometry: OTHER_TRACK } }),
]);
expect(second.data.entries).toEqual([{ index: 0, status: "duplicate" }]);
const route = await getPrismaClient().workoutRoute.findUniqueOrThrow({
where: { workoutId: row.id },
});
expect(route.id).toBe(row.route!.id);
expect(await decrypt(row.id)).toEqual(TRACK);
});
it("writes nothing while the account keeps no tracks", async () => {
const id = await storeWithoutTrack("hk-route-enrich-off");
const before = await getPrismaClient().workout.findUniqueOrThrow({
where: { id },
});
const second = await post([
workout("hk-route-enrich-off", { route: { geometry: TRACK } }),
]);
expect(second.data.entries).toEqual([{ index: 0, status: "duplicate" }]);
expect(
await getPrismaClient().workoutRoute.count({ where: { workoutId: id } }),
).toBe(0);
expect(
await getPrismaClient().workout.findUniqueOrThrow({ where: { id } }),
).toEqual(before);
});
it("reports one enriched for an entry that attaches series and track", async () => {
const id = await storeWithoutTrack("hk-route-enrich-both");
await keepRoutes(true);
const second = await post([
workout("hk-route-enrich-both", {
route: { geometry: TRACK },
samples: SERIES,
}),
]);
expect(second.data.entries).toEqual([{ index: 0, status: "enriched" }]);
const row = await getPrismaClient().workout.findUniqueOrThrow({
where: { id },
include: { route: true, samples: true },
});
expect(row.route).not.toBeNull();
expect(row.samples?.sampleCount).toBe(SERIES.length);
});
it("leaves one track row when two batches race for the same workout", async () => {
const id = await storeWithoutTrack("hk-route-enrich-race");
await keepRoutes(true);
const [a, b] = await Promise.all([
post([workout("hk-route-enrich-race", { route: { geometry: TRACK } })]),
post([
workout("hk-route-enrich-race", { route: { geometry: OTHER_TRACK } }),
]),
]);
expect(a.status).toBe(200);
expect(b.status).toBe(200);
const statuses = [a, b].map((r) => r.data.entries[0]?.status).sort();
expect(statuses).toEqual(["duplicate", "enriched"]);
expect(
await getPrismaClient().workoutRoute.count({ where: { workoutId: id } }),
).toBe(1);
const winner =
a.data.entries[0]?.status === "enriched" ? TRACK : OTHER_TRACK;
expect(await decrypt(id)).toEqual(winner);
});
it("settles as duplicate when a track lands between the probe and the write", async () => {
const id = await storeWithoutTrack("hk-route-enrich-gap");
const { prepareWorkouts } =
await import("@/lib/workouts/batch-ingest/entries");
const { planWorkoutWrites } =
await import("@/lib/workouts/batch-ingest/plan");
const { writeWorkoutPlan } =
await import("@/lib/workouts/batch-ingest/write");
const { encryptRouteGeometry } =
await import("@/lib/workouts/route-geometry-cipher");
const { createBatchWorkoutSchema } =
await import("@/lib/validations/workout");
const { prepared, results } = prepareWorkouts({
userId: TEST_USER_ID,
scoped: false,
keepRoutes: true,
// Through the route's own schema, so the entry is the validated shape.
workouts: createBatchWorkoutSchema.parse({
workouts: [
workout("hk-route-enrich-gap", { route: { geometry: TRACK } }),
],
}).workouts,
refusedSampleIndices: new Set(),
});
const plan = await planWorkoutWrites(TEST_USER_ID, prepared, results);
expect(plan.toEnrich).toHaveLength(1);
// Another writer gets its track in after the probe.
await getPrismaClient().workoutRoute.create({
data: {
workoutId: id,
geometryEncrypted: encryptRouteGeometry(OTHER_TRACK),
},
});
const outcome = await writeWorkoutPlan(TEST_USER_ID, plan, results);
expect(outcome.enrichedCount).toBe(0);
expect(results[0]).toEqual({ index: 0, status: "duplicate" });
expect(await decrypt(id)).toEqual(OTHER_TRACK);
const row = await getPrismaClient().workout.findUniqueOrThrow({
where: { id },
});
// The loser fills no climb either.
expect(row.elevationM).toBeNull();
});
});