mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the login, register, token and session paths, mirroring columns every migration under prisma/migrations already creates. The entrypoint applies those migrations before the server listens and stops the boot when one fails, so the mirror never had work to do, and DDL on a request path needs the table's owner: a connection that may only read and write rows fails every login on it. Drop the module, its call sites and the mocks that stubbed it out.
252 lines
8.2 KiB
TypeScript
252 lines
8.2 KiB
TypeScript
/**
|
|
* Deleting one row of a blood-pressure reading deletes the reading, through
|
|
* the real routes against a real Postgres.
|
|
*
|
|
* Systolic, diastolic and the cuff's pulse are three rows with no link
|
|
* column; they are one reading because every writer stores them with the
|
|
* same source at the same instant. Deleting the systolic used to tombstone
|
|
* only the systolic and leave a diastolic and a pulse behind. Now the
|
|
* single DELETE and the bulk delete take the whole reading, every row bumps
|
|
* its own syncVersion (so the sync feed carries each tombstone), and the
|
|
* undo, which names only the row the person tapped, brings the whole
|
|
* reading back. A reading of another source at the same instant, a reading
|
|
* a minute later, and a pulse deleted on its own are left alone. So is the
|
|
* folded hour of heart rate the dense fold anchors at the local HH:30: a
|
|
* reading taken at half past shares its instant and its source, and is
|
|
* still not the same thing.
|
|
*/
|
|
import { NextRequest } from "next/server";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { canonicalHourlyTimestamp } from "@/lib/measurements/consolidation-tz";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
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);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
|
|
const USER = "user-bp-reading-delete";
|
|
const AT = new Date("2026-10-20T07:30:00.000Z");
|
|
const MINUTE_LATER = new Date(AT.getTime() + 60_000);
|
|
|
|
beforeEach(async () => {
|
|
const prisma = getPrismaClient();
|
|
await truncateAllTables(prisma);
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
await prisma.user.create({
|
|
data: {
|
|
id: USER,
|
|
username: "bp-reading-delete",
|
|
email: "bp-reading-delete@example.test",
|
|
},
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: { userId: USER, expiresAt: new Date(Date.now() + 3_600_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
|
|
const row = (
|
|
id: string,
|
|
type: "BLOOD_PRESSURE_SYS" | "BLOOD_PRESSURE_DIA" | "PULSE",
|
|
value: number,
|
|
source: "MANUAL" | "WITHINGS",
|
|
measuredAt: Date,
|
|
) => ({
|
|
id,
|
|
userId: USER,
|
|
type,
|
|
value,
|
|
unit: type === "PULSE" ? "bpm" : "mmHg",
|
|
source,
|
|
measuredAt,
|
|
});
|
|
await prisma.measurement.createMany({
|
|
data: [
|
|
// The reading under test.
|
|
row("sys", "BLOOD_PRESSURE_SYS", 128, "MANUAL", AT),
|
|
row("dia", "BLOOD_PRESSURE_DIA", 82, "MANUAL", AT),
|
|
row("pulse", "PULSE", 64, "MANUAL", AT),
|
|
// The same instant from another source: another reading.
|
|
row("w-sys", "BLOOD_PRESSURE_SYS", 131, "WITHINGS", AT),
|
|
row("w-dia", "BLOOD_PRESSURE_DIA", 84, "WITHINGS", AT),
|
|
// A minute later from the same source: another reading.
|
|
row("next-sys", "BLOOD_PRESSURE_SYS", 125, "MANUAL", MINUTE_LATER),
|
|
row("next-dia", "BLOOD_PRESSURE_DIA", 80, "MANUAL", MINUTE_LATER),
|
|
row("next-pulse", "PULSE", 62, "MANUAL", MINUTE_LATER),
|
|
],
|
|
});
|
|
});
|
|
|
|
async function rows(): Promise<
|
|
Record<string, { deleted: boolean; syncVersion: number }>
|
|
> {
|
|
const all = await getPrismaClient().measurement.findMany({
|
|
where: { userId: USER },
|
|
select: { id: true, deletedAt: true, syncVersion: true },
|
|
});
|
|
return Object.fromEntries(
|
|
all.map((r) => [
|
|
r.id,
|
|
{ deleted: r.deletedAt !== null, syncVersion: r.syncVersion },
|
|
]),
|
|
);
|
|
}
|
|
|
|
const deletedIds = async () =>
|
|
Object.entries(await rows())
|
|
.filter(([, r]) => r.deleted)
|
|
.map(([id]) => id)
|
|
.sort();
|
|
|
|
async function deleteOne(id: string) {
|
|
const { DELETE } = await import("@/app/api/measurements/[id]/route");
|
|
const res = await DELETE(
|
|
new NextRequest(`http://localhost/api/measurements/${id}`, {
|
|
method: "DELETE",
|
|
}),
|
|
{ params: Promise.resolve({ id }) },
|
|
);
|
|
expect(res.status).toBe(200);
|
|
}
|
|
|
|
async function post(path: "bulk-delete" | "restore", ids: string[]) {
|
|
const route =
|
|
path === "bulk-delete"
|
|
? await import("@/app/api/measurements/bulk-delete/route")
|
|
: await import("@/app/api/measurements/restore/route");
|
|
const res = await route.POST(
|
|
new NextRequest(`http://localhost/api/measurements/${path}`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ ids }),
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
return (await res.json()).data as { deleted?: number; restored?: number };
|
|
}
|
|
|
|
describe("deleting a blood-pressure reading", () => {
|
|
it("takes the other half and the pulse with the systolic, and the undo brings all three back", async () => {
|
|
await deleteOne("sys");
|
|
|
|
expect(await deletedIds()).toEqual(["dia", "pulse", "sys"]);
|
|
const after = await rows();
|
|
for (const id of ["sys", "dia", "pulse"]) {
|
|
expect(after[id]!.syncVersion).toBe(2);
|
|
}
|
|
for (const id of ["w-sys", "w-dia", "next-sys", "next-dia"]) {
|
|
expect(after[id]!.syncVersion).toBe(1);
|
|
}
|
|
|
|
// The undo names only the row that was tapped.
|
|
const { restored } = await post("restore", ["sys"]);
|
|
expect(restored).toBe(3);
|
|
expect(await deletedIds()).toEqual([]);
|
|
});
|
|
|
|
it("takes the systolic and the pulse with the diastolic", async () => {
|
|
await deleteOne("dia");
|
|
expect(await deletedIds()).toEqual(["dia", "pulse", "sys"]);
|
|
});
|
|
|
|
it("deletes a pulse on its own, and its undo leaves the reading as it was", async () => {
|
|
await deleteOne("pulse");
|
|
expect(await deletedIds()).toEqual(["pulse"]);
|
|
await post("restore", ["pulse"]);
|
|
expect(await deletedIds()).toEqual([]);
|
|
});
|
|
|
|
it("does the same for the bulk delete, and restores only what went in the same delete", async () => {
|
|
// The pulse of the second reading was deleted on its own earlier.
|
|
await deleteOne("next-pulse");
|
|
|
|
const { deleted } = await post("bulk-delete", ["sys", "next-dia"]);
|
|
expect(deleted).toBe(5);
|
|
expect(await deletedIds()).toEqual([
|
|
"dia",
|
|
"next-dia",
|
|
"next-pulse",
|
|
"next-sys",
|
|
"pulse",
|
|
"sys",
|
|
]);
|
|
|
|
const { restored } = await post("restore", ["sys", "next-dia"]);
|
|
expect(restored).toBe(5);
|
|
// The pulse deleted at another moment stays deleted.
|
|
expect(await deletedIds()).toEqual(["next-pulse"]);
|
|
});
|
|
|
|
it("BP at :30 beside a folded hour: leaves the hour of heart rate alone", async () => {
|
|
const prisma = getPrismaClient();
|
|
// 09:30 local (Europe/Berlin, the default zone) on a day the fold took:
|
|
// the anchor of that hour's mean.
|
|
const halfPast = canonicalHourlyTimestamp("2026-10-20", 9, "Europe/Berlin");
|
|
await prisma.measurement.createMany({
|
|
data: [
|
|
{
|
|
id: "ah-sys",
|
|
userId: USER,
|
|
type: "BLOOD_PRESSURE_SYS",
|
|
value: 134,
|
|
unit: "mmHg",
|
|
source: "APPLE_HEALTH",
|
|
measuredAt: halfPast,
|
|
externalId: "uuid-ah-sys",
|
|
},
|
|
{
|
|
id: "ah-dia",
|
|
userId: USER,
|
|
type: "BLOOD_PRESSURE_DIA",
|
|
value: 86,
|
|
unit: "mmHg",
|
|
source: "APPLE_HEALTH",
|
|
measuredAt: halfPast,
|
|
externalId: "uuid-ah-dia",
|
|
},
|
|
{
|
|
id: "folded-hour",
|
|
userId: USER,
|
|
type: "PULSE",
|
|
value: 71,
|
|
unit: "bpm",
|
|
source: "APPLE_HEALTH",
|
|
measuredAt: halfPast,
|
|
externalId: "stats:HKQuantityTypeIdentifierHeartRate:2026-10-20T09",
|
|
},
|
|
],
|
|
});
|
|
|
|
await deleteOne("ah-sys");
|
|
expect(await deletedIds()).toEqual(["ah-dia", "ah-sys"]);
|
|
expect((await rows())["folded-hour"]!.syncVersion).toBe(1);
|
|
|
|
const { restored } = await post("restore", ["ah-sys"]);
|
|
expect(restored).toBe(2);
|
|
|
|
const { deleted } = await post("bulk-delete", ["ah-dia"]);
|
|
expect(deleted).toBe(2);
|
|
expect(await deletedIds()).toEqual(["ah-dia", "ah-sys"]);
|
|
});
|
|
});
|