mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
The water quick-add validated calendar realism but never the upper day bound the batch route applies, so a far-future day was accepted and — every read filters `day >= since` with no ceiling — became the permanent latest day on the settings card and the dashboard tile. Hoist the bound into one shared helper both routes use. `mode: "add"` bounded a single request to the catalog's plausible daily max but nothing across requests, so repeated quick-adds drove the stored total arbitrarily high while the batch route enforced the same cap strictly. Clamp after the increment so the write stays atomic. Thiamin's cap sat at exactly 1000 mg while its PRI is exactly 1.0 mg, so a microgram-read-as-milligram sample landed on precisely the cap and the `>` comparison let it through. 500 still clears any real supplement dose. NutrientIntakeDay was in no export path, which contradicted the schema's own reason for denormalising the unit column. Carry it in the full backup with its unit and source — source is part of the composite key, so without it a restore cannot tell a manual entry from a synced day total.
168 lines
5.0 KiB
TypeScript
168 lines
5.0 KiB
TypeScript
/**
|
|
* Integration guard: nutrient day totals ride the full backup.
|
|
*
|
|
* `NutrientIntakeDay` was in no export path at all — which contradicted the
|
|
* schema's own justification for denormalising the `unit` column ("rows stay
|
|
* self-describing in exports even if the catalog ever drifts"). A user who
|
|
* exported everything still lost every water and micronutrient row.
|
|
*
|
|
* `source` is part of the composite primary key, so it has to ride along too:
|
|
* without it a restore cannot tell a manual water entry from a synced day
|
|
* total, and the two collapse onto one row.
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
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);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
});
|
|
|
|
async function seedUserSession(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;
|
|
}
|
|
|
|
interface BackupWithNutrients {
|
|
userId: string;
|
|
nutrientDays?: Array<{
|
|
day: string;
|
|
nutrient: string;
|
|
amount: number;
|
|
unit: string;
|
|
source: string;
|
|
}>;
|
|
}
|
|
|
|
describe("full backup — nutrient day totals", () => {
|
|
it("exports nutrient rows for the authed user, keeping unit and source", async () => {
|
|
const prisma = getPrismaClient();
|
|
const me = await seedUserSession("nutrient-backup-user");
|
|
const other = await prisma.user.create({
|
|
data: {
|
|
username: "nutrient-other",
|
|
email: "nutrient-other@example.test",
|
|
role: "USER",
|
|
},
|
|
});
|
|
|
|
await prisma.nutrientIntakeDay.createMany({
|
|
data: [
|
|
{
|
|
userId: me.id,
|
|
day: "2026-05-01",
|
|
nutrient: "water",
|
|
amount: 1500,
|
|
unit: "ml",
|
|
source: "MANUAL",
|
|
},
|
|
// Same (day, nutrient) under the other source — both must survive,
|
|
// which is the whole reason `source` joined the composite PK.
|
|
{
|
|
userId: me.id,
|
|
day: "2026-05-01",
|
|
nutrient: "water",
|
|
amount: 800,
|
|
unit: "ml",
|
|
source: "APPLE_HEALTH",
|
|
},
|
|
{
|
|
userId: me.id,
|
|
day: "2026-05-01",
|
|
nutrient: "vitamin_d",
|
|
amount: 12.5,
|
|
unit: "ug",
|
|
source: "APPLE_HEALTH",
|
|
},
|
|
{
|
|
userId: other.id,
|
|
day: "2026-05-01",
|
|
nutrient: "water",
|
|
amount: 9999,
|
|
unit: "ml",
|
|
source: "MANUAL",
|
|
},
|
|
],
|
|
});
|
|
|
|
const { GET } = await import("@/app/api/export/full-backup/route");
|
|
const res = await GET(
|
|
new Request("http://localhost/api/export/full-backup", {
|
|
method: "GET",
|
|
}) as Parameters<typeof GET>[0],
|
|
);
|
|
expect(res.status).toBe(200);
|
|
|
|
const parsed = JSON.parse(await res.text()) as BackupWithNutrients;
|
|
expect(parsed.userId).toBe(me.id);
|
|
|
|
const rows = parsed.nutrientDays ?? [];
|
|
expect(rows).toHaveLength(3);
|
|
|
|
// No cross-tenant leak.
|
|
expect(rows.some((r) => r.amount === 9999)).toBe(false);
|
|
|
|
// Both source rows for the same (day, nutrient) survive independently.
|
|
const water = rows.filter((r) => r.nutrient === "water");
|
|
expect(water).toHaveLength(2);
|
|
expect(water.map((r) => r.source).sort()).toEqual([
|
|
"APPLE_HEALTH",
|
|
"MANUAL",
|
|
]);
|
|
|
|
// The denormalised unit rides along, per the schema's own contract.
|
|
const vitaminD = rows.find((r) => r.nutrient === "vitamin_d");
|
|
expect(vitaminD).toMatchObject({ unit: "ug", amount: 12.5 });
|
|
});
|
|
|
|
it("emits an empty array, not a missing key, for an account with no nutrient rows", async () => {
|
|
await seedUserSession("nutrient-empty-user");
|
|
|
|
const { GET } = await import("@/app/api/export/full-backup/route");
|
|
const res = await GET(
|
|
new Request("http://localhost/api/export/full-backup", {
|
|
method: "GET",
|
|
}) as Parameters<typeof GET>[0],
|
|
);
|
|
expect(res.status).toBe(200);
|
|
|
|
const parsed = JSON.parse(await res.text()) as BackupWithNutrients;
|
|
expect(parsed.nutrientDays).toEqual([]);
|
|
});
|
|
});
|