fix(nutrients): bound the manual water writes and put nutrient totals in the backup

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 13:40:03 +02:00
1 parent 8ead442d44
commit 94157a50c7
16 files changed
+389 -90

No files matched your search

+3 -2
View File
@@ -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:
@@ -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();
@@ -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(() => {
@@ -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() },
@@ -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([]) },
+1
View File
@@ -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,
},
});
+1
View File
@@ -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,
},
});
+4 -12
View File
@@ -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<Response> {
}
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 }> = [];
@@ -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();
});
});
+21 -2
View File
@@ -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<Response> {
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<Response> {
},
};
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<Response> {
: { 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.
+6 -18
View File
@@ -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", () => {
+82 -50
View File
@@ -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<FullBackupResult> {
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),
},
};
+6 -1
View File
@@ -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,
+20
View File
@@ -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);
}
+1 -1
View File
@@ -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. */
@@ -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<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([]);
});
});