fix(measurements): delete a blood-pressure reading as one

Systolic, diastolic and the cuff's pulse are three rows with no link
column, and the single DELETE tombstoned only the row it was given, so
deleting a reading's systolic left its diastolic and pulse behind as half
a reading. The bulk delete had the same gap.

A reading is the rows with the same owner, the same source and the
identical measuredAt: that is how the entry form, the MCP write, a
Withings measure group and a HealthKit correlation all store one, and how
the FHIR read pairs a diastolic onto its systolic. The looser five-minute
pairing of the chart reads is right for drawing a point and wrong for
taking rows away.

Deleting a systolic or diastolic now tombstones the rest of its reading in
the same transaction with the same deletedAt, each row with its own
syncVersion bump so paired clients see every tombstone. The restore route
brings back the rows deleted together with a named half (identical
deletedAt), so the undo, which names only the row that was tapped, puts
the whole reading back, from the web list and from any client that calls
it the same way. A pulse is still deleted on its own, and a row deleted at
another moment stays deleted. The response shapes do not change; the
OpenAPI descriptions say what the counts now include.
This commit is contained in:
Marc-André Bombeck committed 2026-10-29 08:56:19 +01:00
1 parent d88313e929
commit e2998183c8
9 files changed
+430 -48

No files matched your search

