mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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.
This commit is contained in:
1 parent
da3d29424d
commit
7b4c67b372
11 files changed
+705
-81
No files matched your search
@@ -26,6 +26,12 @@
|
||||
window. The native client can then tell "nothing in this window" from
|
||||
"further back than the Coach may read". The answer still names the limit,
|
||||
and the web trail keeps its wording.
|
||||
- Turning on workout routes no longer helps only workouts recorded from
|
||||
then on. When the app sends a route again for a workout that is already
|
||||
stored without one, the server now keeps it, sealed like any other
|
||||
track, and fills the workout's climb if it had none; the entry reports
|
||||
`enriched`, as it does for a heart-rate series. A workout that already
|
||||
has a route keeps it, and with routes switched off nothing is stored.
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
+17
-11
@@ -5289,10 +5289,13 @@ paths:
|
||||
accurately. An entry matching a stored workout is first-write-wins for the workout row, except a hand-entered
|
||||
one (`source: MANUAL` with a `manual:` externalId), which a resend with other values overwrites and reports
|
||||
`updated`; when it carries a `samples` array and the stored workout has no heart-rate series, the series is
|
||||
attached and the entry reports `enriched` (repeatable: a workout that already has a series is a no-op). An entry
|
||||
whose `samples` array fails validation is refused on its own with `reason: "invalid_samples"` — the rest of the
|
||||
batch still lands. The request body ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling
|
||||
receive a 413 with `workout.batch.payload_too_large`.
|
||||
attached and the entry reports `enriched` (repeatable: a workout that already has a series is a no-op). The same
|
||||
holds for a `route` while the account keeps tracks (`storeWorkoutRoutes`): a stored workout without a track gets
|
||||
the one the entry carries, sealed like any other, and the entry reports `enriched`; a workout that already has a
|
||||
track keeps it. An entry that attaches both reports one `enriched`. An entry whose `samples` array fails
|
||||
validation is refused on its own with `reason: "invalid_samples"` — the rest of the batch still lands. The
|
||||
request body ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling receive a 413 with
|
||||
`workout.batch.payload_too_large`.
|
||||
|
||||
|
||||
**Narrow token.** Also reachable with a `workouts:write` Bearer (minted at `POST /api/tokens/workouts`), the
|
||||
@@ -52685,8 +52688,8 @@ components:
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
description: Entries that wrote no workout row because the workout was already stored. The top-level counters count
|
||||
workout rows, so an entry reported as `enriched` counts here as well even though its heart-rate series was
|
||||
attached.
|
||||
workout rows, so an entry reported as `enriched` counts here as well even though its heart-rate series or
|
||||
GPS track was attached.
|
||||
skipped:
|
||||
type: array
|
||||
items:
|
||||
@@ -52729,11 +52732,14 @@ components:
|
||||
- updated
|
||||
- skipped
|
||||
description: "`inserted`: a new workout row. `duplicate`: the workout was already stored and nothing was written.
|
||||
`enriched`: the workout was already stored and the heart-rate series this entry carried was attached to it;
|
||||
the workout row itself was not written, and the entry also counts towards the top-level `duplicates`.
|
||||
`updated` (since v1.40.0): a workout entered by hand (`source: MANUAL`, `externalId` starting `manual:`) was
|
||||
sent again with different values and the stored row now carries them; no row was inserted, so it also counts
|
||||
towards `duplicates`. `skipped`: the server refused the entry, see `reason`."
|
||||
`enriched`: the workout was already stored and the heart-rate series or GPS track this entry carried (or
|
||||
both) was attached because the stored workout had none; no workout row was inserted, and the entry also
|
||||
counts towards the top-level `duplicates`. A track is attached only while the account keeps tracks
|
||||
(`storeWorkoutRoutes`), and when the stored workout had no elevation, the climb derived from the attached
|
||||
track fills `elevationM`; no other column of the workout row is written. `updated` (since v1.40.0): a
|
||||
workout entered by hand (`source: MANUAL`, `externalId` starting `manual:`) was sent again with different
|
||||
values and the stored row now carries them; no row was inserted, so it also counts towards `duplicates`.
|
||||
`skipped`: the server refused the entry, see `reason`."
|
||||
reason:
|
||||
description: "Why a `skipped` entry was refused: `unstable_external_id` for an id that does not survive a client
|
||||
restart, `invalid_samples` for a heart-rate series that failed validation."
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock("@/lib/db", () => ({
|
||||
createManyAndReturn: vi.fn(),
|
||||
},
|
||||
workoutRoute: {
|
||||
createMany: vi.fn(),
|
||||
createManyAndReturn: vi.fn(),
|
||||
},
|
||||
workoutSamples: {
|
||||
createManyAndReturn: vi.fn(),
|
||||
@@ -201,13 +201,16 @@ beforeEach(() => {
|
||||
}) as never,
|
||||
);
|
||||
vi.mocked(prisma.workout.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.workoutRoute.createMany).mockResolvedValue({ count: 0 });
|
||||
// The series write reports which rows landed, so an enrichment can tell
|
||||
// an attach from a row a concurrent writer got in first. The fake
|
||||
// echoes every row back as landed.
|
||||
// The series and track writes report which rows landed, so an
|
||||
// enrichment can tell an attach from a row a concurrent writer got in
|
||||
// first. The fakes echo every row back as landed.
|
||||
const echoLanded = (async (args: { data: Array<{ workoutId: string }> }) =>
|
||||
args.data.map((row) => ({ workoutId: row.workoutId }))) as never;
|
||||
vi.mocked(prisma.workoutRoute.createManyAndReturn).mockImplementation(
|
||||
echoLanded,
|
||||
);
|
||||
vi.mocked(prisma.workoutSamples.createManyAndReturn).mockImplementation(
|
||||
(async (args: { data: Array<{ workoutId: string }> }) =>
|
||||
args.data.map((row) => ({ workoutId: row.workoutId }))) as never,
|
||||
echoLanded,
|
||||
);
|
||||
// v1.4.43 — default to no per-user source-priority override so
|
||||
// the write-time picker walks the canonical default ladder. Tests
|
||||
@@ -550,7 +553,7 @@ describe("POST /api/workouts/batch — workout-route opt-in", () => {
|
||||
};
|
||||
// Same per-entry status as a stored track: the client needs no branch.
|
||||
expect(body.data.entries[0]?.status).toBe("inserted");
|
||||
expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled();
|
||||
expect(prisma.workoutRoute.createManyAndReturn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops the track when the account row carries no opt-in at all", async () => {
|
||||
@@ -564,7 +567,7 @@ describe("POST /api/workouts/batch — workout-route opt-in", () => {
|
||||
|
||||
const res = await POST(makeRequest({ workouts: [withRoute()] }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled();
|
||||
expect(prisma.workoutRoute.createManyAndReturn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -599,8 +602,9 @@ describe("POST /api/workouts/batch — nested route attachment", () => {
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.workoutRoute.createMany).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.workoutRoute.createMany).mock.calls[0]?.[0];
|
||||
expect(prisma.workoutRoute.createManyAndReturn).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.workoutRoute.createManyAndReturn).mock
|
||||
.calls[0]?.[0];
|
||||
const rows = (
|
||||
call as {
|
||||
data: Array<{
|
||||
@@ -654,7 +658,7 @@ describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => {
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
// The route table is untouched — an indoor workout has no GPS route.
|
||||
expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled();
|
||||
expect(prisma.workoutRoute.createManyAndReturn).not.toHaveBeenCalled();
|
||||
// The HR series lands in the dedicated child table by FK.
|
||||
expect(prisma.workoutSamples.createManyAndReturn).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.workoutSamples.createManyAndReturn).mock
|
||||
@@ -703,10 +707,10 @@ describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => {
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.workoutRoute.createMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.workoutRoute.createManyAndReturn).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.workoutSamples.createManyAndReturn).toHaveBeenCalledTimes(1);
|
||||
const routeRows = (
|
||||
vi.mocked(prisma.workoutRoute.createMany).mock.calls[0]?.[0] as {
|
||||
vi.mocked(prisma.workoutRoute.createManyAndReturn).mock.calls[0]?.[0] as {
|
||||
data: Array<{ workoutId: string }>;
|
||||
}
|
||||
).data;
|
||||
|
||||
@@ -27,7 +27,7 @@ vi.mock("@/lib/db", () => ({
|
||||
|
||||
const txClient = {
|
||||
workout: { createManyAndReturn: vi.fn() },
|
||||
workoutRoute: { createMany: vi.fn() },
|
||||
workoutRoute: { createManyAndReturn: vi.fn() },
|
||||
workoutSamples: { createMany: vi.fn() },
|
||||
};
|
||||
|
||||
@@ -133,7 +133,7 @@ beforeEach(() => {
|
||||
}));
|
||||
},
|
||||
);
|
||||
txClient.workoutRoute.createMany.mockResolvedValue({ count: 0 });
|
||||
txClient.workoutRoute.createManyAndReturn.mockResolvedValue([]);
|
||||
txClient.workoutSamples.createMany.mockResolvedValue({ count: 0 });
|
||||
});
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
* - `Content-Length` ceiling at 5 MB before parsing — anything
|
||||
* larger returns 413 so the iOS client falls back to one workout
|
||||
* per call.
|
||||
* - Per-entry status (`inserted | duplicate | enriched | skipped`) so
|
||||
* the iOS sync cursor can checkpoint accurately.
|
||||
* - Per-entry status (`inserted | duplicate | enriched | updated |
|
||||
* skipped`) so the iOS sync cursor can checkpoint accurately.
|
||||
*
|
||||
* Idempotency contract:
|
||||
* - HTTP-level via `Idempotency-Key` (replays cached envelope).
|
||||
@@ -45,12 +45,15 @@
|
||||
* composite index. Re-posting the same batch surfaces duplicates
|
||||
* rather than failing the call.
|
||||
*
|
||||
* Heart-rate series enrichment: a re-post that matches a stored
|
||||
* workout and carries a `samples` array attaches that series when the
|
||||
* workout has none. The workout row itself stays first-write-wins —
|
||||
* nothing on it is written, not one column — and a workout that already
|
||||
* holds a series is a no-op, so the client's history sweep is repeatable
|
||||
* as often as it likes. See `src/lib/workouts/batch-ingest/plan.ts`.
|
||||
* Enrichment: a re-post that matches a stored workout and carries a
|
||||
* `samples` array attaches that series when the workout has none, and one
|
||||
* that carries a `route` attaches that track when the workout has none and
|
||||
* the account keeps tracks. The workout row itself stays first-write-wins:
|
||||
* a series writes nothing on it, an attached track fills only an empty
|
||||
* `elevationM` with its climb (as an insert would). A workout that already
|
||||
* holds a series or a track keeps it, so the client's history sweep is
|
||||
* repeatable as often as it likes. See
|
||||
* `src/lib/workouts/batch-ingest/plan.ts`.
|
||||
*
|
||||
* Race reconciliation uses PostgreSQL's `INSERT ... RETURNING` through
|
||||
* Prisma's `createManyAndReturn`. The returned composite keys identify the
|
||||
|
||||
@@ -28,9 +28,9 @@ import { isoDateTime } from "@/lib/validations/datetime";
|
||||
// refused: an unusable external id, or a `samples` array that failed
|
||||
// validation (`reason: "invalid_samples"`).
|
||||
//
|
||||
// `enriched` says the entry matched a stored workout that had no
|
||||
// heart-rate series and the series it carried was attached. The
|
||||
// workout row itself was not written, so such an entry also counts
|
||||
// `enriched` says the entry matched a stored workout that lacked the
|
||||
// heart-rate series or the GPS track it carried, and that part was
|
||||
// attached. No workout row was inserted, so such an entry also counts
|
||||
// towards `duplicates`. That rule is part of the published contract,
|
||||
// not just a note here: it rides the field descriptions below, because
|
||||
// a client summing the counters has to know which bucket an
|
||||
@@ -41,7 +41,7 @@ const workoutBatchEntryResult = z
|
||||
status: z
|
||||
.enum(["inserted", "duplicate", "enriched", "updated", "skipped"])
|
||||
.describe(
|
||||
"`inserted`: a new workout row. `duplicate`: the workout was already stored and nothing was written. `enriched`: the workout was already stored and the heart-rate series this entry carried was attached to it; the workout row itself was not written, and the entry also counts towards the top-level `duplicates`. `updated` (since v1.40.0): a workout entered by hand (`source: MANUAL`, `externalId` starting `manual:`) was sent again with different values and the stored row now carries them; no row was inserted, so it also counts towards `duplicates`. `skipped`: the server refused the entry, see `reason`.",
|
||||
"`inserted`: a new workout row. `duplicate`: the workout was already stored and nothing was written. `enriched`: the workout was already stored and the heart-rate series or GPS track this entry carried (or both) was attached because the stored workout had none; no workout row was inserted, and the entry also counts towards the top-level `duplicates`. A track is attached only while the account keeps tracks (`storeWorkoutRoutes`), and when the stored workout had no elevation, the climb derived from the attached track fills `elevationM`; no other column of the workout row is written. `updated` (since v1.40.0): a workout entered by hand (`source: MANUAL`, `externalId` starting `manual:`) was sent again with different values and the stored row now carries them; no row was inserted, so it also counts towards `duplicates`. `skipped`: the server refused the entry, see `reason`.",
|
||||
),
|
||||
reason: z
|
||||
.string()
|
||||
@@ -71,7 +71,7 @@ const workoutBatchResponse = z
|
||||
.int()
|
||||
.nonnegative()
|
||||
.describe(
|
||||
"Entries that wrote no workout row because the workout was already stored. The top-level counters count workout rows, so an entry reported as `enriched` counts here as well even though its heart-rate series was attached.",
|
||||
"Entries that wrote no workout row because the workout was already stored. The top-level counters count workout rows, so an entry reported as `enriched` counts here as well even though its heart-rate series or GPS track was attached.",
|
||||
),
|
||||
skipped: z.array(
|
||||
z.object({
|
||||
@@ -440,7 +440,7 @@ export const workoutPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Measurements"],
|
||||
summary: "Typed workout batch ingest (v1.4.25 W16b)",
|
||||
description:
|
||||
'Server-side ingest endpoint for HKWorkout records (iOS) and Withings activity rows (server-to-server). Up to 100 workouts per call; nested route geometry (GeoJSON LineString) is capped at 20 000 points and stored in a 1:1 `WorkoutRoute` row keyed by `workoutId`. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status (`inserted | duplicate | enriched | updated | skipped`) lets the iOS sync cursor checkpoint accurately. An entry matching a stored workout is first-write-wins for the workout row, except a hand-entered one (`source: MANUAL` with a `manual:` externalId), which a resend with other values overwrites and reports `updated`; when it carries a `samples` array and the stored workout has no heart-rate series, the series is attached and the entry reports `enriched` (repeatable: a workout that already has a series is a no-op). An entry whose `samples` array fails validation is refused on its own with `reason: "invalid_samples"` — the rest of the batch still lands. The request body ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling receive a 413 with `workout.batch.payload_too_large`.\n\n' +
|
||||
'Server-side ingest endpoint for HKWorkout records (iOS) and Withings activity rows (server-to-server). Up to 100 workouts per call; nested route geometry (GeoJSON LineString) is capped at 20 000 points and stored in a 1:1 `WorkoutRoute` row keyed by `workoutId`. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status (`inserted | duplicate | enriched | updated | skipped`) lets the iOS sync cursor checkpoint accurately. An entry matching a stored workout is first-write-wins for the workout row, except a hand-entered one (`source: MANUAL` with a `manual:` externalId), which a resend with other values overwrites and reports `updated`; when it carries a `samples` array and the stored workout has no heart-rate series, the series is attached and the entry reports `enriched` (repeatable: a workout that already has a series is a no-op). The same holds for a `route` while the account keeps tracks (`storeWorkoutRoutes`): a stored workout without a track gets the one the entry carries, sealed like any other, and the entry reports `enriched`; a workout that already has a track keeps it. An entry that attaches both reports one `enriched`. An entry whose `samples` array fails validation is refused on its own with `reason: "invalid_samples"` — the rest of the batch still lands. The request body ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling receive a 413 with `workout.batch.payload_too_large`.\n\n' +
|
||||
"**Narrow token.** Also reachable with a `workouts:write` Bearer (minted at `POST /api/tokens/workouts`), the supported way to push workouts from a bridge that is not the phone. This route is the scope's only one. Such a credential writes only on its owner's own record; its workouts are attributed `EXTERNAL`, resolved from the credential, and it may not name a source itself, so a batch in which any entry carries one, `MANUAL` and `APPLE_HEALTH` alike, is refused 422 (`workout.batch.source_not_permitted`) and nothing is written. `EXTERNAL` gives those writes their own `(userId, source, externalId)` namespace, so a bridge's ids can never collide with the phone's. Every other narrow token, `measurements:write` included, is refused 403.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* The planner's enrichment arm: a stored workout gets the series or the
|
||||
* track it lacks from an entry that carries one, once per workout, and
|
||||
* nothing it already holds.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { findMany } = vi.hoisted(() => ({
|
||||
findMany: vi.fn<(args: unknown) => Promise<unknown[]>>(),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { workout: { findMany } },
|
||||
}));
|
||||
|
||||
import type { EntryResult, PreparedWorkout } from "../entries";
|
||||
import { planWorkoutWrites } from "../plan";
|
||||
|
||||
const USER = "user-plan";
|
||||
const ROUTE = {
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: [
|
||||
[11.077, 49.452, 300],
|
||||
[11.078, 49.453, 312],
|
||||
],
|
||||
},
|
||||
sampleTimestamps: null,
|
||||
};
|
||||
const SAMPLES = {
|
||||
samples: [{ t: "2026-05-14T06:30:00.000Z", hr: 121 }],
|
||||
sampleCount: 1,
|
||||
};
|
||||
|
||||
function entry(
|
||||
index: number,
|
||||
externalId: string,
|
||||
parts: Partial<Pick<PreparedWorkout, "route" | "samples">> = {},
|
||||
): PreparedWorkout {
|
||||
return {
|
||||
index,
|
||||
row: {
|
||||
userId: USER,
|
||||
sportType: "running",
|
||||
startedAt: new Date("2026-05-14T06:30:00.000Z"),
|
||||
endedAt: new Date("2026-05-14T07:15:00.000Z"),
|
||||
durationSec: 2700,
|
||||
source: "APPLE_HEALTH",
|
||||
externalId,
|
||||
},
|
||||
route: parts.route ?? null,
|
||||
samples: parts.samples ?? null,
|
||||
dedupKey: { source: "APPLE_HEALTH", externalId },
|
||||
};
|
||||
}
|
||||
|
||||
function stored(
|
||||
externalId: string,
|
||||
has: { route?: boolean; samples?: boolean } = {},
|
||||
) {
|
||||
return {
|
||||
id: `w-${externalId}`,
|
||||
source: "APPLE_HEALTH",
|
||||
externalId,
|
||||
route: has.route ? { workoutId: `w-${externalId}` } : null,
|
||||
samples: has.samples ? { workoutId: `w-${externalId}` } : null,
|
||||
sportType: "running",
|
||||
startedAt: new Date("2026-05-14T06:30:00.000Z"),
|
||||
endedAt: new Date("2026-05-14T07:15:00.000Z"),
|
||||
durationSec: 2700,
|
||||
totalEnergyKcal: null,
|
||||
totalDistanceM: null,
|
||||
avgHeartRate: null,
|
||||
maxHeartRate: null,
|
||||
minHeartRate: null,
|
||||
stepCount: null,
|
||||
elevationM: null,
|
||||
pauseDurationSec: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function plan(rows: unknown[], survivors: PreparedWorkout[]) {
|
||||
findMany.mockResolvedValue(rows);
|
||||
const results: EntryResult[] = new Array(survivors.length);
|
||||
const written = await planWorkoutWrites(USER, survivors, results);
|
||||
return { plan: written, results };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
findMany.mockReset();
|
||||
});
|
||||
|
||||
describe("planWorkoutWrites: track enrichment", () => {
|
||||
it("hands a stored workout without a track the one the entry carries", async () => {
|
||||
const { plan: p, results } = await plan(
|
||||
[stored("a")],
|
||||
[entry(0, "a", { route: ROUTE })],
|
||||
);
|
||||
expect(p.toEnrich).toEqual([
|
||||
{ index: 0, workoutId: "w-a", samples: null, route: ROUTE },
|
||||
]);
|
||||
expect(p.toInsert).toEqual([]);
|
||||
expect(p.duplicates).toBe(0);
|
||||
// Settled after the write, from what landed.
|
||||
expect(results[0]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves a stored track alone and settles the entry as a duplicate", async () => {
|
||||
const { plan: p, results } = await plan(
|
||||
[stored("a", { route: true })],
|
||||
[entry(0, "a", { route: ROUTE })],
|
||||
);
|
||||
expect(p.toEnrich).toEqual([]);
|
||||
expect(p.duplicates).toBe(1);
|
||||
expect(results[0]).toEqual({ index: 0, status: "duplicate" });
|
||||
});
|
||||
|
||||
it("plans one enrichment carrying both parts when both are missing", async () => {
|
||||
const { plan: p } = await plan(
|
||||
[stored("a")],
|
||||
[entry(0, "a", { route: ROUTE, samples: SAMPLES })],
|
||||
);
|
||||
expect(p.toEnrich).toEqual([
|
||||
{ index: 0, workoutId: "w-a", samples: SAMPLES, route: ROUTE },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops only the part the stored workout already holds", async () => {
|
||||
const { plan: p } = await plan(
|
||||
[stored("a", { samples: true }), stored("b", { route: true })],
|
||||
[
|
||||
entry(0, "a", { route: ROUTE, samples: SAMPLES }),
|
||||
entry(1, "b", { route: ROUTE, samples: SAMPLES }),
|
||||
],
|
||||
);
|
||||
expect(p.toEnrich).toEqual([
|
||||
{ index: 0, workoutId: "w-a", samples: null, route: ROUTE },
|
||||
{ index: 1, workoutId: "w-b", samples: SAMPLES, route: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("plans nothing for an entry whose track the opt-in already dropped", async () => {
|
||||
// `prepareWorkouts` hands a null route while the account keeps none.
|
||||
const { plan: p, results } = await plan([stored("a")], [entry(0, "a")]);
|
||||
expect(p.toEnrich).toEqual([]);
|
||||
expect(results[0]).toEqual({ index: 0, status: "duplicate" });
|
||||
});
|
||||
|
||||
it("lets only the first entry of a batch enrich a given workout", async () => {
|
||||
const { plan: p, results } = await plan(
|
||||
[stored("a")],
|
||||
[entry(0, "a", { samples: SAMPLES }), entry(1, "a", { route: ROUTE })],
|
||||
);
|
||||
expect(p.toEnrich).toEqual([
|
||||
{ index: 0, workoutId: "w-a", samples: SAMPLES, route: null },
|
||||
]);
|
||||
expect(results[1]).toEqual({ index: 1, status: "duplicate" });
|
||||
});
|
||||
|
||||
it("asks the probe whether a track is stored", async () => {
|
||||
await plan([], [entry(0, "a", { route: ROUTE })]);
|
||||
const args = findMany.mock.calls[0]?.[0] as {
|
||||
select: Record<string, unknown>;
|
||||
};
|
||||
expect(args.select.route).toEqual({ select: { workoutId: true } });
|
||||
});
|
||||
});
|
||||
@@ -20,9 +20,9 @@ export type WorkoutEntry = CreateBatchWorkoutInput["workouts"][number];
|
||||
* Per-entry outcome the iOS client uses to advance its sync cursor.
|
||||
* `inserted`, `duplicate` and `enriched` all indicate the row landed (or
|
||||
* was already present); the client can checkpoint past them identically.
|
||||
* `enriched` additionally says the entry's heart-rate series was attached
|
||||
* to a workout that had none, which is what lets a history sweep count
|
||||
* its own progress. `skipped` covers entries the server refused: an
|
||||
* `enriched` additionally says the entry's heart-rate series or GPS track
|
||||
* (or both) was attached to a stored workout that had none, which is what
|
||||
* lets a history sweep count its own progress. `skipped` covers entries the server refused: an
|
||||
* unusable external id, or a `samples` array that failed validation.
|
||||
* `updated` says a hand-entered workout (`manual:` id, source MANUAL) was
|
||||
* sent again with different values and the stored row now carries them.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* What each surviving entry of a workout batch does to the store: insert a
|
||||
* new workout, hand a stored one its heart-rate series, correct a
|
||||
* hand-entered one in place, or nothing (a duplicate).
|
||||
* new workout, hand a stored one the heart-rate series or GPS track it lacks,
|
||||
* correct a hand-entered one in place, or nothing (a duplicate).
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
@@ -62,11 +62,16 @@ function manualOverwriteChanges(
|
||||
|
||||
export interface WritePlan {
|
||||
toInsert: PreparedWorkout[];
|
||||
/** Stored workouts this batch hands a heart-rate series to. */
|
||||
/**
|
||||
* Stored workouts this batch hands what they lack: a heart-rate series,
|
||||
* a GPS track, or both. Each part is null when the entry carried none or
|
||||
* the stored workout already has one.
|
||||
*/
|
||||
toEnrich: Array<{
|
||||
index: number;
|
||||
workoutId: string;
|
||||
samples: NonNullable<PreparedWorkout["samples"]>;
|
||||
samples: PreparedWorkout["samples"];
|
||||
route: PreparedWorkout["route"];
|
||||
}>;
|
||||
/** Hand-entered workouts this batch edits in place. */
|
||||
toOverwrite: Array<{
|
||||
@@ -82,6 +87,7 @@ export interface WritePlan {
|
||||
type StoredWorkout = {
|
||||
id: string;
|
||||
hasSeries: boolean;
|
||||
hasRoute: boolean;
|
||||
values: ManualOverwriteValues;
|
||||
};
|
||||
|
||||
@@ -89,8 +95,8 @@ type StoredWorkout = {
|
||||
* Only the write-time dedup survivors are probed against existing rows; the
|
||||
* dropped twins already carry a `duplicate` status and never reach the DB.
|
||||
*
|
||||
* The same probe answers both questions: does the row exist, and does it
|
||||
* already carry a series. `id` comes along so an enrichment can address the
|
||||
* The same probe answers every question: does the row exist, and does it
|
||||
* already carry a series and a track. `id` comes along so an enrichment can address the
|
||||
* stored workout without a second round trip.
|
||||
*/
|
||||
async function readStoredWorkouts(
|
||||
@@ -115,6 +121,7 @@ async function readStoredWorkouts(
|
||||
source: true,
|
||||
externalId: true,
|
||||
samples: { select: { workoutId: true } },
|
||||
route: { select: { workoutId: true } },
|
||||
sportType: true,
|
||||
startedAt: true,
|
||||
endedAt: true,
|
||||
@@ -137,6 +144,7 @@ async function readStoredWorkouts(
|
||||
existingByKey.set(`${row.source}::${row.externalId}`, {
|
||||
id: row.id,
|
||||
hasSeries: row.samples !== null,
|
||||
hasRoute: row.route !== null,
|
||||
values: {
|
||||
sportType: row.sportType,
|
||||
startedAt: row.startedAt,
|
||||
@@ -197,17 +205,23 @@ export async function planWorkoutWrites(
|
||||
plan.duplicates += 1;
|
||||
}
|
||||
} else if (known) {
|
||||
if (p.samples && !known.hasSeries && !enrichClaimed.has(known.id)) {
|
||||
// `p.route` is already null while the account keeps no tracks, so
|
||||
// the opt-in needs no second check here.
|
||||
const samples = p.samples && !known.hasSeries ? p.samples : null;
|
||||
const route = p.route && !known.hasRoute ? p.route : null;
|
||||
if ((samples || route) && !enrichClaimed.has(known.id)) {
|
||||
enrichClaimed.add(known.id);
|
||||
// Enrichment. The status is assigned after the write, because
|
||||
// a concurrent writer may get the series in first — in which
|
||||
// case this entry is the plain duplicate it always was. A
|
||||
// second entry of this batch naming the same workout takes the
|
||||
// duplicate arm above: one workout, one series, one `enriched`.
|
||||
// a concurrent writer may get the series or the track in first —
|
||||
// in which case this entry is the plain duplicate it always was.
|
||||
// A second entry of this batch naming the same workout takes the
|
||||
// duplicate arm: one workout, one series, one track, one
|
||||
// `enriched`.
|
||||
plan.toEnrich.push({
|
||||
index: p.index,
|
||||
workoutId: known.id,
|
||||
samples: p.samples,
|
||||
samples,
|
||||
route,
|
||||
});
|
||||
} else {
|
||||
results[p.index] = { index: p.index, status: "duplicate" };
|
||||
|
||||
@@ -7,6 +7,7 @@ import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { lockPersonalRecordsForUser } from "@/lib/personal-records/pr-detection-worker";
|
||||
import { encryptRouteGeometry } from "@/lib/workouts/route-geometry-cipher";
|
||||
import { routeClimbM } from "@/lib/workouts/route-climb";
|
||||
|
||||
import type { EntryResult, PreparedWorkout } from "./entries";
|
||||
import { MANUAL_OVERWRITE_FIELDS, type WritePlan } from "./plan";
|
||||
@@ -47,13 +48,19 @@ export async function writeWorkoutPlan(
|
||||
const { insertedIdByIndex } = outcome;
|
||||
/** Workout ids whose series row this request actually wrote. */
|
||||
const seriesLanded = new Set<string>();
|
||||
/** Workout ids whose track row this request actually wrote. */
|
||||
const routesLanded = new Set<string>();
|
||||
/** Batch indices whose manual edit this request actually wrote. */
|
||||
const overwritten = new Set<number>();
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await overwriteManualWorkouts(tx, userId, toOverwrite, overwritten);
|
||||
await insertWorkouts(tx, toInsert, insertedIdByIndex);
|
||||
await insertChildren(tx, plan, insertedIdByIndex, seriesLanded);
|
||||
await insertChildren(tx, plan, insertedIdByIndex, {
|
||||
seriesLanded,
|
||||
routesLanded,
|
||||
});
|
||||
await fillClimbFromAttachedRoutes(tx, userId, toEnrich, routesLanded);
|
||||
});
|
||||
|
||||
outcome.insertedCount = insertedIdByIndex.size;
|
||||
@@ -68,13 +75,16 @@ export async function writeWorkoutPlan(
|
||||
|
||||
// An enriched entry is still a duplicate of the workout row: the
|
||||
// top-level counters count rows, and no row was inserted for it.
|
||||
// The per-entry status carries what happened to the series.
|
||||
// The per-entry status carries what happened to the series and the
|
||||
// track: one `enriched` when either (or both) landed.
|
||||
for (const e of toEnrich) {
|
||||
const landed =
|
||||
seriesLanded.has(e.workoutId) || routesLanded.has(e.workoutId);
|
||||
results[e.index] = {
|
||||
index: e.index,
|
||||
status: seriesLanded.has(e.workoutId) ? "enriched" : "duplicate",
|
||||
status: landed ? "enriched" : "duplicate",
|
||||
};
|
||||
if (seriesLanded.has(e.workoutId)) outcome.enrichedCount += 1;
|
||||
if (landed) outcome.enrichedCount += 1;
|
||||
outcome.duplicates += 1;
|
||||
}
|
||||
|
||||
@@ -194,29 +204,36 @@ async function insertWorkouts(
|
||||
}
|
||||
}
|
||||
|
||||
/** One track row, the same for a new workout and a stored one. */
|
||||
function routeRow(
|
||||
workoutId: string,
|
||||
route: NonNullable<PreparedWorkout["route"]>,
|
||||
): Prisma.WorkoutRouteCreateManyInput {
|
||||
return {
|
||||
workoutId,
|
||||
// The GPS track is stored sealed only; the readable `geometry`
|
||||
// column stays NULL.
|
||||
geometryEncrypted: encryptRouteGeometry(route.geometry),
|
||||
sampleTimestamps:
|
||||
route.sampleTimestamps === null
|
||||
? Prisma.JsonNull
|
||||
: (route.sampleTimestamps as Prisma.InputJsonValue),
|
||||
};
|
||||
}
|
||||
|
||||
async function insertChildren(
|
||||
tx: Tx,
|
||||
plan: WritePlan,
|
||||
insertedIdByIndex: Map<number, string>,
|
||||
seriesLanded: Set<string>,
|
||||
landed: { seriesLanded: Set<string>; routesLanded: Set<string> },
|
||||
): Promise<void> {
|
||||
const { seriesLanded, routesLanded } = landed;
|
||||
const routesToInsert: Prisma.WorkoutRouteCreateManyInput[] = [];
|
||||
const samplesToInsert: Prisma.WorkoutSamplesCreateManyInput[] = [];
|
||||
for (const p of plan.toInsert) {
|
||||
const id = insertedIdByIndex.get(p.index);
|
||||
if (!id) continue;
|
||||
if (p.route) {
|
||||
routesToInsert.push({
|
||||
workoutId: id,
|
||||
// The GPS track is stored sealed only; the readable `geometry`
|
||||
// column stays NULL.
|
||||
geometryEncrypted: encryptRouteGeometry(p.route.geometry),
|
||||
sampleTimestamps:
|
||||
p.route.sampleTimestamps === null
|
||||
? Prisma.JsonNull
|
||||
: (p.route.sampleTimestamps as Prisma.InputJsonValue),
|
||||
});
|
||||
}
|
||||
if (p.route) routesToInsert.push(routeRow(id, p.route));
|
||||
if (p.samples) {
|
||||
samplesToInsert.push({
|
||||
workoutId: id,
|
||||
@@ -226,24 +243,32 @@ async function insertChildren(
|
||||
}
|
||||
}
|
||||
|
||||
// Enrichment rides the SAME child write as a fresh workout's
|
||||
// series — one mechanism, one place where a series is stored.
|
||||
// Enrichment rides the SAME child writes as a fresh workout's
|
||||
// series and track — one mechanism, one place where each is stored.
|
||||
// The only difference is where the `workoutId` came from: the
|
||||
// pre-flight probe instead of this request's INSERT ... RETURNING.
|
||||
// Nothing here writes to the workout row.
|
||||
for (const e of plan.toEnrich) {
|
||||
samplesToInsert.push({
|
||||
workoutId: e.workoutId,
|
||||
samples: e.samples.samples as Prisma.InputJsonValue,
|
||||
sampleCount: e.samples.sampleCount,
|
||||
});
|
||||
if (e.samples) {
|
||||
samplesToInsert.push({
|
||||
workoutId: e.workoutId,
|
||||
samples: e.samples.samples as Prisma.InputJsonValue,
|
||||
sampleCount: e.samples.sampleCount,
|
||||
});
|
||||
}
|
||||
if (e.route) routesToInsert.push(routeRow(e.workoutId, e.route));
|
||||
}
|
||||
|
||||
for (let i = 0; i < routesToInsert.length; i += CHUNK) {
|
||||
await tx.workoutRoute.createMany({
|
||||
// `workoutId` is unique here too: a workout that gained a track
|
||||
// between the probe and this write (a concurrent batch, the Apple
|
||||
// Health import) keeps it, and RETURNING leaves this one out.
|
||||
const landedRoutes = await tx.workoutRoute.createManyAndReturn({
|
||||
data: routesToInsert.slice(i, i + CHUNK),
|
||||
skipDuplicates: true,
|
||||
select: { workoutId: true },
|
||||
});
|
||||
for (const row of landedRoutes) routesLanded.add(row.workoutId);
|
||||
}
|
||||
for (let i = 0; i < samplesToInsert.length; i += CHUNK) {
|
||||
// RETURNING names the rows that actually landed. `workoutId` is
|
||||
@@ -258,3 +283,26 @@ async function insertChildren(
|
||||
for (const row of landed) seriesLanded.add(row.workoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A track attached to a stored workout fills its climb the way a new
|
||||
* workout's does (`preparedFor`), because the readers take the climb from
|
||||
* the workout row and never open the track for it. Only where the column
|
||||
* is still empty: an elevation the source sent stays.
|
||||
*/
|
||||
async function fillClimbFromAttachedRoutes(
|
||||
tx: Tx,
|
||||
userId: string,
|
||||
toEnrich: WritePlan["toEnrich"],
|
||||
routesLanded: ReadonlySet<string>,
|
||||
): Promise<void> {
|
||||
for (const e of toEnrich) {
|
||||
if (!e.route || !routesLanded.has(e.workoutId)) continue;
|
||||
const climb = routeClimbM(e.route.geometry);
|
||||
if (climb === null) continue;
|
||||
await tx.workout.updateMany({
|
||||
where: { id: e.workoutId, userId, elevationM: null },
|
||||
data: { elevationM: climb },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user