Files
HealthLog/tests/integration/measurements-batch-delete.test.ts
Marc-André Bombeck b6da360856 refactor(db): drop the runtime schema mirror
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.
2026-11-03 20:32:35 +01:00

258 lines
9.1 KiB
TypeScript

/**
* Integration suite for `DELETE /api/measurements/by-external-ids` —
* the iOS deletion-sync endpoint. Asserts the contract the iOS client
* relies on for HealthKit reconciliation:
* - Happy path: matching rows are SOFT-deleted (v1.7.0 — `deletedAt`
* set + `syncVersion` bumped, row retained), deletedCount equals the
* number freshly tombstoned. Tombstoned rows are invisible to normal
* reads but still present in the table for the tombstone feed.
* - Cross-user 404 guard: another user's rows that happen to share an
* externalId remain untouched (live).
* - Empty array: returns 200 with deletedCount = 0 (not a failure).
* - Batch size cap: 501 externalIds returns 422 with a documented
* errorCode so the client can surface the diagnostic.
* - Replay idempotency: a second delete of the same externalIds is a
* no-op (deletedCount 0) because the rows are already tombstoned.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
const TEST_USER_ID = "user-batch-delete-test";
const OTHER_USER_ID = "user-batch-delete-other";
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);
},
})),
};
});
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
await getPrismaClient().user.create({
data: {
id: TEST_USER_ID,
username: "batch-delete",
email: "batch-delete@example.test",
},
});
await getPrismaClient().user.create({
data: {
id: OTHER_USER_ID,
username: "batch-delete-other",
email: "batch-delete-other@example.test",
},
});
const session = await getPrismaClient().session.create({
data: {
userId: TEST_USER_ID,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
});
function makeRequest(body: { externalIds: string[] }): NextRequest {
return new NextRequest("http://localhost/api/measurements/by-external-ids", {
method: "DELETE",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
// Hash an externalId into a stable minute offset (0..1023) so two
// rows seeded with different externalIds land on different
// `measuredAt` values. The W17b/c unique index now covers
// (user_id, type, measured_at, source, sleep_stage) with NULLs not
// distinct, so all-same-instant fixtures collide; spreading by
// minute keeps the fixture deterministic without sacrificing the
// "owned by the caller" intent of the test.
function minuteOffsetFor(externalId: string): number {
let h = 0;
for (let i = 0; i < externalId.length; i++) {
h = (h * 31 + externalId.charCodeAt(i)) | 0;
}
return Math.abs(h) % 1024;
}
async function seedMeasurement(opts: {
userId: string;
externalId: string;
value?: number;
}): Promise<void> {
const base = new Date("2026-05-09T07:30:00.000Z").getTime();
const measuredAt = new Date(base + minuteOffsetFor(opts.externalId) * 60_000);
await getPrismaClient().measurement.create({
data: {
userId: opts.userId,
type: "WEIGHT",
value: opts.value ?? 80,
unit: "kg",
source: "APPLE_HEALTH",
measuredAt,
externalId: opts.externalId,
},
});
}
describe("DELETE /api/measurements/by-external-ids (real Postgres)", () => {
it("soft-deletes matching rows owned by the caller and returns deletedCount", async () => {
const { DELETE } =
await import("@/app/api/measurements/by-external-ids/route");
await seedMeasurement({ userId: TEST_USER_ID, externalId: "uuid-del-1" });
await seedMeasurement({ userId: TEST_USER_ID, externalId: "uuid-del-2" });
await seedMeasurement({
userId: TEST_USER_ID,
externalId: "uuid-keep-3",
});
const response = await DELETE(
makeRequest({ externalIds: ["uuid-del-1", "uuid-del-2"] }),
);
expect(response.status).toBe(200);
const json = (await response.json()) as { data: { deletedCount: number } };
expect(json.data.deletedCount).toBe(2);
// v1.7.0 — rows are SOFT-deleted, so all three rows still exist in
// the table. The two targeted rows carry a non-null `deletedAt` (and
// a bumped `syncVersion`); the untouched row stays live.
const all = await getPrismaClient().measurement.findMany({
where: { userId: TEST_USER_ID },
orderBy: { externalId: "asc" },
});
expect(all).toHaveLength(3);
const live = all.filter((r) => r.deletedAt === null);
expect(live).toHaveLength(1);
expect(live[0]?.externalId).toBe("uuid-keep-3");
const tombstoned = all.filter((r) => r.deletedAt !== null);
expect(tombstoned.map((r) => r.externalId).sort()).toEqual([
"uuid-del-1",
"uuid-del-2",
]);
// syncVersion bumped from the default 1 to 2 on tombstone.
for (const row of tombstoned) {
expect(row.syncVersion).toBe(2);
}
});
it("never tombstones rows owned by another user (cross-user 404 guard)", async () => {
const { DELETE } =
await import("@/app/api/measurements/by-external-ids/route");
// Two users own rows with the same externalId; deleting via the
// logged-in user must only tombstone that user's row.
await seedMeasurement({ userId: TEST_USER_ID, externalId: "uuid-shared" });
await seedMeasurement({
userId: OTHER_USER_ID,
externalId: "uuid-shared",
value: 70,
});
const response = await DELETE(
makeRequest({ externalIds: ["uuid-shared"] }),
);
expect(response.status).toBe(200);
const json = (await response.json()) as { data: { deletedCount: number } };
expect(json.data.deletedCount).toBe(1);
const callerRows = await getPrismaClient().measurement.findMany({
where: { userId: TEST_USER_ID },
});
expect(callerRows).toHaveLength(1);
expect(callerRows[0]?.deletedAt).not.toBeNull();
// The other user's row must stay live (deletedAt null) and untouched.
const otherRows = await getPrismaClient().measurement.findMany({
where: { userId: OTHER_USER_ID },
});
expect(otherRows).toHaveLength(1);
expect(otherRows[0]?.externalId).toBe("uuid-shared");
expect(otherRows[0]?.deletedAt).toBeNull();
});
it("accepts an empty externalIds array as a no-op", async () => {
const { DELETE } =
await import("@/app/api/measurements/by-external-ids/route");
await seedMeasurement({ userId: TEST_USER_ID, externalId: "uuid-keep" });
const response = await DELETE(makeRequest({ externalIds: [] }));
expect(response.status).toBe(200);
const json = (await response.json()) as { data: { deletedCount: number } };
expect(json.data.deletedCount).toBe(0);
const stored = await getPrismaClient().measurement.findMany({
where: { userId: TEST_USER_ID },
});
expect(stored).toHaveLength(1);
expect(stored[0]?.deletedAt).toBeNull();
});
it("is idempotent on replay — a second delete tombstones zero rows", async () => {
const { DELETE } =
await import("@/app/api/measurements/by-external-ids/route");
await seedMeasurement({ userId: TEST_USER_ID, externalId: "uuid-replay" });
const first = await DELETE(makeRequest({ externalIds: ["uuid-replay"] }));
const firstJson = (await first.json()) as {
data: { deletedCount: number };
};
expect(firstJson.data.deletedCount).toBe(1);
const second = await DELETE(makeRequest({ externalIds: ["uuid-replay"] }));
const secondJson = (await second.json()) as {
data: { deletedCount: number };
};
// Already tombstoned — the `deletedAt: null` guard matches nothing,
// so the replay is a no-op and syncVersion does not bump again.
expect(secondJson.data.deletedCount).toBe(0);
const row = await getPrismaClient().measurement.findFirst({
where: { userId: TEST_USER_ID, externalId: "uuid-replay" },
});
expect(row?.deletedAt).not.toBeNull();
expect(row?.syncVersion).toBe(2);
});
it("returns 422 with measurement.delete.too_large when batch exceeds the cap", async () => {
const { DELETE } =
await import("@/app/api/measurements/by-external-ids/route");
const externalIds: string[] = [];
for (let i = 0; i < 501; i++) {
externalIds.push(`uuid-cap-${i}`);
}
const response = await DELETE(makeRequest({ externalIds }));
expect(response.status).toBe(422);
const json = (await response.json()) as {
error: string;
meta?: { errorCode?: string };
};
expect(json.error).toMatch(/500/);
expect(json.meta?.errorCode).toBe("measurement.delete.too_large");
});
});