Files
HealthLog/tests/integration/measurements-batch-delete.test.ts
T
Marc-André Bombeck 959adec899 feat(api): DELETE /api/measurements/by-external-ids for iOS deletion-sync
HealthKit's API does not surface a stream of deleted UUIDs, so the iOS
HealthLog client performs a periodic 30-day reconciliation: it diffs
HealthKit's current UUID set against the server's externalIds and
posts the surplus here. This endpoint deletes the user's matching
rows and never touches another user's data (Prisma's deleteMany with
the userId predicate is the cross-user 404 guard).

Contract:
  - Body: { externalIds: string[] } with min 0, max 500 entries.
  - Auth: requireAuth() — session cookie or bearer.
  - Empty array: returns { deletedCount: 0 } with 200 (no-op, not a
    failure — matches the ingest path's symmetric tolerance).
  - Over-cap: 422 with errorCode "measurement.delete.too_large" so
    the iOS client can surface a specific diagnostic.

Integration coverage: happy path, cross-user 404 guard, empty array,
and batch-cap rejection. The cap (500) mirrors POST /measurements/batch
so the two surfaces share one operational ceiling.

OpenAPI: registered as DELETE on /api/measurements/by-external-ids
with the Measurements tag; req/res schemas reuse the same z.array cap
the runtime validator enforces. openapi.yaml regenerated.
2026-05-14 13:07:05 +02:00

194 lines
6.2 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 deleted, deletedCount equals the
* number actually removed.
* - Cross-user 404 guard: another user's rows that happen to share an
* externalId remain untouched.
* - 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.
*/
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);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
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),
});
}
async function seedMeasurement(opts: {
userId: string;
externalId: string;
value?: number;
}): Promise<void> {
await getPrismaClient().measurement.create({
data: {
userId: opts.userId,
type: "WEIGHT",
value: opts.value ?? 80,
unit: "kg",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-09T07:30:00.000Z"),
externalId: opts.externalId,
},
});
}
describe("DELETE /api/measurements/by-external-ids (real Postgres)", () => {
it("removes 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);
const remaining = await getPrismaClient().measurement.findMany({
where: { userId: TEST_USER_ID },
orderBy: { externalId: "asc" },
});
expect(remaining).toHaveLength(1);
expect(remaining[0]?.externalId).toBe("uuid-keep-3");
});
it("never deletes 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 remove 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(0);
const otherRows = await getPrismaClient().measurement.findMany({
where: { userId: OTHER_USER_ID },
});
expect(otherRows).toHaveLength(1);
expect(otherRows[0]?.externalId).toBe("uuid-shared");
});
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);
});
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");
});
});