mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
Two entries carrying the same external id and both a `samples` array reached the enrichment arm one after the other, and both read the same landed workout id back, so the response claimed two attaches where the database had written one. The second entry now takes the duplicate arm: a workout is claimed by the first entry of the batch that reaches it.
415 lines
13 KiB
TypeScript
415 lines
13 KiB
TypeScript
/**
|
|
* Integration suite for the heart-rate series enrichment path of
|
|
* `POST /api/workouts/batch`, against a real Postgres in a testcontainer.
|
|
*
|
|
* The case: the native client posted a workout before it learned to send
|
|
* the per-workout HR series. The row exists, the curve does not. A later
|
|
* re-post of the SAME `(source, externalId)` carrying a `samples` array
|
|
* attaches the series to the stored workout and reports the entry as
|
|
* `enriched`.
|
|
*
|
|
* What the suite pins:
|
|
* - The attach happens once and reports `enriched`
|
|
* - A second re-post over an existing series is a no-op and never
|
|
* reports `enriched` twice
|
|
* - The workout row is byte-identical across the enrichment — every
|
|
* column, `updatedAt` included, compared as a whole row
|
|
* - A malformed `samples` array is refused per entry; the rest of the
|
|
* batch still lands
|
|
* - A batch mixing insert / enrich / duplicate / skip reports each
|
|
* entry with its own status
|
|
*/
|
|
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-enrich-integration-32-bytes-min-1234567890";
|
|
process.env.ENCRYPTION_KEY =
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
const TEST_USER_ID = "user-workout-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);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
await getPrismaClient().user.create({
|
|
data: {
|
|
id: TEST_USER_ID,
|
|
username: "workout-enrich",
|
|
email: "workout-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 WorkoutFixture {
|
|
sportType?: string;
|
|
startedAt?: string;
|
|
endedAt?: string;
|
|
totalEnergyKcal?: number;
|
|
totalDistanceM?: number;
|
|
avgHeartRate?: number;
|
|
maxHeartRate?: number;
|
|
minHeartRate?: number;
|
|
source?: string;
|
|
externalId?: string;
|
|
metadata?: Record<string, unknown>;
|
|
samples?: unknown;
|
|
}
|
|
|
|
interface BatchResponse {
|
|
processed: number;
|
|
inserted: number;
|
|
duplicates: number;
|
|
skipped: Array<{ index: number; reason: string }>;
|
|
entries: Array<{ index: number; status: string; reason?: string }>;
|
|
}
|
|
|
|
function makeRequest(body: { workouts: WorkoutFixture[] }): NextRequest {
|
|
return new NextRequest("http://localhost/api/workouts/batch", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
function baseWorkout(
|
|
externalId: string,
|
|
overrides: WorkoutFixture = {},
|
|
): WorkoutFixture {
|
|
return {
|
|
sportType: "running",
|
|
startedAt: "2026-05-14T06:30:00.000Z",
|
|
endedAt: "2026-05-14T07:15:00.000Z",
|
|
source: "APPLE_HEALTH",
|
|
externalId,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const SERIES = [
|
|
{ t: "2026-05-14T06:30:00.000Z", hr: 121 },
|
|
{ t: "2026-05-14T06:30:05.000Z", hr: 128, cadence: 168 },
|
|
{ t: "2026-05-14T06:30:10.000Z", hr: 133 },
|
|
];
|
|
|
|
async function post(body: {
|
|
workouts: WorkoutFixture[];
|
|
}): Promise<{ status: number; data: BatchResponse }> {
|
|
const { POST } = await import("@/app/api/workouts/batch/route");
|
|
const res = await POST(makeRequest(body));
|
|
const json = (await res.json()) as { data: BatchResponse };
|
|
return { status: res.status, data: json.data };
|
|
}
|
|
|
|
describe("POST /api/workouts/batch — HR series enrichment (real Postgres)", () => {
|
|
it("attaches the series to a stored workout that has none and reports enriched", async () => {
|
|
const first = await post({
|
|
workouts: [baseWorkout("hk-uuid-enrich-001")],
|
|
});
|
|
expect(first.status).toBe(200);
|
|
expect(first.data.inserted).toBe(1);
|
|
expect(
|
|
await getPrismaClient().workoutSamples.count({
|
|
where: { workout: { userId: TEST_USER_ID } },
|
|
}),
|
|
).toBe(0);
|
|
|
|
const second = await post({
|
|
workouts: [baseWorkout("hk-uuid-enrich-001", { samples: SERIES })],
|
|
});
|
|
expect(second.status).toBe(200);
|
|
expect(second.data.inserted).toBe(0);
|
|
expect(second.data.entries).toEqual([{ index: 0, status: "enriched" }]);
|
|
|
|
const stored = await getPrismaClient().workout.findFirst({
|
|
where: { userId: TEST_USER_ID, externalId: "hk-uuid-enrich-001" },
|
|
include: { samples: true },
|
|
});
|
|
expect(stored?.samples).not.toBeNull();
|
|
expect(stored?.samples?.sampleCount).toBe(3);
|
|
expect(stored?.samples?.samples).toEqual(SERIES);
|
|
|
|
// The workout row count is unchanged — enrichment never mints a row.
|
|
expect(
|
|
await getPrismaClient().workout.count({
|
|
where: { userId: TEST_USER_ID },
|
|
}),
|
|
).toBe(1);
|
|
});
|
|
|
|
it("treats a re-post over an existing series as a no-op and never reports enriched twice", async () => {
|
|
await post({ workouts: [baseWorkout("hk-uuid-enrich-002")] });
|
|
const enriching = await post({
|
|
workouts: [baseWorkout("hk-uuid-enrich-002", { samples: SERIES })],
|
|
});
|
|
expect(enriching.data.entries[0]?.status).toBe("enriched");
|
|
|
|
const storedSeries = await getPrismaClient().workoutSamples.findFirst({
|
|
where: { workout: { externalId: "hk-uuid-enrich-002" } },
|
|
});
|
|
expect(storedSeries).not.toBeNull();
|
|
|
|
const again = await post({
|
|
workouts: [
|
|
baseWorkout("hk-uuid-enrich-002", {
|
|
samples: [
|
|
...SERIES,
|
|
{ t: "2026-05-14T06:30:15.000Z", hr: 141 },
|
|
{ t: "2026-05-14T06:30:20.000Z", hr: 145 },
|
|
],
|
|
}),
|
|
],
|
|
});
|
|
expect(again.status).toBe(200);
|
|
expect(again.data.entries[0]?.status).toBe("duplicate");
|
|
expect(again.data.duplicates).toBe(1);
|
|
|
|
// The stored series is the first one, untouched — the no-op does not
|
|
// overwrite a curve the server already holds.
|
|
const afterSeries = await getPrismaClient().workoutSamples.findFirst({
|
|
where: { workout: { externalId: "hk-uuid-enrich-002" } },
|
|
});
|
|
expect(afterSeries).toEqual(storedSeries);
|
|
expect(
|
|
await getPrismaClient().workoutSamples.count({
|
|
where: { workout: { userId: TEST_USER_ID } },
|
|
}),
|
|
).toBe(1);
|
|
});
|
|
|
|
it("reports one enrichment when two entries in one batch aim at the same workout", async () => {
|
|
await post({ workouts: [baseWorkout("hk-uuid-enrich-007")] });
|
|
|
|
// Same external id twice, far enough apart in time that the
|
|
// write-time twin dedup keeps both entries. Only one series can land.
|
|
const res = await post({
|
|
workouts: [
|
|
baseWorkout("hk-uuid-enrich-007", { samples: SERIES }),
|
|
baseWorkout("hk-uuid-enrich-007", {
|
|
startedAt: "2026-05-14T09:00:00.000Z",
|
|
endedAt: "2026-05-14T09:45:00.000Z",
|
|
samples: [{ t: "2026-05-14T09:00:00.000Z", hr: 155 }],
|
|
}),
|
|
],
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(
|
|
res.data.entries.filter((e) => e.status === "enriched"),
|
|
).toHaveLength(1);
|
|
expect(
|
|
await getPrismaClient().workoutSamples.count({
|
|
where: { workout: { userId: TEST_USER_ID } },
|
|
}),
|
|
).toBe(1);
|
|
});
|
|
|
|
it("leaves every workout column byte-identical across an enrichment", async () => {
|
|
await post({
|
|
workouts: [
|
|
baseWorkout("hk-uuid-enrich-003", {
|
|
totalEnergyKcal: 388,
|
|
totalDistanceM: 7100,
|
|
avgHeartRate: 141,
|
|
maxHeartRate: 169,
|
|
minHeartRate: 96,
|
|
metadata: { device: "watch" },
|
|
}),
|
|
],
|
|
});
|
|
|
|
const before = await getPrismaClient().workout.findFirstOrThrow({
|
|
where: { userId: TEST_USER_ID, externalId: "hk-uuid-enrich-003" },
|
|
});
|
|
|
|
// A re-post whose every field contradicts the stored row. First-write-
|
|
// wins still governs the workout itself; only the series is attached.
|
|
const enriched = await post({
|
|
workouts: [
|
|
baseWorkout("hk-uuid-enrich-003", {
|
|
sportType: "cycling",
|
|
startedAt: "2026-05-14T09:00:00.000Z",
|
|
endedAt: "2026-05-14T10:30:00.000Z",
|
|
totalEnergyKcal: 999,
|
|
totalDistanceM: 42000,
|
|
avgHeartRate: 111,
|
|
maxHeartRate: 198,
|
|
minHeartRate: 55,
|
|
metadata: { device: "phone" },
|
|
samples: SERIES,
|
|
}),
|
|
],
|
|
});
|
|
expect(enriched.data.entries[0]?.status).toBe("enriched");
|
|
|
|
const after = await getPrismaClient().workout.findFirstOrThrow({
|
|
where: { userId: TEST_USER_ID, externalId: "hk-uuid-enrich-003" },
|
|
});
|
|
// Whole-row comparison — a spot check would miss a column nobody
|
|
// thought to name, and `updatedAt` proves no write touched the row.
|
|
expect(after).toEqual(before);
|
|
});
|
|
|
|
it("refuses a malformed samples array per entry without failing the batch", async () => {
|
|
await post({ workouts: [baseWorkout("hk-uuid-enrich-004")] });
|
|
|
|
const mixed = await post({
|
|
workouts: [
|
|
baseWorkout("hk-uuid-enrich-004", {
|
|
samples: [{ t: "not-a-timestamp", hr: 4000 }],
|
|
}),
|
|
baseWorkout("hk-uuid-enrich-005", {
|
|
startedAt: "2026-05-15T06:30:00.000Z",
|
|
endedAt: "2026-05-15T07:15:00.000Z",
|
|
}),
|
|
],
|
|
});
|
|
|
|
expect(mixed.status).toBe(200);
|
|
expect(mixed.data.entries[0]?.status).toBe("skipped");
|
|
expect(mixed.data.entries[0]?.reason).toBe("invalid_samples");
|
|
expect(mixed.data.entries[1]?.status).toBe("inserted");
|
|
expect(mixed.data.inserted).toBe(1);
|
|
expect(mixed.data.skipped).toEqual([
|
|
{ index: 0, reason: "invalid_samples" },
|
|
]);
|
|
|
|
// Nothing attached to the workout the bad entry addressed.
|
|
expect(
|
|
await getPrismaClient().workoutSamples.count({
|
|
where: { workout: { userId: TEST_USER_ID } },
|
|
}),
|
|
).toBe(0);
|
|
// And the second entry did land.
|
|
expect(
|
|
await getPrismaClient().workout.count({
|
|
where: { userId: TEST_USER_ID },
|
|
}),
|
|
).toBe(2);
|
|
});
|
|
|
|
it("refuses an over-cap samples array per entry rather than the whole batch", async () => {
|
|
await post({ workouts: [baseWorkout("hk-uuid-enrich-006")] });
|
|
|
|
const oversized = Array.from({ length: 30_001 }, (_v, i) => ({
|
|
t: new Date(Date.UTC(2026, 4, 14, 6, 30, 0) + i * 1000).toISOString(),
|
|
hr: 120,
|
|
}));
|
|
|
|
const res = await post({
|
|
workouts: [baseWorkout("hk-uuid-enrich-006", { samples: oversized })],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.data.entries[0]?.status).toBe("skipped");
|
|
expect(res.data.entries[0]?.reason).toBe("invalid_samples");
|
|
expect(
|
|
await getPrismaClient().workoutSamples.count({
|
|
where: { workout: { userId: TEST_USER_ID } },
|
|
}),
|
|
).toBe(0);
|
|
});
|
|
|
|
it("reports insert, enrich, duplicate and skip side by side in one batch", async () => {
|
|
await post({
|
|
workouts: [
|
|
baseWorkout("hk-uuid-mix-known-series", {
|
|
startedAt: "2026-05-10T06:00:00.000Z",
|
|
endedAt: "2026-05-10T06:45:00.000Z",
|
|
}),
|
|
baseWorkout("hk-uuid-mix-known-plain", {
|
|
startedAt: "2026-05-11T06:00:00.000Z",
|
|
endedAt: "2026-05-11T06:45:00.000Z",
|
|
}),
|
|
],
|
|
});
|
|
|
|
const mixed = await post({
|
|
workouts: [
|
|
// 0 — new workout, inserts
|
|
baseWorkout("hk-uuid-mix-new", {
|
|
startedAt: "2026-05-12T06:00:00.000Z",
|
|
endedAt: "2026-05-12T06:45:00.000Z",
|
|
}),
|
|
// 1 — known workout with a series in the payload, enriches
|
|
baseWorkout("hk-uuid-mix-known-series", {
|
|
startedAt: "2026-05-10T06:00:00.000Z",
|
|
endedAt: "2026-05-10T06:45:00.000Z",
|
|
samples: SERIES,
|
|
}),
|
|
// 2 — known workout, no series in the payload, unchanged
|
|
baseWorkout("hk-uuid-mix-known-plain", {
|
|
startedAt: "2026-05-11T06:00:00.000Z",
|
|
endedAt: "2026-05-11T06:45:00.000Z",
|
|
}),
|
|
// 3 — unusable external id, refused per entry
|
|
baseWorkout(" ", {
|
|
startedAt: "2026-05-13T06:00:00.000Z",
|
|
endedAt: "2026-05-13T06:45:00.000Z",
|
|
}),
|
|
],
|
|
});
|
|
|
|
expect(mixed.status).toBe(200);
|
|
expect(mixed.data.processed).toBe(4);
|
|
expect(mixed.data.entries.map((e) => e.status)).toEqual([
|
|
"inserted",
|
|
"enriched",
|
|
"duplicate",
|
|
"skipped",
|
|
]);
|
|
expect(mixed.data.inserted).toBe(1);
|
|
// An enriched entry is still a duplicate of the workout row itself —
|
|
// the counters keep counting rows, the entry status carries the detail.
|
|
expect(mixed.data.duplicates).toBe(2);
|
|
expect(mixed.data.skipped).toHaveLength(1);
|
|
|
|
const series = await getPrismaClient().workoutSamples.findMany({
|
|
where: { workout: { userId: TEST_USER_ID } },
|
|
include: { workout: { select: { externalId: true } } },
|
|
});
|
|
expect(series).toHaveLength(1);
|
|
expect(series[0]?.workout.externalId).toBe("hk-uuid-mix-known-series");
|
|
expect(
|
|
await getPrismaClient().workout.count({
|
|
where: { userId: TEST_USER_ID },
|
|
}),
|
|
).toBe(3);
|
|
});
|
|
});
|