diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1b8fc1dc1..7d5391a06 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -13349,8 +13349,9 @@ components: $ref: "#/components/schemas/NutrientIntakeEntry" required: - entries - description: Bulk day-total upsert, max 500 entries. Upsert key is (day, nutrient); a re-post replaces the stored total - (last-writer-wins day-total contract). + description: Bulk day-total upsert, max 500 entries. Upsert key is the composite primary key (userId, day, nutrient, + source); this route always writes the APPLE_HEALTH source row, so a manual water entry is never clobbered. A + re-post replaces the stored total (last-writer-wins day-total contract). NutrientIntakeEntry: type: object properties: diff --git a/src/__tests__/proxy-early-exit-headers.test.ts b/src/__tests__/proxy-early-exit-headers.test.ts index fbb6f2ebd..9095f4105 100644 --- a/src/__tests__/proxy-early-exit-headers.test.ts +++ b/src/__tests__/proxy-early-exit-headers.test.ts @@ -37,17 +37,16 @@ function makeRequest( }); } -const ORIGINAL_NODE_ENV = process.env.NODE_ENV; const ORIGINAL_DEMO = process.env.DEMO_MODE; beforeEach(() => { shouldRunWeb.mockReturnValue(true); - process.env.NODE_ENV = "production"; + vi.stubEnv("NODE_ENV", "production"); delete process.env.DEMO_MODE; }); afterEach(() => { - process.env.NODE_ENV = ORIGINAL_NODE_ENV; + vi.unstubAllEnvs(); if (ORIGINAL_DEMO === undefined) delete process.env.DEMO_MODE; else process.env.DEMO_MODE = ORIGINAL_DEMO; }); @@ -110,7 +109,7 @@ describe("proxy early exits carry the baseline security headers", () => { }); it("omits HSTS in development, like the main header block", () => { - process.env.NODE_ENV = "development"; + vi.stubEnv("NODE_ENV", "development"); const res = proxy(makeRequest("/stimmung")); expect(res.status).toBe(301); expect(res.headers.get("Strict-Transport-Security")).toBeNull(); diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index f442c3e15..14597dfa5 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -16,6 +16,7 @@ vi.mock("@/lib/db", () => ({ medication: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, + nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) }, // v1.15.0 — cycle tables read by the full-backup helper. cycleProfile: { findUnique: vi.fn() }, menstrualCycle: { findMany: vi.fn() }, @@ -70,6 +71,9 @@ function mkReq(url: string): NextRequest { beforeEach(() => { vi.resetAllMocks(); + // `resetAllMocks` clears the inline default, and the full-backup payload + // builder reads this unconditionally. + vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([] as never); }); afterEach(() => { diff --git a/src/app/api/export/__tests__/soft-delete-filter.test.ts b/src/app/api/export/__tests__/soft-delete-filter.test.ts index 05b05118e..0f039db61 100644 --- a/src/app/api/export/__tests__/soft-delete-filter.test.ts +++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts @@ -24,6 +24,7 @@ vi.mock("@/lib/db", () => ({ medication: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn(), count: vi.fn() }, moodEntry: { findMany: vi.fn(), count: vi.fn() }, + nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) }, user: { findUnique: vi.fn() }, passkey: { findMany: vi.fn() }, apiToken: { count: vi.fn() }, diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index 075776e11..6a409c51d 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -14,6 +14,7 @@ vi.mock("@/lib/db", () => ({ medication: { findMany: vi.fn().mockResolvedValue([]) }, medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) }, moodEntry: { findMany: vi.fn().mockResolvedValue([]) }, + nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) }, cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, diff --git a/src/app/api/export/encrypted/route.ts b/src/app/api/export/encrypted/route.ts index 69c8b5806..1bdb223d9 100644 --- a/src/app/api/export/encrypted/route.ts +++ b/src/app/api/export/encrypted/route.ts @@ -116,6 +116,7 @@ export const POST = apiHandler(async (request: NextRequest) => { export_family_history_count: counts.familyHistory, export_workout_count: counts.workouts, export_document_count: counts.documents, + export_nutrient_day_count: counts.nutrientDays, export_archive_bytes: archive.byteLength, }, }); diff --git a/src/app/api/export/full-backup/route.ts b/src/app/api/export/full-backup/route.ts index f5fe21918..ca0f629c5 100644 --- a/src/app/api/export/full-backup/route.ts +++ b/src/app/api/export/full-backup/route.ts @@ -71,6 +71,7 @@ export const GET = apiHandler(async (request: NextRequest) => { export_family_history_count: counts.familyHistory, export_workout_count: counts.workouts, export_document_count: counts.documents, + export_nutrient_day_count: counts.nutrientDays, }, }); diff --git a/src/app/api/nutrients/batch/route.ts b/src/app/api/nutrients/batch/route.ts index 933bc7ecc..68e6ce217 100644 --- a/src/app/api/nutrients/batch/route.ts +++ b/src/app/api/nutrients/batch/route.ts @@ -7,7 +7,8 @@ * user's IANA timezone (the `stats:` day-key contract — the server * trusts the string after regex + calendar sanity, no re-derivation). * - * Upsert key = the composite PK (userId, day, nutrient). Re-post + * Upsert key = the composite PK (userId, day, nutrient, source), and this + * route always owns the APPLE_HEALTH row. Re-post * replaces amount / unit / externalSourceVersion — last-writer-wins, * the day-total contract; per-entry status is `updated` then, never * `duplicate`. Validation failures (`unit_mismatch` | `value_out_of_range` @@ -42,6 +43,7 @@ import { checkRateLimit } from "@/lib/rate-limit"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate"; import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog"; +import { maxAcceptableNutrientDay } from "@/lib/nutrients/day-bounds"; import { MAX_NUTRIENT_ENTRIES_PER_BATCH, nutrientBatchSchema, @@ -64,16 +66,6 @@ function isRealCalendarDay(day: string): boolean { ); } -/** - * Upper day bound with timezone slack: "tomorrow" in the most-ahead - * IANA zone (UTC+14) is at most the UTC calendar date + 2 days, so a - * key beyond that cannot be a legitimate local day and is corruption. - */ -function maxAcceptableDay(now: Date): string { - const limit = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000); - return limit.toISOString().slice(0, 10); -} - type EntryStatus = "inserted" | "updated" | "skipped"; interface EntryResult { index: number; @@ -133,7 +125,7 @@ async function postBatch(request: NextRequest): Promise { } const { entries } = parsed.data; - const dayCeiling = maxAcceptableDay(new Date()); + const dayCeiling = maxAcceptableNutrientDay(new Date()); const results: EntryResult[] = new Array(entries.length); const skipped: Array<{ index: number; reason: string }> = []; diff --git a/src/app/api/nutrients/water/__tests__/route.test.ts b/src/app/api/nutrients/water/__tests__/route.test.ts index 8c9bf7a1e..9139fc7e8 100644 --- a/src/app/api/nutrients/water/__tests__/route.test.ts +++ b/src/app/api/nutrients/water/__tests__/route.test.ts @@ -13,6 +13,7 @@ vi.mock("@/lib/db", () => ({ prisma: { nutrientIntakeDay: { upsert: vi.fn(), + update: vi.fn(), }, }, })); @@ -56,6 +57,7 @@ import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate"; +import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -216,3 +218,69 @@ describe("POST /api/nutrients/water", () => { }); }); }); + +describe("POST /api/nutrients/water — day bound and increment cap", () => { + // The batch route bounded `day` with UTC+14 slack; this route validated + // calendar realism only. Every read filters `day: { gte: since }` with no + // upper bound, so a far-future row became the permanent `latestDay` on the + // settings card and the permanent `lastSeenAt` on the dashboard water tile. + it("rejects a far-future day with 422", async () => { + const res = await POST( + postReq({ amountMl: 500, mode: "set", day: "2999-01-01" }), + ); + expect(res.status).toBe(422); + expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled(); + }); + + it("still accepts a day two days ahead — the UTC+14 slack the batch route allows", async () => { + const soon = new Date(Date.now() + 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); + const res = await POST(postReq({ amountMl: 500, mode: "set", day: soon })); + expect(res.status).toBe(200); + }); + + it("accepts a past day", async () => { + const past = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); + const res = await POST(postReq({ amountMl: 500, mode: "set", day: past })); + expect(res.status).toBe(200); + }); + + // `mode: "add"` was unbounded across requests: the schema caps a SINGLE + // write, so repeated quick-adds drove the stored day total arbitrarily high + // while the batch route enforced the same cap strictly on the Apple row. + it("clamps an incremented total back to the catalog's plausible daily max", async () => { + vi.mocked(prisma.nutrientIntakeDay.upsert).mockResolvedValue({ + day: "2026-07-16", + nutrient: "water", + source: "MANUAL", + amount: 99_000, + unit: "ml", + } as never); + vi.mocked(prisma.nutrientIntakeDay.update).mockResolvedValue({ + day: "2026-07-16", + nutrient: "water", + source: "MANUAL", + amount: NUTRIENT_CATALOG.water.plausibleDailyMax, + unit: "ml", + } as never); + + const res = await POST(postReq({ amountMl: 500, mode: "add" })); + expect(res.status).toBe(200); + expect(prisma.nutrientIntakeDay.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: { amount: NUTRIENT_CATALOG.water.plausibleDailyMax }, + }), + ); + const body = (await res.json()) as WaterResponse; + expect(body.data.amount).toBe(NUTRIENT_CATALOG.water.plausibleDailyMax); + }); + + it("leaves a total under the cap untouched", async () => { + const res = await POST(postReq({ amountMl: 500, mode: "add" })); + expect(res.status).toBe(200); + expect(prisma.nutrientIntakeDay.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/nutrients/water/route.ts b/src/app/api/nutrients/water/route.ts index d6d0f225f..84044ffba 100644 --- a/src/app/api/nutrients/water/route.ts +++ b/src/app/api/nutrients/water/route.ts @@ -30,6 +30,7 @@ import { checkRateLimit } from "@/lib/rate-limit"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate"; import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog"; +import { maxAcceptableNutrientDay } from "@/lib/nutrients/day-bounds"; import { nutrientWaterWriteSchema } from "@/lib/validations/nutrients"; import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; @@ -79,7 +80,12 @@ async function postWater(request: NextRequest): Promise { const userTz = user.timezone || DEFAULT_TIMEZONE; const day = parsed.data.day ?? userDayKey(new Date(), userTz); - if (!isRealCalendarDay(day)) { + // Calendar realism AND the upper bound the batch route already applied. A + // client-supplied far-future day used to be accepted here, and since every + // read filters `day: { gte: since }` with no upper bound it became the + // permanent `latestDay` on the settings card and the permanent `lastSeenAt` + // on the dashboard water tile. + if (!isRealCalendarDay(day) || day > maxAcceptableNutrientDay(new Date())) { return apiError("Invalid day", 422, { errorCode: "nutrient.water.invalid_day", }); @@ -95,7 +101,7 @@ async function postWater(request: NextRequest): Promise { }, }; - const row = await prisma.nutrientIntakeDay.upsert({ + let row = await prisma.nutrientIntakeDay.upsert({ where: key, create: { userId: user.id, @@ -111,6 +117,19 @@ async function postWater(request: NextRequest): Promise { : { amount: amountMl }, }); + // The request schema bounds a SINGLE write to the catalog's plausible daily + // max, but `mode: "add"` is unbounded across requests — repeated quick-adds + // drove the stored day total arbitrarily high while the batch route enforced + // the same cap strictly on the Apple-sourced row. Clamp after the fact rather + // than reading first: the increment stays atomic, and the clamp converges on + // the cap no matter how the increments interleave. + if (row.amount > definition.plausibleDailyMax) { + row = await prisma.nutrientIntakeDay.update({ + where: key, + data: { amount: definition.plausibleDailyMax }, + }); + } + // Interactive single-entry write — hard-evict (not mark-stale) so the // dashboard water tile reflects the new total on the very next read, // matching the mood / medication / measurement posture. diff --git a/src/lib/auth/__tests__/webauthn-rp.test.ts b/src/lib/auth/__tests__/webauthn-rp.test.ts index f2541a7e4..2e00d458f 100644 --- a/src/lib/auth/__tests__/webauthn-rp.test.ts +++ b/src/lib/auth/__tests__/webauthn-rp.test.ts @@ -8,7 +8,7 @@ * bypass — but a configured production origin has no reason to carry it, and an * unpinned second layer is exactly what regresses unnoticed. */ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getConfiguredOrigins, @@ -16,23 +16,15 @@ import { getRpId, } from "../webauthn-rp"; -const ORIGINAL = { - NODE_ENV: process.env.NODE_ENV, - APP_URL: process.env.APP_URL, - NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, -}; - function setEnv(env: { nodeEnv?: string; appUrl?: string; publicAppUrl?: string; }) { - if (env.nodeEnv === undefined) delete process.env.NODE_ENV; - else process.env.NODE_ENV = env.nodeEnv; - if (env.appUrl === undefined) delete process.env.APP_URL; - else process.env.APP_URL = env.appUrl; - if (env.publicAppUrl === undefined) delete process.env.NEXT_PUBLIC_APP_URL; - else process.env.NEXT_PUBLIC_APP_URL = env.publicAppUrl; + vi.unstubAllEnvs(); + if (env.nodeEnv !== undefined) vi.stubEnv("NODE_ENV", env.nodeEnv); + vi.stubEnv("APP_URL", env.appUrl ?? ""); + vi.stubEnv("NEXT_PUBLIC_APP_URL", env.publicAppUrl ?? ""); } beforeEach(() => { @@ -40,11 +32,7 @@ beforeEach(() => { }); afterEach(() => { - setEnv({ - nodeEnv: ORIGINAL.NODE_ENV, - appUrl: ORIGINAL.APP_URL, - publicAppUrl: ORIGINAL.NEXT_PUBLIC_APP_URL, - }); + vi.unstubAllEnvs(); }); describe("getConfiguredOrigins", () => { diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 85229e03c..1e7075d21 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -28,6 +28,7 @@ export interface FullBackupCounts extends RecordsBackupCounts { moodEntries: number; cycles: number; cycleDayLogs: number; + nutrientDays: number; } export interface FullBackupResult { @@ -43,58 +44,81 @@ export async function buildFullBackupPayload( prisma: PrismaClient, userId: string, ): Promise { - const [measurements, medications, intakeEvents, moodEntries, cycle, records] = - await Promise.all([ - // v1.28.25 — keyset-paginated read with a narrow select. The backup - // is inherently whole-history, so on a CGM / per-sample-HR account the - // previous single full-width `findMany` materialised a six-figure row - // set in one shot. The chunked read accumulates the same rows in the - // same `measuredAt desc` order; the payload FORMAT below is untouched - // (restore compatibility). The select is exactly the fields the - // serializer reads: type / value / unit / measuredAt / source + the - // note columns `readNote` decrypts + the `id` cursor key. - findMeasurementsPaged( - prisma, - { userId, deletedAt: null }, - { - id: true, - type: true, - value: true, - unit: true, - measuredAt: true, - source: true, - notes: true, - notesEncrypted: true, - }, - ), - prisma.medication.findMany({ - where: { userId }, - select: { - name: true, - dose: true, - active: true, - schedules: { - select: { - windowStart: true, - windowEnd: true, - label: true, - dose: true, - }, + const [ + measurements, + medications, + intakeEvents, + moodEntries, + cycle, + records, + nutrientDays, + ] = await Promise.all([ + // v1.28.25 — keyset-paginated read with a narrow select. The backup + // is inherently whole-history, so on a CGM / per-sample-HR account the + // previous single full-width `findMany` materialised a six-figure row + // set in one shot. The chunked read accumulates the same rows in the + // same `measuredAt desc` order; the payload FORMAT below is untouched + // (restore compatibility). The select is exactly the fields the + // serializer reads: type / value / unit / measuredAt / source + the + // note columns `readNote` decrypts + the `id` cursor key. + findMeasurementsPaged( + prisma, + { userId, deletedAt: null }, + { + id: true, + type: true, + value: true, + unit: true, + measuredAt: true, + source: true, + notes: true, + notesEncrypted: true, + }, + ), + prisma.medication.findMany({ + where: { userId }, + select: { + name: true, + dose: true, + active: true, + schedules: { + select: { + windowStart: true, + windowEnd: true, + label: true, + dose: true, }, }, - }), - prisma.medicationIntakeEvent.findMany({ - where: { userId, deletedAt: null }, - include: { medication: { select: { name: true } } }, - orderBy: { scheduledFor: "desc" }, - }), - prisma.moodEntry.findMany({ - where: { userId, deletedAt: null }, - orderBy: { moodLoggedAt: "desc" }, - }), - buildCycleBackupSection(prisma, userId), - buildRecordsBackupSection(prisma, userId), - ]); + }, + }), + prisma.medicationIntakeEvent.findMany({ + where: { userId, deletedAt: null }, + include: { medication: { select: { name: true } } }, + orderBy: { scheduledFor: "desc" }, + }), + prisma.moodEntry.findMany({ + where: { userId, deletedAt: null }, + orderBy: { moodLoggedAt: "desc" }, + }), + buildCycleBackupSection(prisma, userId), + buildRecordsBackupSection(prisma, userId), + // Nutrient day totals were absent from every export path, which + // contradicted the schema's own justification for denormalising the unit + // column ("rows stay self-describing in exports even if the catalog ever + // drifts"). `source` is part of the composite PK, so it has to ride along + // or a restore cannot tell a manual water entry from a synced day total. + prisma.nutrientIntakeDay.findMany({ + where: { userId }, + select: { + day: true, + nutrient: true, + amount: true, + unit: true, + source: true, + }, + orderBy: [{ day: "desc" }, { nutrient: "asc" }], + }), + ]); const payload = { schemaVersion: BACKUP_SCHEMA_VERSION, @@ -148,6 +172,13 @@ export async function buildFullBackupPayload( familyHistory: records.familyHistory, workouts: records.workouts, documents: records.documents, + nutrientDays: nutrientDays.map((n) => ({ + day: n.day, + nutrient: n.nutrient, + amount: n.amount, + unit: n.unit, + source: n.source, + })), manifest: records.manifest, }; @@ -160,6 +191,7 @@ export async function buildFullBackupPayload( moodEntries: moodEntries.length, cycles: cycle.cycles.length, cycleDayLogs: cycle.cycleDayLogs.length, + nutrientDays: nutrientDays.length, ...countRecordsBackupSection(records), }, }; diff --git a/src/lib/nutrients/catalog.ts b/src/lib/nutrients/catalog.ts index c8af5db0c..dedf9dc0d 100644 --- a/src/lib/nutrients/catalog.ts +++ b/src/lib/nutrients/catalog.ts @@ -132,7 +132,12 @@ export const NUTRIENT_CATALOG: Readonly< female: 650, source: "EFSA DRV 2015 (retinol equivalents, adults)", }), - thiamin: def("thiamin", "Thiamin", "mg", 1000, { + // The cap is 500, not the 1000 the other B-vitamins carry, because thiamin's + // PRI is exactly 1.0 mg: a µg-read-as-mg sample lands on exactly 1000, and + // the ingest guard compares with `>`, so a 1000× error slipped through at + // precisely this one analyte. 500 still clears any real supplement dose + // (high-dose thiamin tops out around 300 mg) while catching that collision. + thiamin: def("thiamin", "Thiamin", "mg", 500, { kind: "PRI", direction: "target", adult: 1.0, diff --git a/src/lib/nutrients/day-bounds.ts b/src/lib/nutrients/day-bounds.ts new file mode 100644 index 000000000..ee440c155 --- /dev/null +++ b/src/lib/nutrients/day-bounds.ts @@ -0,0 +1,20 @@ +/** + * Shared upper day bound for the nutrient write paths. + * + * `NutrientIntakeDay.day` is a local `YYYY-MM-DD` key, so the server cannot + * simply compare it against the UTC date: a client in the most-ahead IANA zone + * (UTC+14) is legitimately already on "tomorrow" relative to UTC. Two days of + * slack covers every real zone and still rejects a key that cannot be a local + * day at all. + * + * The batch route carried this bound privately while `POST /api/nutrients/water` + * validated calendar realism only — so a manual write could land a `2999-01-01` + * row, and since every read filters `day: { gte: since }` with no upper bound, + * that row became the permanent `latestDay` on the settings card and the + * permanent `lastSeenAt` on the dashboard water tile. Both routes now share + * this one bound. + */ +export function maxAcceptableNutrientDay(now: Date): string { + const limit = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000); + return limit.toISOString().slice(0, 10); +} diff --git a/src/lib/validations/nutrients.ts b/src/lib/validations/nutrients.ts index 506f67ad9..20c00f344 100644 --- a/src/lib/validations/nutrients.ts +++ b/src/lib/validations/nutrients.ts @@ -51,7 +51,7 @@ export const nutrientBatchSchema = z .meta({ id: "NutrientBatchRequest", description: - "Bulk day-total upsert, max 500 entries. Upsert key is (day, nutrient); a re-post replaces the stored total (last-writer-wins day-total contract).", + "Bulk day-total upsert, max 500 entries. Upsert key is the composite primary key (userId, day, nutrient, source); this route always writes the APPLE_HEALTH source row, so a manual water entry is never clobbered. A re-post replaces the stored total (last-writer-wins day-total contract).", }); /** Per-entry outcome — a re-post is by definition an update, never a duplicate. */ diff --git a/tests/integration/full-backup-nutrients.test.ts b/tests/integration/full-backup-nutrients.test.ts new file mode 100644 index 000000000..84e0b713d --- /dev/null +++ b/tests/integration/full-backup-nutrients.test.ts @@ -0,0 +1,167 @@ +/** + * 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[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[0], + ); + expect(res.status).toBe(200); + + const parsed = JSON.parse(await res.text()) as BackupWithNutrients; + expect(parsed.nutrientDays).toEqual([]); + }); +});