diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 479a40268..f8c36f55f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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:`. 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:`. 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: diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts index 84e18c624..be24ec3a2 100644 --- a/src/app/api/measurements/[id]/route.ts +++ b/src/app/api/measurements/[id]/route.ts @@ -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" }, ); diff --git a/src/app/api/measurements/bulk-delete/__tests__/route.test.ts b/src/app/api/measurements/bulk-delete/__tests__/route.test.ts index b41d53ccb..f546f195d 100644 --- a/src/app/api/measurements/bulk-delete/__tests__/route.test.ts +++ b/src/app/api/measurements/bulk-delete/__tests__/route.test.ts @@ -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, }); diff --git a/src/app/api/measurements/bulk-delete/route.ts b/src/app/api/measurements/bulk-delete/route.ts index 03ea9abc3..16e2a22ee 100644 --- a/src/app/api/measurements/bulk-delete/route.ts +++ b/src/app/api/measurements/bulk-delete/route.ts @@ -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 { // 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", { diff --git a/src/app/api/measurements/restore/__tests__/route.test.ts b/src/app/api/measurements/restore/__tests__/route.test.ts index 0fa7e8427..73ce2540e 100644 --- a/src/app/api/measurements/restore/__tests__/route.test.ts +++ b/src/app/api/measurements/restore/__tests__/route.test.ts @@ -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 }, }); diff --git a/src/app/api/measurements/restore/route.ts b/src/app/api/measurements/restore/route.ts index 3c2a7b92d..62aeab9a7 100644 --- a/src/app/api/measurements/restore/route.ts +++ b/src/app/api/measurements/restore/route.ts @@ -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 { // 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", { diff --git a/src/lib/measurements/blood-pressure-reading.ts b/src/lib/measurements/blood-pressure-reading.ts new file mode 100644 index 000000000..589a7fb66 --- /dev/null +++ b/src/lib/measurements/blood-pressure-reading.ts @@ -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 = 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; +}; + +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 { + 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; +} diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index c8a3dc8af..b04e40220 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -1055,7 +1055,7 @@ export const measurementPaths: NonNullable = { 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 = { 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:`. 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:`. 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 = { 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:`. 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:`. 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: { diff --git a/tests/integration/blood-pressure-reading-delete.test.ts b/tests/integration/blood-pressure-reading-delete.test.ts new file mode 100644 index 000000000..d7f4028d9 --- /dev/null +++ b/tests/integration/blood-pressure-reading-delete.test.ts @@ -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 +> { + 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"]); + }); +});