mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Merge branch 'work/ecg-ingest' into release/v1.35.3
This commit is contained in:
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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user