mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
A round trip through the real handlers: post a recording, read it back through the list and the detail routes, and check what a client actually receives rather than a fixture rebuilt at each end. Post it twice and the second call says updated, not inserted, and the table does not grow. Feed the same strip to the export-archive importer afterwards and it says skipped; feed it to the importer first and the route says duplicate. Both directions leave one row. The migration test drops the index, manufactures the collisions the resolution step exists for, and runs the migration file through the same executor the deploy uses. It checks which row survived each tiebreak, that the salvaged event link followed the keeper, that rows differing only by user, source or sampling rate were left alone, and that the constraint rejects a new twin afterwards. Applying the migration to the empty testcontainer proves none of that. The Withings ECG tests now hand the transaction the same delegate the shared writer uses, so they still watch the real write instead of a stub standing in for it.
323 lines
10 KiB
TypeScript
323 lines
10 KiB
TypeScript
/**
|
|
* 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(),
|
|
);
|
|
});
|
|
});
|