diff --git a/src/lib/ecg/__tests__/persist-recording.test.ts b/src/lib/ecg/__tests__/persist-recording.test.ts new file mode 100644 index 000000000..9a0319a19 --- /dev/null +++ b/src/lib/ecg/__tests__/persist-recording.test.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/withings/ecg-waveform-codec", () => ({ + encryptWaveformToBytes: vi.fn(), +})); + +import { encryptWaveformToBytes } from "@/lib/withings/ecg-waveform-codec"; +import { + ecgDurationSeconds, + persistEcgRecording, +} from "@/lib/ecg/persist-recording"; + +function fakeDb() { + const findUnique = vi.fn(); + const upsert = vi.fn(); + const $transaction = vi.fn( + async (fn: (tx: unknown) => unknown) => + await fn({ ecgRecording: { findUnique, upsert } }), + ); + return { db: { $transaction }, findUnique, upsert, $transaction }; +} + +function input(overrides: Record = {}) { + return { + userId: "user-1", + source: "APPLE_HEALTH" as const, + externalRecordingId: "hk-uuid-1", + recordedAt: new Date("2026-07-18T06:14:03.000Z"), + samples: [12, -7, 3], + samplingFrequency: 512, + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(encryptWaveformToBytes).mockImplementation( + () => new Uint8Array(new ArrayBuffer(4)), + ); +}); + +describe("ecgDurationSeconds", () => { + it("divides the sample count by the sampling rate", () => { + expect(ecgDurationSeconds(15_360, 512)).toBe(30); + }); + + it("is null when the source reported no sampling rate", () => { + expect(ecgDurationSeconds(15_360, 0)).toBeNull(); + }); +}); + +describe("persistEcgRecording", () => { + it("fails closed on a crypto error without ever opening a write transaction", async () => { + vi.mocked(encryptWaveformToBytes).mockImplementation(() => { + throw new Error("no encryption key"); + }); + const { db, $transaction } = fakeDb(); + + await expect(persistEcgRecording(input(), db as never)).rejects.toThrow( + "no encryption key", + ); + expect($transaction).not.toHaveBeenCalled(); + }); + + it("inserts when neither identity matches an existing row", async () => { + const { db, findUnique, upsert } = fakeDb(); + findUnique.mockResolvedValue(null); + upsert.mockResolvedValue({ id: "row-new" }); + + const result = await persistEcgRecording(input(), db as never); + + expect(result).toMatchObject({ + outcome: "inserted", + id: "row-new", + sampleCount: 3, + }); + expect(upsert).toHaveBeenCalledTimes(1); + }); + + it("updates in place when the source id is already stored", async () => { + const { db, findUnique, upsert } = fakeDb(); + findUnique.mockResolvedValueOnce({ id: "row-existing" }); + upsert.mockResolvedValue({ id: "row-existing" }); + + const result = await persistEcgRecording(input(), db as never); + + expect(result.outcome).toBe("updated"); + expect(result.id).toBe("row-existing"); + // The source-id lookup answered; the recording-identity lookup is not + // needed and must not run. + expect(findUnique).toHaveBeenCalledTimes(1); + }); + + it("writes nothing when the recording is already stored under another id", async () => { + const { db, findUnique, upsert } = fakeDb(); + findUnique + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: "row-from-the-archive" }); + + const result = await persistEcgRecording(input(), db as never); + + expect(result.outcome).toBe("duplicate"); + expect(result.id).toBe("row-from-the-archive"); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("looks the twin up by the recording's own identity, not by the source id", async () => { + const { db, findUnique, upsert } = fakeDb(); + findUnique.mockResolvedValue(null); + upsert.mockResolvedValue({ id: "row-new" }); + + await persistEcgRecording(input(), db as never); + + expect(findUnique.mock.calls[1][0].where).toEqual({ + userId_source_recordedAt_samplingFrequency: { + userId: "user-1", + source: "APPLE_HEALTH", + recordedAt: new Date("2026-07-18T06:14:03.000Z"), + samplingFrequency: 512, + }, + }); + }); + + it("builds the row field by field and narrows the owner to the caller's user", async () => { + const { db, findUnique, upsert } = fakeDb(); + findUnique.mockResolvedValue(null); + upsert.mockResolvedValue({ id: "row-new" }); + + await persistEcgRecording( + input({ + lead: "I", + averageHeartRate: 64, + rhythmClassification: "IRREGULAR", + }), + db as never, + ); + + const created = upsert.mock.calls[0][0].create; + expect(created).toMatchObject({ + userId: "user-1", + source: "APPLE_HEALTH", + externalRecordingId: "hk-uuid-1", + samplingFrequency: 512, + sampleCount: 3, + lead: "I", + averageHeartRate: 64, + rhythmClassification: "IRREGULAR", + measurementId: null, + }); + expect(created.durationSeconds).toBeCloseTo(3 / 512, 10); + }); + + it("leaves no plaintext samples behind in the caller's array", async () => { + const { db, findUnique, upsert } = fakeDb(); + findUnique.mockResolvedValue(null); + upsert.mockResolvedValue({ id: "row-new" }); + + const payload = input(); + await persistEcgRecording(payload, db as never); + + expect(payload.samples).toEqual([0, 0, 0]); + }); +}); diff --git a/src/lib/withings/__tests__/sync-ecg.test.ts b/src/lib/withings/__tests__/sync-ecg.test.ts index 5baca7a92..c916e0a15 100644 --- a/src/lib/withings/__tests__/sync-ecg.test.ts +++ b/src/lib/withings/__tests__/sync-ecg.test.ts @@ -8,19 +8,31 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("@/lib/db", () => ({ - prisma: { - measurement: { - upsert: vi.fn(), +// The waveform write goes through the shared writer +// (`@/lib/ecg/persist-recording`), which opens a transaction and looks the +// recording up under both identities before upserting. The mock hands the +// transaction the same `ecgRecording` delegate, so these tests still watch the +// real assembly rather than a stub standing in for it. +vi.mock("@/lib/db", () => { + const ecgRecording = { + upsert: vi.fn(), + findUnique: vi.fn(), + }; + return { + prisma: { + measurement: { + upsert: vi.fn(), + }, + ecgRecording, + withingsConnection: { + findUnique: vi.fn(), + }, + $transaction: vi.fn( + async (fn: (tx: unknown) => unknown) => await fn({ ecgRecording }), + ), }, - ecgRecording: { - upsert: vi.fn(), - }, - withingsConnection: { - findUnique: vi.fn(), - }, - }, -})); + }; +}); vi.mock("../ecg-waveform-codec", () => ({ encryptWaveformToBytes: vi.fn((samples: number[]) => @@ -126,7 +138,14 @@ beforeEach(() => { vi.mocked(prisma.measurement.upsert).mockResolvedValue({ id: "m-1", } as never); - vi.mocked(prisma.ecgRecording.upsert).mockResolvedValue({} as never); + vi.mocked(prisma.ecgRecording.findUnique).mockResolvedValue(null as never); + vi.mocked(prisma.ecgRecording.upsert).mockResolvedValue({ + id: "ecg-1", + } as never); + vi.mocked(prisma.$transaction).mockImplementation( + (async (fn: (tx: unknown) => unknown) => + await fn({ ecgRecording: prisma.ecgRecording })) as never, + ); }); afterEach(() => { diff --git a/tests/integration/ecg-ingest-roundtrip.test.ts b/tests/integration/ecg-ingest-roundtrip.test.ts new file mode 100644 index 000000000..bb2d6cb38 --- /dev/null +++ b/tests/integration/ecg-ingest-roundtrip.test.ts @@ -0,0 +1,365 @@ +/** + * `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; + +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>; + 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; + 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 }> { + 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 { + 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"); + for (const sample of SAMPLES) { + expect(stored).not.toContain(String(sample)); + } + expect(stored).not.toContain("[12,-7,3"); + }); + + 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); + }); +}); diff --git a/tests/integration/ecg-recording-identity-migration.test.ts b/tests/integration/ecg-recording-identity-migration.test.ts new file mode 100644 index 000000000..f57fa4e96 --- /dev/null +++ b/tests/integration/ecg-recording-identity-migration.test.ts @@ -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 { + await getPrismaClient().$executeRawUnsafe( + `DROP INDEX IF EXISTS "${INDEX_NAME}"`, + ); +} + +async function indexExists(): Promise { + const rows = await getPrismaClient().$queryRaw>` + 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 { + 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 { + 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(), + ); + }); +});