mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +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.
258 lines
9.1 KiB
TypeScript
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");
|
|
});
|
|
});
|