mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
chore(release): v1.28.34 — nutrient intake lands as quiet context (server side)
Supplement-style intake written to Apple Health by nutrition apps — vitamins, minerals, water, caffeine — can now sync as daily totals into a dedicated NutrientIntakeDay store (migration 0241): composite (user, day, nutrient) identity, re-post replaces, no soft delete, no rollup coupling. A code-side catalog carries the 26 nutrient codes with canonical units, plausibility caps, and EFSA dietary reference values (direction-aware; chromium cites NIH ODS where EFSA declined a DRV). Deliberately not a nutrition feature: no diary, no calories, no dashboard surface. POST /api/nutrients/batch is gated behind the new opt-in nutrients module (default off — with the module off the server refuses ingest entirely), rate-limited, idempotency-wrapped, with per-entry unit-mismatch and out-of-range skips. GET /api/nutrients serves a catalog-ordered summary; the only visible surface is a read-only card under Settings → Sources. Coach/correlation access is a later slice; the iOS reading side is coordinated in the companion-app tracker.
This commit is contained in:
1 parent
14a9a08a6e
commit
5445fc67ea
34 files changed
+2766
-12
No files matched your search
@@ -321,6 +321,7 @@ describe("doctor-report — module enable/disable gating", () => {
|
||||
mcp: true,
|
||||
inboundDocuments: true,
|
||||
mentalHealth: true,
|
||||
nutrients: true,
|
||||
} as const;
|
||||
|
||||
it("includes every module's data when all modules are enabled", async () => {
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* v1.28 — `POST /api/nutrients/batch` + `GET /api/nutrients` real-Postgres
|
||||
* integration.
|
||||
*
|
||||
* Asserts the composite-PK contract the unit mocks can't pin:
|
||||
* - a clean batch inserts one row per (user, day, nutrient)
|
||||
* - a re-post replaces the day total in place (`updated`, no new row)
|
||||
* - a unit-mismatched entry is skipped and never lands
|
||||
* - the opt-in module gate refuses ingest AND read with 403
|
||||
* `module.disabled` for an account that never opted in
|
||||
* - the read endpoint folds stored rows into the window summary
|
||||
*/
|
||||
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 OPTED_IN_USER = "user-nutrients";
|
||||
const OPTED_OUT_USER = "user-nutrients-off";
|
||||
|
||||
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),
|
||||
}));
|
||||
|
||||
async function signIn(userId: string): Promise<void> {
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: {
|
||||
userId,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: OPTED_IN_USER,
|
||||
username: "nutrients-on",
|
||||
email: "nutrients-on@example.test",
|
||||
timezone: "Europe/Berlin",
|
||||
// Opt-in module: only an explicit `true` enables it.
|
||||
modulePreferencesJson: { nutrients: true },
|
||||
},
|
||||
});
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: OPTED_OUT_USER,
|
||||
username: "nutrients-off",
|
||||
email: "nutrients-off@example.test",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
await signIn(OPTED_IN_USER);
|
||||
});
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/nutrients/batch", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function recentDay(offsetDays: number): string {
|
||||
return new Date(Date.now() - offsetDays * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
interface BatchEnvelope {
|
||||
data: {
|
||||
processed: number;
|
||||
inserted: number;
|
||||
updated: number;
|
||||
skipped: Array<{ index: number; reason: string }>;
|
||||
entries: Array<{ index: number; status: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
describe("POST /api/nutrients/batch (real Postgres)", () => {
|
||||
it("inserts a clean batch — one row per (user, day, nutrient)", async () => {
|
||||
const { POST } = await import("@/app/api/nutrients/batch/route");
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{
|
||||
day: recentDay(1),
|
||||
nutrient: "vitamin_d",
|
||||
unit: "ug",
|
||||
amount: 20,
|
||||
externalSourceVersion: "writer-1.0",
|
||||
},
|
||||
{ day: recentDay(1), nutrient: "caffeine", unit: "mg", amount: 250 },
|
||||
{ day: recentDay(2), nutrient: "caffeine", unit: "mg", amount: 400 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as BatchEnvelope;
|
||||
expect(json.data.processed).toBe(3);
|
||||
expect(json.data.inserted).toBe(3);
|
||||
expect(json.data.updated).toBe(0);
|
||||
|
||||
const stored = await getPrismaClient().nutrientIntakeDay.findMany({
|
||||
where: { userId: OPTED_IN_USER },
|
||||
orderBy: [{ nutrient: "asc" }, { day: "asc" }],
|
||||
});
|
||||
expect(stored).toHaveLength(3);
|
||||
expect(stored.map((r) => r.nutrient)).toEqual([
|
||||
"caffeine",
|
||||
"caffeine",
|
||||
"vitamin_d",
|
||||
]);
|
||||
expect(stored[2].unit).toBe("ug");
|
||||
expect(stored[2].source).toBe("APPLE_HEALTH");
|
||||
expect(stored[2].externalSourceVersion).toBe("writer-1.0");
|
||||
});
|
||||
|
||||
it("re-post replaces the day total in place (composite-PK upsert, status updated)", async () => {
|
||||
const { POST } = await import("@/app/api/nutrients/batch/route");
|
||||
const day = recentDay(1);
|
||||
|
||||
await POST(
|
||||
postReq({
|
||||
entries: [{ day, nutrient: "water", unit: "ml", amount: 900 }],
|
||||
}),
|
||||
);
|
||||
// The steps pattern: the total grows as the day progresses.
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [{ day, nutrient: "water", unit: "ml", amount: 1800 }],
|
||||
}),
|
||||
);
|
||||
|
||||
const json = (await res.json()) as BatchEnvelope;
|
||||
expect(json.data.inserted).toBe(0);
|
||||
expect(json.data.updated).toBe(1);
|
||||
expect(json.data.entries[0].status).toBe("updated");
|
||||
|
||||
const stored = await getPrismaClient().nutrientIntakeDay.findMany({
|
||||
where: { userId: OPTED_IN_USER, nutrient: "water" },
|
||||
});
|
||||
expect(stored).toHaveLength(1);
|
||||
expect(stored[0].amount).toBe(1800);
|
||||
});
|
||||
|
||||
it("skips a unit-mismatched entry without landing a row", async () => {
|
||||
const { POST } = await import("@/app/api/nutrients/batch/route");
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ day: recentDay(1), nutrient: "vitamin_d", unit: "mg", amount: 20 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const json = (await res.json()) as BatchEnvelope;
|
||||
expect(json.data.skipped).toEqual([{ index: 0, reason: "unit_mismatch" }]);
|
||||
const count = await getPrismaClient().nutrientIntakeDay.count({
|
||||
where: { userId: OPTED_IN_USER },
|
||||
});
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it("refuses ingest 403 module.disabled for an account that never opted in", async () => {
|
||||
const { POST } = await import("@/app/api/nutrients/batch/route");
|
||||
cookieJar.clear();
|
||||
await signIn(OPTED_OUT_USER);
|
||||
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ day: recentDay(1), nutrient: "iron", unit: "mg", amount: 12 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
expect(body.meta?.errorCode).toBe("module.disabled");
|
||||
const count = await getPrismaClient().nutrientIntakeDay.count({
|
||||
where: { userId: OPTED_OUT_USER },
|
||||
});
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/nutrients (real Postgres)", () => {
|
||||
it("folds stored rows into the catalog-ordered window summary", async () => {
|
||||
const { POST } = await import("@/app/api/nutrients/batch/route");
|
||||
const { GET } = await import("@/app/api/nutrients/route");
|
||||
|
||||
await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ day: recentDay(2), nutrient: "caffeine", unit: "mg", amount: 400 },
|
||||
{ day: recentDay(1), nutrient: "caffeine", unit: "mg", amount: 250 },
|
||||
{ day: recentDay(1), nutrient: "vitamin_d", unit: "ug", amount: 20 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/nutrients?days=14"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: {
|
||||
windowDays: number;
|
||||
nutrients: Array<{
|
||||
nutrient: string;
|
||||
latestDay: string;
|
||||
latestAmount: number;
|
||||
daysWithData: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
expect(json.data.windowDays).toBe(14);
|
||||
expect(json.data.nutrients).toHaveLength(2);
|
||||
// Catalog order: vitamin_d before the caffeine tail entry.
|
||||
expect(json.data.nutrients[0].nutrient).toBe("vitamin_d");
|
||||
expect(json.data.nutrients[1]).toMatchObject({
|
||||
nutrient: "caffeine",
|
||||
latestDay: recentDay(1),
|
||||
latestAmount: 250,
|
||||
daysWithData: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses the read 403 module.disabled when the module is off", async () => {
|
||||
const { GET } = await import("@/app/api/nutrients/route");
|
||||
cookieJar.clear();
|
||||
await signIn(OPTED_OUT_USER);
|
||||
const res = await GET(new NextRequest("http://localhost/api/nutrients"));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user