Files
HealthLog/tests/integration/bulk-delete-source-filter.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

346 lines
10 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);
},
})),
};
});
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);
});
});