mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Prove the ECG ingest, the dedupe and the migration on dirty data
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.
This commit is contained in:
1 parent
0e301b580c
commit
8f90fd3174
4 files changed
+882
-13
No files matched your search
@@ -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<string, unknown> = {}) {
|
||||
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]);
|
||||
});
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in new issue
Block a user