Merge branch 'work/ecg-ingest' into release/v1.35.3

This commit is contained in:
Marc-André Bombeck committed 2026-08-01 19:50:52 +02:00
commit 3b37d73f31
13 files changed
+1728 -108

No files matched your search

@@ -0,0 +1,368 @@
/**
* `POST /api/insights/ecg` end to end against real Postgres.
*
* Everything here goes through the actual route handlers — the ingest, the
* metadata list, and the waveform detail — because the thing worth proving is
* the assembly between them, not that each end works in isolation. What a
* client receives is read out of the real GET responses, never rebuilt from a
* fixture.
*
* The cross-door case is the point of the second unique index: the same
* physical recording reaching the export-archive importer and the live route
* carries two different source ids, and must still be one row.
*/
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.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
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),
}));
import {
GET as getEcgList,
POST as postEcg,
} from "@/app/api/insights/ecg/route";
import { GET as getEcgDetail } from "@/app/api/insights/ecg/[id]/route";
import { importAppleHealthEcg } from "@/lib/apple-health/ecg-import";
const USER_ID = "ecg-ingest-roundtrip";
const OTHER_USER_ID = "ecg-ingest-roundtrip-other";
const RECORDED_AT = "2026-07-18T08:14:03+02:00";
const SAMPLES = [12, -7, 3, 240, -180, 60];
type IngestBody = Record<string, unknown>;
function ingestPayload(overrides: IngestBody = {}): IngestBody {
return {
externalRecordingId: "8E1F3B0C-0000-4000-8000-000000000001",
recordedAt: RECORDED_AT,
samplingFrequency: 512,
samples: [...SAMPLES],
lead: "I",
averageHeartRate: 64,
classification: "NOT_DETECTED",
source: "APPLE_HEALTH",
...overrides,
};
}
async function ingest(body: IngestBody): Promise<{
status: number;
data: {
id: string;
status: string;
recordedAt: string;
sampleCount: number;
durationSeconds: number | null;
};
}> {
const response = await postEcg(
new NextRequest("http://localhost/api/insights/ecg", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
);
return {
status: response.status,
data: (await response.json()).data,
};
}
async function readList(): Promise<{
recordings: Array<Record<string, unknown>>;
hasRecordings: boolean;
}> {
// The list handler declares no parameters; the wrapper still receives the
// request, so hand it one the way the route's own unit test does.
const callList = getEcgList as unknown as (
request: NextRequest,
) => Promise<Response>;
const response = await callList(
new NextRequest("http://localhost/api/insights/ecg"),
);
expect(response.status).toBe(200);
return (await response.json()).data;
}
async function readDetail(
id: string,
full = false,
): Promise<{ response: Response; body: Record<string, unknown> }> {
const url = full
? `http://localhost/api/insights/ecg/${id}?full=1`
: `http://localhost/api/insights/ecg/${id}`;
const response = await getEcgDetail(new NextRequest(url), {
params: Promise.resolve({ id }),
});
return { response, body: (await response.json()).data };
}
async function signIn(userId: string): Promise<void> {
const session = await getPrismaClient().session.create({
data: { userId, expiresAt: new Date(Date.now() + 60 * 60 * 1000) },
});
cookieJar.set("healthlog_session", session.id);
}
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
cookieJar.clear();
headerJar.clear();
await prisma.user.createMany({
data: [
{
id: USER_ID,
username: "ecg-roundtrip",
email: "ecg-roundtrip@example.test",
timezone: "Europe/Berlin",
},
{
id: OTHER_USER_ID,
username: "ecg-roundtrip-other",
email: "ecg-roundtrip-other@example.test",
timezone: "Europe/Berlin",
},
],
});
await signIn(USER_ID);
});
describe("POST /api/insights/ecg — round trip through the real routes", () => {
it("stores a posted recording and serves it back through the list and the detail", async () => {
const posted = await ingest(ingestPayload());
expect(posted.status).toBe(201);
expect(posted.data.status).toBe("inserted");
expect(posted.data.sampleCount).toBe(SAMPLES.length);
expect(posted.data.durationSeconds).toBeCloseTo(SAMPLES.length / 512, 10);
const list = await readList();
expect(list.hasRecordings).toBe(true);
expect(list.recordings).toHaveLength(1);
expect(list.recordings[0]).toMatchObject({
id: posted.data.id,
samplingFrequency: 512,
sampleCount: SAMPLES.length,
averageHeartRate: 64,
lead: "I",
classification: "NOT_DETECTED",
source: "APPLE_HEALTH",
hasWaveform: true,
});
expect(
new Date(list.recordings[0].recordedAt as string).toISOString(),
).toBe(new Date(RECORDED_AT).toISOString());
const detail = await readDetail(posted.data.id, true);
expect(detail.response.status).toBe(200);
expect(detail.body.samples).toEqual(SAMPLES);
expect(detail.body.decimated).toBe(false);
expect(detail.body.classification).toBe("NOT_DETECTED");
});
it("never writes the waveform as plaintext", async () => {
const posted = await ingest(ingestPayload());
const row = await getPrismaClient().ecgRecording.findUniqueOrThrow({
where: { id: posted.data.id },
});
const stored = Buffer.from(row.waveformEncrypted).toString("utf8");
// The column holds the `<keyId>.<base64>` envelope and nothing else. A
// serialised sample array cannot satisfy that shape — it carries
// brackets, commas and minus signs — so this rules plaintext out without
// asking whether some digit happens to appear in a base64 blob, which it
// routinely does.
expect(stored).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9+/=]+$/);
expect(stored).not.toContain(JSON.stringify(SAMPLES));
});
it("reports a re-post as updated and does not grow the table", async () => {
const first = await ingest(ingestPayload());
const second = await ingest(ingestPayload());
expect(second.status).toBe(200);
expect(second.data.status).toBe("updated");
expect(second.data.id).toBe(first.data.id);
expect(
await getPrismaClient().ecgRecording.count({
where: { userId: USER_ID },
}),
).toBe(1);
expect((await readList()).recordings).toHaveLength(1);
});
it("overwrites the stored recording in place when a re-post revises it", async () => {
const first = await ingest(ingestPayload());
const revised = [1, 2, 3, 4];
const second = await ingest(
ingestPayload({ samples: revised, classification: "IRREGULAR" }),
);
expect(second.data.status).toBe("updated");
expect(second.data.id).toBe(first.data.id);
const detail = await readDetail(first.data.id, true);
expect(detail.body.samples).toEqual(revised);
expect(detail.body.classification).toBe("IRREGULAR");
});
it("keeps one row when the same recording also arrives through the export archive", async () => {
const posted = await ingest(ingestPayload());
// The archive importer identifies the strip by a hash of its content, so
// it arrives under a completely different externalRecordingId.
const outcome = await importAppleHealthEcg({
userId: USER_ID,
ecg: {
recordedAt: new Date(RECORDED_AT),
samplingFrequency: 512,
samples: [...SAMPLES],
lead: "I",
averageHeartRate: 64,
rhythmClassification: "NOT_DETECTED",
},
prisma: getPrismaClient(),
});
expect(outcome).toBe("skipped");
const rows = await getPrismaClient().ecgRecording.findMany({
where: { userId: USER_ID },
});
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe(posted.data.id);
expect((await readList()).recordings).toHaveLength(1);
});
it("reports duplicate when the export archive got there first", async () => {
await importAppleHealthEcg({
userId: USER_ID,
ecg: {
recordedAt: new Date(RECORDED_AT),
samplingFrequency: 512,
samples: [...SAMPLES],
lead: "I",
averageHeartRate: 64,
rhythmClassification: "NOT_DETECTED",
},
prisma: getPrismaClient(),
});
const archived = await getPrismaClient().ecgRecording.findFirstOrThrow({
where: { userId: USER_ID },
});
const posted = await ingest(ingestPayload());
expect(posted.status).toBe(200);
expect(posted.data.status).toBe("duplicate");
expect(posted.data.id).toBe(archived.id);
expect(
await getPrismaClient().ecgRecording.count({
where: { userId: USER_ID },
}),
).toBe(1);
// Nothing was written: the archive's own external id still owns the row.
expect(
(
await getPrismaClient().ecgRecording.findUniqueOrThrow({
where: { id: archived.id },
})
).externalRecordingId,
).toBe(archived.externalRecordingId);
});
it("stores distinct recordings distinctly", async () => {
await ingest(ingestPayload());
const other = await ingest(
ingestPayload({
externalRecordingId: "8E1F3B0C-0000-4000-8000-000000000002",
recordedAt: "2026-07-19T08:14:03+02:00",
}),
);
expect(other.data.status).toBe("inserted");
expect((await readList()).recordings).toHaveLength(2);
});
it("writes to the session's user and cannot reach another account's rows", async () => {
const mine = await ingest(ingestPayload());
cookieJar.clear();
await signIn(OTHER_USER_ID);
// Same recording id, same instant — a different account entirely.
const theirs = await ingest(ingestPayload());
expect(theirs.status).toBe(201);
expect(theirs.data.status).toBe("inserted");
expect(theirs.data.id).not.toBe(mine.data.id);
expect((await readList()).recordings).toHaveLength(1);
const foreign = await readDetail(mine.data.id, true);
expect(foreign.response.status).toBe(404);
const owners = await getPrismaClient().ecgRecording.findMany({
select: { userId: true },
});
expect(owners.map((r) => r.userId).sort()).toEqual(
[USER_ID, OTHER_USER_ID].sort(),
);
});
it("refuses a source a client is not allowed to claim", async () => {
const response = await postEcg(
new NextRequest("http://localhost/api/insights/ecg", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(ingestPayload({ source: "WITHINGS" })),
}),
);
expect(response.status).toBe(422);
expect(await getPrismaClient().ecgRecording.count()).toBe(0);
});
it("refuses a userId in the body rather than honouring it", async () => {
const response = await postEcg(
new NextRequest("http://localhost/api/insights/ecg", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(ingestPayload({ userId: OTHER_USER_ID })),
}),
);
expect(response.status).toBe(422);
expect(await getPrismaClient().ecgRecording.count()).toBe(0);
});
it("refuses an event verdict that cannot occur on an ECG", async () => {
const response = await postEcg(
new NextRequest("http://localhost/api/insights/ecg", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(ingestPayload({ classification: "VERY_LOW" })),
}),
);
expect(response.status).toBe(422);
expect(await getPrismaClient().ecgRecording.count()).toBe(0);
});
});
@@ -0,0 +1,322 @@
/**
* Migration `0291_ecg_recording_identity` against a table that ALREADY holds
* colliding rows.
*
* The testcontainer starts empty, so applying this migration during global
* setup proves nothing about the deployed instances it will actually run on:
* two writers have been filling `ecg_recordings` since v1.19.0, and a unique
* index that meets a pre-existing duplicate aborts the migration on boot. So
* this test drops the index, manufactures the collisions the resolution step
* exists for, and runs the REAL migration file through the Prisma CLI — the
* same executor `db:migrate:deploy` uses — then checks which row survived and
* that the constraint is live afterwards.
*/
import { execFileSync } from "node:child_process";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
const PROJECT_ROOT = resolve(
dirname(fileURLToPath(import.meta.url)),
"..",
"..",
);
const MIGRATION_FILE = join(
PROJECT_ROOT,
"prisma",
"migrations",
"0291_ecg_recording_identity",
"migration.sql",
);
const INDEX_NAME = "ecg_recordings_user_source_recorded_at_freq_key";
const USER_ID = "ecg-identity-migration";
function applyMigration(): void {
// Same executor and same connection `db:migrate:deploy` uses; the URL comes
// from the child environment exactly as it does for the real deploy.
execFileSync(
"pnpm",
["exec", "prisma", "db", "execute", "--file", MIGRATION_FILE],
{
cwd: PROJECT_ROOT,
stdio: "pipe",
env: { ...process.env, DATABASE_URL: process.env.DATABASE_URL },
},
);
}
async function dropIndex(): Promise<void> {
await getPrismaClient().$executeRawUnsafe(
`DROP INDEX IF EXISTS "${INDEX_NAME}"`,
);
}
async function indexExists(): Promise<boolean> {
const rows = await getPrismaClient().$queryRaw<Array<{ indexname: string }>>`
SELECT indexname FROM pg_indexes
WHERE tablename = 'ecg_recordings' AND indexname = ${INDEX_NAME}
`;
return rows.length === 1;
}
/**
* Insert a row directly. `waveform_encrypted` is opaque to the migration, so
* a marker byte string is enough and keeps the fixture honest about what the
* migration does and does not read.
*/
async function insertRecording(input: {
id: string;
externalRecordingId: string;
recordedAt: string;
samplingFrequency: number;
sampleCount: number;
measurementId?: string | null;
updatedAt: string;
}): Promise<void> {
await getPrismaClient().$executeRawUnsafe(
`INSERT INTO ecg_recordings
(id, user_id, source, external_recording_id, recorded_at,
waveform_encrypted, sampling_frequency, sample_count, duration_seconds,
measurement_id, created_at, updated_at)
VALUES ($1, $2, 'APPLE_HEALTH', $3, $4::timestamptz, $5, $6, $7, $8, $9,
$10::timestamptz, $10::timestamptz)`,
input.id,
USER_ID,
input.externalRecordingId,
input.recordedAt,
Buffer.from(`ciphertext-${input.id}`, "utf8"),
input.samplingFrequency,
input.sampleCount,
input.sampleCount / input.samplingFrequency,
input.measurementId ?? null,
input.updatedAt,
);
}
async function createMeasurement(
id: string,
measuredAt: string,
): Promise<string> {
const row = await getPrismaClient().measurement.create({
data: {
id,
userId: USER_ID,
type: "IRREGULAR_RHYTHM_NOTIFICATION",
value: 1,
unit: "event",
measuredAt: new Date(measuredAt),
source: "APPLE_HEALTH",
},
select: { id: true },
});
return row.id;
}
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
await prisma.user.create({
data: {
id: USER_ID,
username: "ecg-identity-migration",
email: "ecg-identity-migration@example.test",
timezone: "UTC",
},
});
});
afterEach(async () => {
// Whatever this test did to the schema, the next file gets the migrated one.
if (!(await indexExists())) applyMigration();
});
describe("0291_ecg_recording_identity against a table that already has duplicates", () => {
it("resolves the duplicates, keeps the canonical row, and leaves the index enforcing", async () => {
const prisma = getPrismaClient();
await dropIndex();
expect(await indexExists()).toBe(false);
const eventId = await createMeasurement(
"ecg-identity-event-1",
"2026-07-18T06:14:03.000Z",
);
// Partition 1 — the same recording under two source ids. The richer strip
// has no event link; the thin one does, and its link must survive.
await insertRecording({
id: "p1-keeper-more-samples",
externalRecordingId: "apple-health:ecg:content-hash",
recordedAt: "2026-07-18T06:14:03Z",
samplingFrequency: 512,
sampleCount: 15_360,
updatedAt: "2026-07-18T07:00:00Z",
});
await insertRecording({
id: "p1-loser-fewer-samples",
externalRecordingId: "8E1F3B0C-0000-4000-8000-000000000001",
recordedAt: "2026-07-18T06:14:03Z",
samplingFrequency: 512,
sampleCount: 12,
measurementId: eventId,
updatedAt: "2026-07-18T09:00:00Z",
});
// Partition 2 — equal strips, one linked to its event row.
await insertRecording({
id: "p2-unlinked",
externalRecordingId: "p2-a",
recordedAt: "2026-07-19T06:14:03Z",
samplingFrequency: 512,
sampleCount: 512,
updatedAt: "2026-07-19T09:00:00Z",
});
const secondEventId = await createMeasurement(
"ecg-identity-event-2",
"2026-07-19T06:14:03.000Z",
);
await insertRecording({
id: "p2-linked",
externalRecordingId: "p2-b",
recordedAt: "2026-07-19T06:14:03Z",
samplingFrequency: 512,
sampleCount: 512,
measurementId: secondEventId,
updatedAt: "2026-07-19T07:00:00Z",
});
// Partition 3 — indistinguishable but for when they were last written.
await insertRecording({
id: "p3-stale",
externalRecordingId: "p3-a",
recordedAt: "2026-07-20T06:14:03Z",
samplingFrequency: 512,
sampleCount: 512,
updatedAt: "2026-07-20T07:00:00Z",
});
await insertRecording({
id: "p3-fresh",
externalRecordingId: "p3-b",
recordedAt: "2026-07-20T06:14:03Z",
samplingFrequency: 512,
sampleCount: 512,
updatedAt: "2026-07-20T09:00:00Z",
});
// A recording that collides with nothing must be left exactly as it is.
await insertRecording({
id: "p4-untouched",
externalRecordingId: "p4-a",
recordedAt: "2026-07-21T06:14:03Z",
samplingFrequency: 512,
sampleCount: 512,
updatedAt: "2026-07-21T07:00:00Z",
});
expect(await prisma.ecgRecording.count()).toBe(7);
applyMigration();
expect(await indexExists()).toBe(true);
const survivors = await prisma.ecgRecording.findMany({
orderBy: { recordedAt: "asc" },
select: { id: true, measurementId: true, externalRecordingId: true },
});
expect(survivors.map((row) => row.id)).toEqual([
"p1-keeper-more-samples",
"p2-linked",
"p3-fresh",
"p4-untouched",
]);
// The loser's event link was salvaged onto the keeper.
expect(survivors[0].measurementId).toBe(eventId);
expect(survivors[1].measurementId).toBe(secondEventId);
expect(survivors[3].externalRecordingId).toBe("p4-a");
// The event rows themselves are untouched — resolving a duplicate
// recording never deletes a measurement.
expect(await prisma.measurement.count({ where: { userId: USER_ID } })).toBe(
2,
);
// And the constraint is live: the collision cannot come back.
await expect(
insertRecording({
id: "post-migration-twin",
externalRecordingId: "another-id-entirely",
recordedAt: "2026-07-21T06:14:03Z",
samplingFrequency: 512,
sampleCount: 512,
updatedAt: "2026-07-21T10:00:00Z",
}),
).rejects.toThrow();
expect(await prisma.ecgRecording.count()).toBe(4);
});
it("leaves rows alone that only look alike across users, sources or sampling rates", async () => {
const prisma = getPrismaClient();
await dropIndex();
const otherUser = await prisma.user.create({
data: {
username: "ecg-identity-migration-other",
email: "ecg-identity-migration-other@example.test",
},
});
await insertRecording({
id: "same-instant-apple",
externalRecordingId: "a",
recordedAt: "2026-07-18T06:14:03Z",
samplingFrequency: 512,
sampleCount: 512,
updatedAt: "2026-07-18T07:00:00Z",
});
// Same instant, different sampling rate — a different recording.
await insertRecording({
id: "same-instant-other-rate",
externalRecordingId: "b",
recordedAt: "2026-07-18T06:14:03Z",
samplingFrequency: 300,
sampleCount: 300,
updatedAt: "2026-07-18T07:00:00Z",
});
// Same instant and rate, but a different source.
await prisma.$executeRawUnsafe(
`INSERT INTO ecg_recordings
(id, user_id, source, external_recording_id, recorded_at,
waveform_encrypted, sampling_frequency, sample_count, created_at, updated_at)
VALUES ('same-instant-withings', $1, 'WITHINGS', 'c',
'2026-07-18T06:14:03Z'::timestamptz, $2, 512, 512, NOW(), NOW())`,
USER_ID,
Buffer.from("ciphertext-withings", "utf8"),
);
// Same instant and rate, but somebody else's account.
await prisma.$executeRawUnsafe(
`INSERT INTO ecg_recordings
(id, user_id, source, external_recording_id, recorded_at,
waveform_encrypted, sampling_frequency, sample_count, created_at, updated_at)
VALUES ('same-instant-other-user', $1, 'APPLE_HEALTH', 'd',
'2026-07-18T06:14:03Z'::timestamptz, $2, 512, 512, NOW(), NOW())`,
otherUser.id,
Buffer.from("ciphertext-other-user", "utf8"),
);
applyMigration();
expect(await indexExists()).toBe(true);
const ids = (
await prisma.ecgRecording.findMany({ select: { id: true } })
).map((row) => row.id);
expect(ids.sort()).toEqual(
[
"same-instant-apple",
"same-instant-other-rate",
"same-instant-other-user",
"same-instant-withings",
].sort(),
);
});
});