mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Add `POST /api/measurements/bulk-delete` and `POST /api/mood-entries/bulk-delete`,
mirroring the bulk-insert conventions (`withIdempotency`, per-user rate
limit, response envelope). Body is `{ ids: string[] }`, capped at 200.
Soft-delete only, ownership-scoped: a single `updateMany` pins `userId` +
`deletedAt: null` and sets `deletedAt` + bumps `syncVersion`, matching the
single-DELETE iOS tombstone contract. A forged or foreign id is a silent
no-op — the returned `deleted` count reflects owned rows only, never a 404
existence leak.
Side effects fan out efficiently: measurements collapse the deleted rows to
the unique `(type, day)` set before recomputing rollups (one recompute per
type-day, not per row) and invalidate the affected status-insight caches;
mood collapses to the unique day set for its rollup recompute. Both bust the
per-user read caches.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
251 lines
9.6 KiB
TypeScript
251 lines
9.6 KiB
TypeScript
/**
|
|
* v1.15.13 — bulk soft-delete + source filter, real-Postgres integration.
|
|
*
|
|
* Covers, against the real testcontainer:
|
|
* - `POST /api/measurements/bulk-delete` soft-deletes only the caller's
|
|
* rows; a foreign id is a silent no-op (no existence leak), bumps
|
|
* `syncVersion`, sets `deletedAt`, and the count reflects owned rows
|
|
* only.
|
|
* - Idempotency replay returns the same `deleted` count without
|
|
* re-mutating (a second distinct call would re-bump syncVersion).
|
|
* - `POST /api/mood-entries/bulk-delete` mirrors the same contract.
|
|
* - `sourceEq` (measurements) + `source` (mood) narrow the list reads.
|
|
*/
|
|
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 USER_A = "user-bulkdel-a";
|
|
const USER_B = "user-bulkdel-b";
|
|
|
|
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),
|
|
}));
|
|
|
|
async function seedUser(id: string, username: string) {
|
|
const prisma = getPrismaClient();
|
|
await prisma.user.create({
|
|
data: { id, username, email: `${username}@example.test`, timezone: "UTC" },
|
|
});
|
|
}
|
|
|
|
async function loginAs(id: string) {
|
|
const session = await getPrismaClient().session.create({
|
|
data: { userId: id, expiresAt: new Date(Date.now() + 60 * 60 * 1000) },
|
|
});
|
|
cookieJar.clear();
|
|
cookieJar.set("healthlog_session", session.id);
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
await seedUser(USER_A, "bulkdel-a");
|
|
await seedUser(USER_B, "bulkdel-b");
|
|
});
|
|
|
|
function postReq(path: string, body: unknown, idempotencyKey?: string) {
|
|
return new NextRequest(`http://localhost${path}`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
describe("POST /api/measurements/bulk-delete (real Postgres)", () => {
|
|
it("soft-deletes owned rows, skips a foreign id, bumps syncVersion", async () => {
|
|
const prisma = getPrismaClient();
|
|
const now = new Date();
|
|
const mine = await prisma.measurement.createManyAndReturn({
|
|
data: [
|
|
{ userId: USER_A, type: "WEIGHT", value: 80, unit: "kg", source: "MANUAL", measuredAt: now },
|
|
{ userId: USER_A, type: "WEIGHT", value: 81, unit: "kg", source: "MANUAL", measuredAt: new Date(now.getTime() - 1000) },
|
|
],
|
|
});
|
|
const theirs = await prisma.measurement.create({
|
|
data: { userId: USER_B, type: "WEIGHT", value: 70, unit: "kg", source: "MANUAL", measuredAt: now },
|
|
});
|
|
|
|
await loginAs(USER_A);
|
|
const { POST } = await import("@/app/api/measurements/bulk-delete/route");
|
|
const res = await POST(
|
|
postReq("/api/measurements/bulk-delete", {
|
|
ids: [mine[0].id, mine[1].id, theirs.id],
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { data: { deleted: number } };
|
|
// Only the two owned rows count; the foreign id is a silent no-op.
|
|
expect(body.data.deleted).toBe(2);
|
|
|
|
const minedAfter = await prisma.measurement.findMany({
|
|
where: { id: { in: [mine[0].id, mine[1].id] } },
|
|
});
|
|
for (const row of minedAfter) {
|
|
expect(row.deletedAt).not.toBeNull();
|
|
// Measurement.syncVersion defaults to 1; one bulk delete bumps it to 2.
|
|
expect(row.syncVersion).toBe(2);
|
|
}
|
|
// The other user's row is untouched (default syncVersion = 1).
|
|
const theirsAfter = await prisma.measurement.findUnique({
|
|
where: { id: theirs.id },
|
|
});
|
|
expect(theirsAfter!.deletedAt).toBeNull();
|
|
expect(theirsAfter!.syncVersion).toBe(1);
|
|
});
|
|
|
|
it("idempotency replay returns the cached count without re-mutating", async () => {
|
|
const prisma = getPrismaClient();
|
|
const row = await prisma.measurement.create({
|
|
data: { userId: USER_A, type: "WEIGHT", value: 80, unit: "kg", source: "MANUAL", measuredAt: new Date() },
|
|
});
|
|
|
|
await loginAs(USER_A);
|
|
const { POST } = await import("@/app/api/measurements/bulk-delete/route");
|
|
const key = "idem-meas-bulk-1";
|
|
|
|
const first = await POST(
|
|
postReq("/api/measurements/bulk-delete", { ids: [row.id] }, key),
|
|
);
|
|
expect(first.status).toBe(200);
|
|
expect(((await first.json()) as { data: { deleted: number } }).data.deleted).toBe(1);
|
|
|
|
// Replay with the SAME key → cached body, same count, no re-mutation.
|
|
const replay = await POST(
|
|
postReq("/api/measurements/bulk-delete", { ids: [row.id] }, key),
|
|
);
|
|
expect(replay.status).toBe(200);
|
|
expect(replay.headers.get("X-Idempotent-Replay")).toBe("true");
|
|
expect(((await replay.json()) as { data: { deleted: number } }).data.deleted).toBe(1);
|
|
|
|
// Measurement.syncVersion defaults to 1; the single real delete bumped
|
|
// it to 2 and the replay did NOT re-run the mutation (so it stays 2).
|
|
const after = await prisma.measurement.findUnique({ where: { id: row.id } });
|
|
expect(after!.syncVersion).toBe(2);
|
|
});
|
|
|
|
it("rejects a >200-id batch with 422", async () => {
|
|
await loginAs(USER_A);
|
|
const { POST } = await import("@/app/api/measurements/bulk-delete/route");
|
|
const ids = Array.from({ length: 201 }, (_, i) => `id-${i}`);
|
|
const res = await POST(postReq("/api/measurements/bulk-delete", { ids }));
|
|
expect(res.status).toBe(422);
|
|
});
|
|
});
|
|
|
|
describe("POST /api/mood-entries/bulk-delete (real Postgres)", () => {
|
|
it("soft-deletes owned rows, skips a foreign id, bumps syncVersion", async () => {
|
|
const prisma = getPrismaClient();
|
|
const mine = await prisma.moodEntry.createManyAndReturn({
|
|
data: [
|
|
{ userId: USER_A, date: "2026-01-01", mood: "GUT", score: 4, source: "MANUAL", moodLoggedAt: new Date("2026-01-01T08:00:00Z"), tz: "UTC" },
|
|
{ userId: USER_A, date: "2026-01-02", mood: "OKAY", score: 3, source: "MANUAL", moodLoggedAt: new Date("2026-01-02T08:00:00Z"), tz: "UTC" },
|
|
],
|
|
});
|
|
const theirs = await prisma.moodEntry.create({
|
|
data: { userId: USER_B, date: "2026-01-01", mood: "LAUSIG", score: 1, source: "MANUAL", moodLoggedAt: new Date("2026-01-01T09:00:00Z"), tz: "UTC" },
|
|
});
|
|
|
|
await loginAs(USER_A);
|
|
const { POST } = await import("@/app/api/mood-entries/bulk-delete/route");
|
|
const res = await POST(
|
|
postReq("/api/mood-entries/bulk-delete", {
|
|
ids: [mine[0].id, mine[1].id, theirs.id],
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(((await res.json()) as { data: { deleted: number } }).data.deleted).toBe(2);
|
|
|
|
const after = await prisma.moodEntry.findMany({
|
|
where: { id: { in: [mine[0].id, mine[1].id] } },
|
|
});
|
|
for (const row of after) {
|
|
expect(row.deletedAt).not.toBeNull();
|
|
expect(row.syncVersion).toBe(1);
|
|
}
|
|
const theirsAfter = await prisma.moodEntry.findUnique({
|
|
where: { id: theirs.id },
|
|
});
|
|
expect(theirsAfter!.deletedAt).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("source filter narrows the list reads (real Postgres)", () => {
|
|
it("measurements sourceEq returns only the matching source", async () => {
|
|
const prisma = getPrismaClient();
|
|
const now = new Date();
|
|
await prisma.measurement.createMany({
|
|
data: [
|
|
{ userId: USER_A, type: "WEIGHT", value: 80, unit: "kg", source: "MANUAL", measuredAt: now },
|
|
{ userId: USER_A, type: "WEIGHT", value: 81, unit: "kg", source: "WITHINGS", measuredAt: new Date(now.getTime() - 1000) },
|
|
{ userId: USER_A, type: "WEIGHT", value: 82, unit: "kg", source: "WITHINGS", measuredAt: new Date(now.getTime() - 2000) },
|
|
],
|
|
});
|
|
|
|
await loginAs(USER_A);
|
|
const { GET } = await import("@/app/api/measurements/route");
|
|
const res = await GET(
|
|
new NextRequest("http://localhost/api/measurements?sourceEq=WITHINGS"),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as {
|
|
data: {
|
|
measurements: Array<{ source: string }>;
|
|
meta: { total: number };
|
|
};
|
|
};
|
|
expect(body.data.measurements).toHaveLength(2);
|
|
expect(body.data.measurements.every((m) => m.source === "WITHINGS")).toBe(true);
|
|
expect(body.data.meta.total).toBe(2);
|
|
});
|
|
|
|
it("mood source returns only the matching source", async () => {
|
|
const prisma = getPrismaClient();
|
|
await prisma.moodEntry.createMany({
|
|
data: [
|
|
{ userId: USER_A, date: "2026-01-01", mood: "GUT", score: 4, source: "MANUAL", moodLoggedAt: new Date("2026-01-01T08:00:00Z"), tz: "UTC" },
|
|
{ userId: USER_A, date: "2026-01-02", mood: "OKAY", score: 3, source: "TELEGRAM", moodLoggedAt: new Date("2026-01-02T08:00:00Z"), tz: "UTC" },
|
|
],
|
|
});
|
|
|
|
await loginAs(USER_A);
|
|
const { GET } = await import("@/app/api/mood-entries/route");
|
|
const res = await GET(
|
|
new NextRequest("http://localhost/api/mood-entries?source=TELEGRAM"),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as {
|
|
data: { entries: Array<{ source: string }>; meta: { total: number } };
|
|
};
|
|
expect(body.data.entries).toHaveLength(1);
|
|
expect(body.data.entries[0].source).toBe("TELEGRAM");
|
|
expect(body.data.meta.total).toBe(1);
|
|
});
|
|
});
|