mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +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.
376 lines
12 KiB
TypeScript
376 lines
12 KiB
TypeScript
/**
|
|
* v1.4.40 — soft-delete invisibility across every reader tier.
|
|
*
|
|
* `Measurement.deletedAt` was set by the iOS sync path but ignored by
|
|
* every read path (Senior Infra+DB audit Critical Finding #3 in
|
|
* `.planning/round-v1439-arch-qa-infra-db.md`). The moment iOS soft-
|
|
* deleted its first reading, the tombstoned row would still contribute
|
|
* to dashboards, analytics fan-outs, the rollup populator's count, and
|
|
* the Coach snapshot the model grounds against.
|
|
*
|
|
* This test pins the tombstone-invisibility contract end-to-end against
|
|
* the real Postgres testcontainer. Three tiers are covered:
|
|
*
|
|
* 1. **Analytics summaries slice** — the rollup-fresh path's narrow
|
|
* aggregate + DISTINCT ON latest reads MUST exclude the soft-
|
|
* deleted row. The slim slice runs the live aggregator on this
|
|
* fixture (no rollup coverage yet) so the cold-fallback path is
|
|
* exercised in the same call.
|
|
*
|
|
* 2. **Dashboard summary** — the per-type `groupBy` aggregate +
|
|
* `latestIn7d` DISTINCT ON + streak-day raw SQL all need the
|
|
* filter. The fixture parks one soft-deleted row inside the 7-day
|
|
* window so the iOS Dashboard tile-strip can be checked for
|
|
* tombstone leakage.
|
|
*
|
|
* 3. **Rollup recompute** — `recomputeBucketsForMeasurement` must
|
|
* build the DAY bucket from live (non-deleted) rows only. The
|
|
* fixture seeds three same-day rows, soft-deletes one, recomputes,
|
|
* and asserts `count == 2` (not 3) on the resulting bucket.
|
|
*/
|
|
import { NextRequest } from "next/server";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
process.env.ENCRYPTION_KEY ??=
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
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();
|
|
headerJar.clear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
async function seedSession(username: string) {
|
|
const prisma = getPrismaClient();
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
username,
|
|
email: `${username}@example.test`,
|
|
role: "USER",
|
|
},
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: {
|
|
userId: user.id,
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
},
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
return user;
|
|
}
|
|
|
|
describe("Measurement.deletedAt — tombstone invisibility (v1.4.40 W-DELETED)", () => {
|
|
it("excludes soft-deleted measurements from the analytics summaries slice", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await seedSession("soft-delete-analytics");
|
|
|
|
// Three same-type WEIGHT readings; the middle one (value 99) is the
|
|
// tombstone that must NOT contribute to count / min / max / mean.
|
|
const now = new Date();
|
|
const oneDayAgo = new Date(now.getTime() - 86_400_000);
|
|
const twoDaysAgo = new Date(now.getTime() - 2 * 86_400_000);
|
|
await prisma.measurement.createMany({
|
|
data: [
|
|
{
|
|
userId: user.id,
|
|
type: "WEIGHT",
|
|
value: 80.0,
|
|
unit: "kg",
|
|
source: "MANUAL",
|
|
measuredAt: twoDaysAgo,
|
|
},
|
|
{
|
|
userId: user.id,
|
|
type: "WEIGHT",
|
|
// Out-of-band sentinel — the test fails loudly if this row
|
|
// leaks into any aggregate (it would crash min/max + skew the
|
|
// mean by a huge margin).
|
|
value: 99.0,
|
|
unit: "kg",
|
|
source: "MANUAL",
|
|
measuredAt: oneDayAgo,
|
|
},
|
|
{
|
|
userId: user.id,
|
|
type: "WEIGHT",
|
|
value: 80.2,
|
|
unit: "kg",
|
|
source: "MANUAL",
|
|
measuredAt: now,
|
|
},
|
|
],
|
|
});
|
|
|
|
// Tombstone the middle row.
|
|
await prisma.measurement.updateMany({
|
|
where: { userId: user.id, value: 99.0 },
|
|
data: { deletedAt: new Date() },
|
|
});
|
|
|
|
const { GET } = await import("@/app/api/analytics/route");
|
|
const response = await (
|
|
GET as unknown as (req: Request) => Promise<Response>
|
|
)(new Request("http://localhost/api/analytics?slice=summaries"));
|
|
|
|
expect(response.status).toBe(200);
|
|
const envelope = (await response.json()) as {
|
|
data: {
|
|
summaries: Record<
|
|
string,
|
|
{
|
|
count: number;
|
|
latest: number | null;
|
|
min: number | null;
|
|
max: number | null;
|
|
mean: number | null;
|
|
}
|
|
>;
|
|
};
|
|
};
|
|
const weight = envelope.data.summaries.WEIGHT;
|
|
|
|
// The 99.0 sentinel must not contribute to any aggregate field.
|
|
expect(weight.count).toBe(2);
|
|
expect(weight.latest).toBeCloseTo(80.2, 2);
|
|
expect(weight.min).toBeCloseTo(80.0, 2);
|
|
expect(weight.max).toBeCloseTo(80.2, 2);
|
|
expect(weight.mean).toBeCloseTo(80.1, 2);
|
|
});
|
|
|
|
it("excludes soft-deleted measurements from the dashboard summary", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await seedSession("soft-delete-dashboard");
|
|
|
|
const now = new Date();
|
|
const sixHoursAgo = new Date(now.getTime() - 6 * 60 * 60 * 1000);
|
|
const oneDayAgo = new Date(now.getTime() - 86_400_000);
|
|
|
|
await prisma.measurement.createMany({
|
|
data: [
|
|
{
|
|
userId: user.id,
|
|
type: "WEIGHT",
|
|
value: 80.0,
|
|
unit: "kg",
|
|
source: "MANUAL",
|
|
measuredAt: oneDayAgo,
|
|
},
|
|
{
|
|
userId: user.id,
|
|
// Most-recent row by wall-clock — if the tombstone leaks the
|
|
// tile's `latestValue` would surface 99.0 here.
|
|
type: "WEIGHT",
|
|
value: 99.0,
|
|
unit: "kg",
|
|
source: "MANUAL",
|
|
measuredAt: sixHoursAgo,
|
|
},
|
|
],
|
|
});
|
|
|
|
await prisma.measurement.updateMany({
|
|
where: { userId: user.id, value: 99.0 },
|
|
data: { deletedAt: new Date() },
|
|
});
|
|
|
|
const { GET } = await import("@/app/api/dashboard/summary/route");
|
|
const response = await (
|
|
GET as unknown as (req: Request) => Promise<Response>
|
|
)(new Request("http://localhost/api/dashboard/summary"));
|
|
|
|
expect(response.status).toBe(200);
|
|
const envelope = (await response.json()) as {
|
|
data: {
|
|
metrics: Array<{
|
|
kind: string;
|
|
latestValue: number | null;
|
|
allTimeCount: number;
|
|
}>;
|
|
};
|
|
};
|
|
|
|
const weightTile = envelope.data.metrics.find((m) => m.kind === "weight");
|
|
expect(weightTile).toBeDefined();
|
|
// Live row (80.0) is the only reading the tile sees; the 99.0
|
|
// tombstone must not leak into the latest value or the all-time
|
|
// count.
|
|
expect(weightTile!.latestValue).toBeCloseTo(80.0, 2);
|
|
expect(weightTile!.allTimeCount).toBe(1);
|
|
});
|
|
|
|
it("recomputeBucketsForMeasurement produces count=2 not 3 with one soft-deleted row", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await seedSession("soft-delete-rollup");
|
|
|
|
// Three readings inside the same UTC calendar day. The middle one
|
|
// is tombstoned; the resulting DAY bucket must reflect only two
|
|
// live readings.
|
|
const noonUtc = new Date(
|
|
Date.UTC(
|
|
new Date().getUTCFullYear(),
|
|
new Date().getUTCMonth(),
|
|
new Date().getUTCDate(),
|
|
12,
|
|
0,
|
|
0,
|
|
),
|
|
);
|
|
const elevenUtc = new Date(noonUtc.getTime() - 60 * 60 * 1000);
|
|
const thirteenUtc = new Date(noonUtc.getTime() + 60 * 60 * 1000);
|
|
|
|
await prisma.measurement.createMany({
|
|
data: [
|
|
{
|
|
userId: user.id,
|
|
type: "WEIGHT",
|
|
value: 80.0,
|
|
unit: "kg",
|
|
source: "MANUAL",
|
|
measuredAt: elevenUtc,
|
|
},
|
|
{
|
|
userId: user.id,
|
|
type: "WEIGHT",
|
|
value: 99.0,
|
|
unit: "kg",
|
|
source: "MANUAL",
|
|
measuredAt: noonUtc,
|
|
},
|
|
{
|
|
userId: user.id,
|
|
type: "WEIGHT",
|
|
value: 80.4,
|
|
unit: "kg",
|
|
source: "MANUAL",
|
|
measuredAt: thirteenUtc,
|
|
},
|
|
],
|
|
});
|
|
|
|
await prisma.measurement.updateMany({
|
|
where: { userId: user.id, value: 99.0 },
|
|
data: { deletedAt: new Date() },
|
|
});
|
|
|
|
await recomputeBucketsForMeasurement(user.id, "WEIGHT", noonUtc);
|
|
|
|
const dayStartUtc = new Date(
|
|
Date.UTC(
|
|
noonUtc.getUTCFullYear(),
|
|
noonUtc.getUTCMonth(),
|
|
noonUtc.getUTCDate(),
|
|
),
|
|
);
|
|
// v1.11.1 — rollups are source-aware; both seeded rows share the default
|
|
// MANUAL source so they still collapse to one DAY row. findFirst keeps the
|
|
// assertion source-agnostic.
|
|
const bucket = await prisma.measurementRollup.findFirst({
|
|
where: {
|
|
userId: user.id,
|
|
type: "WEIGHT",
|
|
granularity: "DAY",
|
|
bucketStart: dayStartUtc,
|
|
},
|
|
});
|
|
expect(bucket).not.toBeNull();
|
|
expect(bucket!.count).toBe(2);
|
|
// Mean over the two live rows = (80.0 + 80.4) / 2 = 80.2. If the
|
|
// tombstone leaked the mean would land at ~86.5 — the assertion
|
|
// fails loudly.
|
|
expect(bucket!.mean).toBeCloseTo(80.2, 2);
|
|
expect(bucket!.minValue).toBeCloseTo(80.0, 2);
|
|
expect(bucket!.maxValue).toBeCloseTo(80.4, 2);
|
|
});
|
|
|
|
it("GET /api/measurements/[id] 404s a soft-deleted row, PUT refuses to resurrect it (v1.7.0 M3)", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await seedSession("soft-delete-single-resource");
|
|
|
|
const live = await prisma.measurement.create({
|
|
data: {
|
|
userId: user.id,
|
|
type: "WEIGHT",
|
|
value: 80.0,
|
|
unit: "kg",
|
|
source: "MANUAL",
|
|
measuredAt: new Date(),
|
|
},
|
|
});
|
|
|
|
// The single-resource GET resolves the live row.
|
|
const { GET, PUT } = await import("@/app/api/measurements/[id]/route");
|
|
const ctx = { params: Promise.resolve({ id: live.id }) };
|
|
|
|
const liveGet = await GET(
|
|
new NextRequest(`http://localhost/api/measurements/${live.id}`, {
|
|
method: "GET",
|
|
}),
|
|
ctx,
|
|
);
|
|
expect(liveGet.status).toBe(200);
|
|
|
|
// Tombstone the row.
|
|
await prisma.measurement.update({
|
|
where: { id: live.id },
|
|
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
|
|
});
|
|
|
|
// GET now 404s — the tombstone is invisible to the single-resource read.
|
|
const tombGet = await GET(
|
|
new NextRequest(`http://localhost/api/measurements/${live.id}`, {
|
|
method: "GET",
|
|
}),
|
|
ctx,
|
|
);
|
|
expect(tombGet.status).toBe(404);
|
|
|
|
// PUT refuses to resurrect-edit the tombstoned row.
|
|
const tombPut = await PUT(
|
|
new NextRequest(`http://localhost/api/measurements/${live.id}`, {
|
|
method: "PUT",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ value: 81.0 }),
|
|
}),
|
|
ctx,
|
|
);
|
|
expect(tombPut.status).toBe(404);
|
|
|
|
// The row stays tombstoned and unedited.
|
|
const after = await prisma.measurement.findUnique({
|
|
where: { id: live.id },
|
|
});
|
|
expect(after!.deletedAt).not.toBeNull();
|
|
expect(after!.value).toBeCloseTo(80.0, 2);
|
|
});
|
|
});
|