+10 -3
View File
@@ -4825,7 +4825,10 @@ paths:
tags:
- Measurements
summary: Delete a measurement
description: Soft-deletes the row (tombstone) so paired clients can reconcile the deletion.
description: "Soft-deletes the row (tombstone) so paired clients can reconcile the deletion. A blood-pressure systolic
or diastolic takes the rest of its reading with it: the other half and the pulse with the same source and the
identical `measuredAt`, tombstoned in the same transaction with the same `deletedAt`, each with its own
`syncVersion` bump. A pulse is deleted on its own."
parameters:
- in: path
name: id
@@ -4936,7 +4939,8 @@ paths:
description: Soft-deletes (tombstones) up to 200 of the caller's measurement rows in one call, mirroring the
single-DELETE contract. Idempotent via the `Idempotency-Key` header; rate-limited
`measurements:bulk-delete:<userId>`. Forged / foreign / already-deleted ids are silently skipped — `deleted` is
the count of rows actually tombstoned. The deletions surface in `/api/sync/changes` as tombstones.
the count of rows actually tombstoned, which includes the rest of any blood-pressure reading a selected systolic
or diastolic belongs to (as on the single DELETE). The deletions surface in `/api/sync/changes` as tombstones.
parameters:
- *a6
requestBody:
@@ -4965,7 +4969,10 @@ paths:
description: Un-tombstones up to 200 of the caller's soft-deleted measurement rows in one call — the delete-Undo
counterpart to `/api/measurements/bulk-delete`. Idempotent via the `Idempotency-Key` header; rate-limited
`measurements:restore:<userId>`. Forged / foreign / not-deleted ids are silently skipped — `restored` is the
count of rows actually un-tombstoned. The restored rows re-surface in `/api/sync/changes` as upserts.
count of rows actually un-tombstoned. A blood-pressure systolic or diastolic also restores the rows of its
reading that were deleted with it (same source, identical `measuredAt` and `deletedAt`), so naming one half
undoes the whole reading; a row deleted at another moment stays deleted. The restored rows re-surface in
`/api/sync/changes` as upserts.
parameters:
- *a6
requestBody:
+39 -8
View File
@@ -23,6 +23,7 @@ import {
} from "@/lib/measurements/apple-health-mirror";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import { bloodPressureSiblings } from "@/lib/measurements/blood-pressure-reading";
import { Prisma, type MeasurementType } from "@/generated/prisma/client";
import { NextRequest } from "next/server";
import { z } from "zod";
@@ -237,18 +238,45 @@ export const DELETE = apiHandler(
// (see `measurements/route.ts:100`), so the row is invisible to
// normal reads from this point on. A row that is already tombstoned
// re-bumps `syncVersion` harmlessly (idempotent re-delete).
await prisma.measurement.update({
where: { id },
data: {
deletedAt: new Date(),
syncVersion: { increment: 1 },
},
//
// A systolic or diastolic takes the rest of its reading with it (the
// other half and the cuff's pulse, `bloodPressureSiblings`), in the same
// transaction and with the same `deletedAt`, which is how the restore
// route knows to bring them back together. Each sibling bumps its own
// `syncVersion`, so a paired client sees every row's tombstone.
const deletedAt = new Date();
const siblings = await prisma.$transaction(async (tx) => {
const found = await bloodPressureSiblings(
tx,
user.id,
[existing],
"live",
);
await tx.measurement.update({
where: { id },
data: { deletedAt, syncVersion: { increment: 1 } },
});
if (found.length > 0) {
await tx.measurement.updateMany({
where: {
id: { in: found.map((s) => s.id) },
userId: user.id,
deletedAt: null,
},
data: { deletedAt, syncVersion: { increment: 1 } },
});
}
return found;
});
await auditLog("measurement.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { measurementId: id, type: existing.type },
details: {
measurementId: id,
type: existing.type,
readingRows: siblings.length,
},
});
annotate({
@@ -269,7 +297,10 @@ export const DELETE = apiHandler(
// the rollup row when the day's measurement count goes to zero).
await afterMeasurementMutation(
user.id,
[{ type: existing.type, measuredAt: existing.measuredAt }],
[
{ type: existing.type, measuredAt: existing.measuredAt },
...siblings.map((s) => ({ type: s.type, measuredAt: s.measuredAt })),
],
{ source: "manual" },
);
@@ -10,15 +10,19 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
vi.mock("@/lib/db", () => {
const prisma = {
measurement: {
findMany: vi.fn(),
updateMany: vi.fn(),
},
auditLog: { create: vi.fn() },
},
}));
// The read and the write run in one transaction; the stub hands the
// callback the same mocked client.
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(prisma)),
};
return { prisma };
});
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
@@ -113,8 +117,16 @@ describe("POST /api/measurements/bulk-delete", () => {
it("soft-deletes only owned, non-tombstoned rows and returns that count", async () => {
// Caller asked for 3 ids but only 2 are owned + live.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T08:00:00Z") },
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T20:00:00Z") },
{
id: "m1",
type: "WEIGHT",
measuredAt: new Date("2026-01-01T08:00:00Z"),
},
{
id: "m2",
type: "WEIGHT",
measuredAt: new Date("2026-01-01T20:00:00Z"),
},
] as never);
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({
count: 2,
@@ -129,7 +141,9 @@ describe("POST /api/measurements/bulk-delete", () => {
// never 404s, it's just excluded from the count.
const call = vi.mocked(prisma.measurement.updateMany).mock.calls[0][0];
expect(call.where).toMatchObject({
id: { in: ["m1", "m2", "foreign"] },
// The write names the rows the scoped read found, never the
// forged id the caller sent.
id: { in: ["m1", "m2"] },
userId: "user-1",
deletedAt: null,
});
+23 -10
View File
@@ -7,6 +7,8 @@
*
* Soft-deletes (tombstones) every owned, not-already-deleted row in one
* `updateMany` — matching the single-DELETE iOS tombstone contract. A
* selected blood-pressure half takes the rest of its reading with it, as the
* single DELETE does, and `deleted` counts every row tombstoned. A
* forged / foreign id is a silent no-op (never a 404 existence leak): the
* mutation `where` pins `userId` so it only ever touches the caller's rows.
*
@@ -26,6 +28,7 @@ import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import { bloodPressureSiblings } from "@/lib/measurements/blood-pressure-reading";
import type { MeasurementType } from "@/generated/prisma/client";
const MAX_IDS_PER_BATCH = 200;
@@ -73,17 +76,27 @@ async function postBulkDelete(request: NextRequest): Promise<Response> {
// a status-insight cache drop. Ownership-scoped + `deletedAt: null` so
// a forged / foreign / already-tombstoned id is excluded here exactly
// as it is from the mutation below — no existence leak.
const affected = await prisma.measurement.findMany({
where: { id: { in: ids }, userId: user.id, deletedAt: null },
select: { type: true, measuredAt: true },
});
//
// A selected systolic or diastolic brings the rest of its reading (the
// other half and the cuff's pulse, `bloodPressureSiblings`) into the same
// statement, so the shared `deletedAt` lets the restore route bring the
// reading back whole.
const { affected, count } = await prisma.$transaction(async (tx) => {
const selected = await tx.measurement.findMany({
where: { id: { in: ids }, userId: user.id, deletedAt: null },
select: { id: true, type: true, source: true, measuredAt: true },
});
const siblings = await bloodPressureSiblings(tx, user.id, selected, "live");
const rowIds = [...selected, ...siblings].map((row) => row.id);
// Soft-delete (tombstone) in one statement. The `where` pins `userId`
// so a forged id for another user is silently a no-op; `deletedAt: null`
// keeps the count honest (an already-tombstoned id doesn't re-count).
const { count } = await prisma.measurement.updateMany({
where: { id: { in: ids }, userId: user.id, deletedAt: null },
data: { deletedAt: now, syncVersion: { increment: 1 } },
// Soft-delete (tombstone) in one statement. The `where` pins `userId`
// so a forged id for another user is silently a no-op; `deletedAt: null`
// keeps the count honest (an already-tombstoned id doesn't re-count).
const { count } = await tx.measurement.updateMany({
where: { id: { in: rowIds }, userId: user.id, deletedAt: null },
data: { deletedAt: now, syncVersion: { increment: 1 } },
});
return { affected: [...selected, ...siblings], count };
});
await auditLog("measurement.delete.bulk", {
@@ -10,15 +10,19 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
vi.mock("@/lib/db", () => {
const prisma = {
measurement: {
findMany: vi.fn(),
updateMany: vi.fn(),
},
auditLog: { create: vi.fn() },
},
}));
// The read and the write run in one transaction; the stub hands the
// callback the same mocked client.
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(prisma)),
};
return { prisma };
});
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
@@ -110,8 +114,16 @@ describe("POST /api/measurements/restore", () => {
it("un-tombstones only owned, tombstoned rows and returns that count", async () => {
// Caller asked for 3 ids but only 2 are owned + tombstoned.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T08:00:00Z") },
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T20:00:00Z") },
{
id: "m1",
type: "WEIGHT",
measuredAt: new Date("2026-01-01T08:00:00Z"),
},
{
id: "m2",
type: "WEIGHT",
measuredAt: new Date("2026-01-01T20:00:00Z"),
},
] as never);
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({
count: 2,
@@ -126,7 +138,9 @@ describe("POST /api/measurements/restore", () => {
// forged / live id never 404s, it's just excluded from the count.
const call = vi.mocked(prisma.measurement.updateMany).mock.calls[0][0];
expect(call.where).toMatchObject({
id: { in: ["m1", "m2", "foreign"] },
// The write names the rows the scoped read found, never the
// forged id the caller sent.
id: { in: ["m1", "m2"] },
userId: "user-1",
deletedAt: { not: null },
});
+33 -10
View File
@@ -28,6 +28,7 @@ import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import { bloodPressureSiblings } from "@/lib/measurements/blood-pressure-reading";
import type { MeasurementType } from "@/generated/prisma/client";
const MAX_IDS_PER_BATCH = 200;
@@ -73,17 +74,39 @@ async function postRestore(request: NextRequest): Promise<Response> {
// a status-insight cache drop. Ownership-scoped + `deletedAt != null`
// so a forged / foreign / never-deleted id is excluded here exactly as
// it is from the mutation below — no existence leak.
const affected = await prisma.measurement.findMany({
where: { id: { in: ids }, userId: user.id, deletedAt: { not: null } },
select: { type: true, measuredAt: true },
});
//
// A blood-pressure half brings back the rows of its reading that were
// tombstoned in the same statement (identical `deletedAt`,
// `bloodPressureSiblings`), so an undo that names only the row the person
// tapped restores the reading the delete took. A row deleted on its own,
// at another moment, stays deleted.
const { affected, count } = await prisma.$transaction(async (tx) => {
const selected = await tx.measurement.findMany({
where: { id: { in: ids }, userId: user.id, deletedAt: { not: null } },
select: {
id: true,
type: true,
source: true,
measuredAt: true,
deletedAt: true,
},
});
const siblings = await bloodPressureSiblings(
tx,
user.id,
selected,
"deletedWith",
);
const rowIds = [...selected, ...siblings].map((row) => row.id);
// Un-tombstone in one statement. The `where` pins `userId` so a forged
// id for another user is silently a no-op; `deletedAt: { not: null }`
// keeps the count honest (a live row doesn't re-count).
const { count } = await prisma.measurement.updateMany({
where: { id: { in: ids }, userId: user.id, deletedAt: { not: null } },
data: { deletedAt: null, syncVersion: { increment: 1 } },
// Un-tombstone in one statement. The `where` pins `userId` so a forged
// id for another user is silently a no-op; `deletedAt: { not: null }`
// keeps the count honest (a live row doesn't re-count).
const { count } = await tx.measurement.updateMany({
where: { id: { in: rowIds }, userId: user.id, deletedAt: { not: null } },
data: { deletedAt: null, syncVersion: { increment: 1 } },
});
return { affected: [...selected, ...siblings], count };
});
await auditLog("measurement.restore", {
@@ -0,0 +1,82 @@
/**
* One blood-pressure reading is up to three rows: systolic, diastolic and
* the pulse the cuff measured with them. No column links them. What makes
* them one reading is how every writer stores it: the entry form, the MCP
* write, a Withings measure group and a HealthKit correlation all write the
* halves with the same source at the same instant, and the FHIR read pairs a
* diastolic onto its systolic by exactly that instant.
*
* So the rule here is the strict one: same owner, same source, identical
* `measuredAt`. The series and in-target reads pair more loosely (nearest
* diastolic within five minutes) because they only draw a point; a delete
* takes rows away, so it takes only the rows that can be nothing else.
*
* Deleting a systolic or a diastolic takes the whole reading with it.
* Deleting a pulse takes only the pulse: a pulse is also a value of its own,
* and removing one is no reason to remove a blood pressure.
*/
import type { MeasurementType, Prisma } from "@/generated/prisma/client";
/** The types that anchor a reading: deleting one deletes the reading. */
const BP_HALVES: ReadonlySet<string> = new Set([
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
]);
/** Every type a reading's rows can carry. */
const BP_READING_TYPES: MeasurementType[] = [
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
];
export interface ReadingAnchor {
id: string;
type: MeasurementType | string;
source: string;
measuredAt: Date;
deletedAt?: Date | null;
}
type MeasurementReader = {
measurement: Pick<Prisma.TransactionClient["measurement"], "findMany">;
};
export interface ReadingSibling {
id: string;
type: MeasurementType;
measuredAt: Date;
}
/**
* The other rows of the readings `rows` belong to, not counting `rows`
* themselves. `state: "live"` finds the rows a delete takes with it;
* `state: "deletedWith"` finds the rows that were tombstoned in the same
* statement as their anchor (the identical `deletedAt`), which is what a
* restore brings back with it, and never a row deleted on its own.
*/
export async function bloodPressureSiblings(
db: MeasurementReader,
userId: string,
rows: readonly ReadingAnchor[],
state: "live" | "deletedWith",
): Promise<ReadingSibling[]> {
const anchors = rows.filter(
(r) => BP_HALVES.has(r.type) && (state === "live" || r.deletedAt),
);
if (anchors.length === 0) return [];
const found = await db.measurement.findMany({
where: {
userId,
id: { notIn: rows.map((r) => r.id) },
type: { in: BP_READING_TYPES },
OR: anchors.map((a) => ({
source: a.source as Prisma.MeasurementWhereInput["source"],
measuredAt: a.measuredAt,
deletedAt: state === "live" ? null : a.deletedAt,
})),
},
select: { id: true, type: true, measuredAt: true },
});
return found;
}
+3 -3
View File
@@ -1055,7 +1055,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Measurements"],
summary: "Delete a measurement",
description:
"Soft-deletes the row (tombstone) so paired clients can reconcile the deletion.",
"Soft-deletes the row (tombstone) so paired clients can reconcile the deletion. A blood-pressure systolic or diastolic takes the rest of its reading with it: the other half and the pulse with the same source and the identical `measuredAt`, tombstoned in the same transaction with the same `deletedAt`, each with its own `syncVersion` bump. A pulse is deleted on its own.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
...recordRefusal(),
@@ -1145,7 +1145,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Measurements"],
summary: "Bulk soft-delete measurements",
description:
"Soft-deletes (tombstones) up to 200 of the caller's measurement rows in one call, mirroring the single-DELETE contract. Idempotent via the `Idempotency-Key` header; rate-limited `measurements:bulk-delete:<userId>`. Forged / foreign / already-deleted ids are silently skipped — `deleted` is the count of rows actually tombstoned. The deletions surface in `/api/sync/changes` as tombstones.",
"Soft-deletes (tombstones) up to 200 of the caller's measurement rows in one call, mirroring the single-DELETE contract. Idempotent via the `Idempotency-Key` header; rate-limited `measurements:bulk-delete:<userId>`. Forged / foreign / already-deleted ids are silently skipped — `deleted` is the count of rows actually tombstoned, which includes the rest of any blood-pressure reading a selected systolic or diastolic belongs to (as on the single DELETE). The deletions surface in `/api/sync/changes` as tombstones.",
requestBody: {
required: true,
content: {
@@ -1176,7 +1176,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Measurements"],
summary: "Restore soft-deleted measurements",
description:
"Un-tombstones up to 200 of the caller's soft-deleted measurement rows in one call — the delete-Undo counterpart to `/api/measurements/bulk-delete`. Idempotent via the `Idempotency-Key` header; rate-limited `measurements:restore:<userId>`. Forged / foreign / not-deleted ids are silently skipped — `restored` is the count of rows actually un-tombstoned. The restored rows re-surface in `/api/sync/changes` as upserts.",
"Un-tombstones up to 200 of the caller's soft-deleted measurement rows in one call — the delete-Undo counterpart to `/api/measurements/bulk-delete`. Idempotent via the `Idempotency-Key` header; rate-limited `measurements:restore:<userId>`. Forged / foreign / not-deleted ids are silently skipped — `restored` is the count of rows actually un-tombstoned. A blood-pressure systolic or diastolic also restores the rows of its reading that were deleted with it (same source, identical `measuredAt` and `deletedAt`), so naming one half undoes the whole reading; a row deleted at another moment stays deleted. The restored rows re-surface in `/api/sync/changes` as upserts.",
requestBody: {
required: true,
content: {
@@ -0,0 +1,198 @@
/**
* 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.
*/
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";
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),
}));
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"]);
});
});