/** * The ladder, the job and the route, against real Postgres and the real rows. * * The unit tests prove the arithmetic and the gates in isolation. This proves * the assembly: that an account one day short of the threshold really gets * nothing written, that the day it crosses really gets a row, that switching * the module off really takes the rows away again, and that deleting back * below the threshold does not leave a forecast standing with a fresh date on * it. Every one of those is a claim about what is IN THE TABLE, so every * assertion reads the table rather than a return value. * * It is also the automated half of the ladder walk the phase plan asks a human * to do. What is left for a browser is what a browser is for: that the band is * legible at 390 px and that the count sits beside the number rather than in a * tooltip. */ import { NextRequest } from "next/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { cookieJar } from "./mock-next-headers"; import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup"; const USER = "user-mood-prognosis"; const TZ = "Europe/Berlin"; const DAY_MS = 24 * 60 * 60 * 1000; 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), })); /** Fixed clock: a ladder test that slides with the calendar is a date bomb. */ const NOW = new Date("2026-08-08T09:00:00.000Z"); function dayKey(daysAgo: number): string { return new Date(NOW.getTime() - daysAgo * DAY_MS).toISOString().slice(0, 10); } async function seedUser(): Promise { await getPrismaClient().user.create({ data: { id: USER, username: "mood-prognosis", email: "mood-prognosis@example.test", timezone: TZ, }, }); } /** * `count` consecutive days, each carrying a rating and a stress value the * rating follows. A clean relationship on purpose: the point of this file is * the plumbing, and a fixture the screening refuses would test the refusal * path twice and the write path never. */ async function seedDays(count: number): Promise { for (let i = 0; i < count; i++) { const stress = i % 11; await getPrismaClient().moodEntry.create({ data: { userId: USER, date: dayKey(i), tz: TZ, mood: "OKAY", score: 3, moodA1: Math.round(0.8 * (10 - stress) + 1), stressA2: stress, source: "MANUAL", moodLoggedAt: new Date(NOW.getTime() - i * DAY_MS), }, }); } } async function runJob() { const { runMoodPrognosisRefresh } = await import("@/lib/jobs/mood-prognosis-refresh"); return runMoodPrognosisRefresh(NOW); } function storedRows() { return getPrismaClient().moodPrediction.findMany({ where: { userId: USER }, orderBy: { date: "asc" }, }); } beforeEach(async () => { await truncateAllTables(getPrismaClient()); cookieJar.clear(); }); describe("the minimum-n ladder, end to end", () => { it("writes nothing at one day short of the threshold, and says why", async () => { await seedUser(); await seedDays(29); const summary = await runJob(); expect(summary.refreshed).toBe(0); // The account is not in the cohort at all: the job's cheap cut is the same // threshold, so an account a day short is never even read. The next case // is the other half of the evidence — one more day and `scanned` is 1, so // this zero is the cut landing in the right place rather than a sweep that // matched nothing. expect(summary.scanned).toBe(0); expect(await storedRows()).toEqual([]); const { readMoodPrognosis } = await import("@/lib/analytics/mood-prognosis/read"); const reading = await readMoodPrognosis(USER, NOW, TZ); expect(reading.present).toBe(false); if (!reading.present) { expect(reading.reason).toBe("learning-phase"); expect(reading.entries).toBe(29); expect(reading.nextThreshold).toBe(30); } }); it("writes a banded, counted, provisional forecast on the day it crosses", async () => { await seedUser(); await seedDays(30); const summary = await runJob(); expect(summary.scanned).toBe(1); expect(summary.refreshed).toBe(1); const rows = await storedRows(); expect(rows.length).toBeGreaterThan(0); // The trailing window, not the whole year of days. expect(rows.length).toBeLessThanOrEqual(7); for (const row of rows) { expect(row.predicted).toBeGreaterThanOrEqual(0); expect(row.predicted).toBeLessThanOrEqual(10); // The band and the count are part of the value, not decoration. expect(row.ciLow).not.toBeNull(); expect(row.ciHigh).not.toBeNull(); expect(row.ciLow!).toBeLessThanOrEqual(row.predicted); expect(row.ciHigh!).toBeGreaterThanOrEqual(row.predicted); expect(row.n).toBe(30); expect(row.modelVersion).toBe("mood-ridge-1"); // The stored evidence the explanation is generated from. const contributions = JSON.parse(row.features) as Array<{ feature: string; contribution: number; }>; expect(contributions.length).toBeGreaterThan(0); expect(contributions[0].feature).toBe("dimension:a2"); expect(typeof contributions[0].contribution).toBe("number"); } const { readMoodPrognosis } = await import("@/lib/analytics/mood-prognosis/read"); const reading = await readMoodPrognosis(USER, NOW, TZ); expect(reading.present).toBe(true); if (reading.present) { // Thirty days is a forecast, and a forecast at thirty days is labelled. expect(reading.provisional).toBe(true); expect(reading.stage).toBe("provisional"); expect(reading.n).toBe(30); // The self-assessment is returned unchanged beside it, never merged. const entry = await getPrismaClient().moodEntry.findFirstOrThrow({ where: { userId: USER, date: reading.date }, }); expect(reading.selfAssessment).toBe(entry.moodA1); expect(reading.deviation).not.toBeNull(); } }); it("drops the provisional label once the account has enough days", async () => { await seedUser(); await seedDays(70); await runJob(); const { readMoodPrognosis } = await import("@/lib/analytics/mood-prognosis/read"); const reading = await readMoodPrognosis(USER, NOW, TZ); expect(reading.present).toBe(true); if (reading.present) { expect(reading.provisional).toBe(false); expect(reading.stage).toBe("regular"); expect(reading.seasonalUnlocked).toBe(false); } }); it("computes nothing and stores nothing while the mood module is off", async () => { await seedUser(); await seedDays(40); await runJob(); expect((await storedRows()).length).toBeGreaterThan(0); await getPrismaClient().user.update({ where: { id: USER }, data: { modulePreferencesJson: { mood: false } }, }); const summary = await runJob(); expect(summary.moduleOff).toBe(1); expect(summary.refreshed).toBe(0); // And the rows it wrote before are gone: a forecast that outlives the // switch that turned its module off is a claim nobody asked for. expect(await storedRows()).toEqual([]); }); it("clears a stale forecast when the account falls back below the threshold", async () => { await seedUser(); await seedDays(40); await runJob(); const before = await storedRows(); expect(before.length).toBeGreaterThan(0); // Delete back under the threshold. The account is no longer in the // eligibility half of the cohort at all, so the pass only reaches it // through the rows it already holds. await getPrismaClient().moodEntry.deleteMany({ where: { userId: USER, date: { gte: dayKey(28) } }, }); const summary = await runJob(); expect(summary.scanned).toBe(1); expect(summary.refused).toBe(1); expect( await storedRows(), "the forecast outlived its own eligibility — a stale claim with a fresh date on it", ).toEqual([]); }); it("replaces rather than accumulates across passes", async () => { await seedUser(); await seedDays(40); await runJob(); const first = await storedRows(); // A row from a pass that will not be repeated: an older day, written by // hand the way a previous window would have left it. await getPrismaClient().moodPrediction.create({ data: { userId: USER, date: dayKey(200), predicted: 5, ciLow: 4, ciHigh: 6, n: 99, modelVersion: "mood-ridge-0", features: "[]", computedAt: new Date(NOW.getTime() - 200 * DAY_MS), }, }); await runJob(); const after = await storedRows(); expect(after.map((r) => r.date)).toEqual(first.map((r) => r.date)); expect(after.some((r) => r.modelVersion === "mood-ridge-0")).toBe(false); }); }); describe("GET /api/mood/prognosis", () => { /** * A signed-in browser session. * * The expiry is off the REAL clock, not the fixture's: the seeded days are * dated against `NOW` so the ladder cannot slide with the calendar, but a * session stamped from a fixed instant expires the moment the wall clock * passes it, and every route then answers 401 for a reason that has nothing * to do with what the case is about. * * `actingOn` is passed only for a delegate. Switching an owner's session * into their own record would make the request a delegated one against a * grant that does not exist, and the refusal would look like the scope * refusal this file is trying to prove. */ async function signIn(userId: string, actingOn?: string) { const session = await getPrismaClient().session.create({ data: { userId, expiresAt: new Date(Date.now() + 60 * 60 * 1000), }, }); cookieJar.set("healthlog_session", session.id); if (actingOn) await switchSessionTo(session.id, actingOn); return session; } it("answers the owner with both readings", async () => { await seedUser(); await seedDays(40); await runJob(); await signIn(USER); const { GET } = await import("@/app/api/mood/prognosis/route"); const res = await GET( new NextRequest("http://localhost/api/mood/prognosis"), ); expect(res.status).toBe(200); const body = (await res.json()) as { data: { present: boolean; n: number; predicted: number; ciLow: number }; }; expect(body.data.present).toBe(true); expect(body.data.n).toBe(40); expect(typeof body.data.predicted).toBe("number"); expect(typeof body.data.ciLow).toBe("number"); }); it("refuses below the floor even while a stale row survives, and never emits an out-of-enum stage", async () => { // The gap the read path has to close on its own. The job runs nightly, so // between an entry deletion and the next 04:35 pass a MoodPrediction row // outlives its own eligibility. If the read path trusted the row it would // answer present:true with a value the account no longer has the data to // support — and with stage "insufficient", which is not in the published // enum, so a strict client decode fails the whole payload. The read path // must agree with the job's own stale rule: below the floor is absence. await seedUser(); await seedDays(40); await runJob(); expect((await storedRows()).length).toBeGreaterThan(0); // Delete back to 20 rated days — under the 30-day forecast floor, but // still inside the learning band, so the refusal reason is a real rung // rather than the empty-account one. Deliberately DO NOT run the job: the // point is what the route answers in the window before the sweep clears // the row. The existing "clears a stale forecast" case runs the job before // reading, which is exactly why it never saw this. await getPrismaClient().moodEntry.deleteMany({ where: { userId: USER, date: { gte: dayKey(20) } }, }); expect( (await storedRows()).length, "the row must still be on disk — otherwise this proves nothing about the read path", ).toBeGreaterThan(0); await signIn(USER); const { GET } = await import("@/app/api/mood/prognosis/route"); const res = await GET( new NextRequest("http://localhost/api/mood/prognosis"), ); expect(res.status).toBe(200); const body = (await res.json()) as { data: { present: boolean; reason?: string; stage?: string; predicted?: number; }; }; expect( body.data.present, "the read path served a forecast to an account below every floor", ).toBe(false); expect(body.data).not.toHaveProperty("predicted"); // The out-of-enum stage a strict Codable decode chokes on must never leave // the route at all. expect(body.data.stage).toBeUndefined(); expect(body.data.reason).toBe("learning-phase"); }); it("refuses a delegate whose grant does not open the whole record", async () => { await seedUser(); await seedDays(40); await runJob(); const rows = await storedRows(); expect(rows.length).toBeGreaterThan(0); const delegate = await getPrismaClient().user.create({ data: { id: "user-mood-prognosis-delegate", username: "mood-prognosis-delegate", email: "mood-prognosis-delegate@example.test", timezone: TZ, }, }); await getPrismaClient().accountGrant.create({ data: { grantorId: USER, granteeId: delegate.id, access: "READ", acceptedAt: new Date(), // Only the mind section. The forecast reads across sections and names // what it read, so it is not a mind payload. scopeJson: ["mind"] as never, }, }); await signIn(delegate.id, USER); const { GET } = await import("@/app/api/mood/prognosis/route"); const res = await GET( new NextRequest("http://localhost/api/mood/prognosis"), ); expect(res.status).toBe(403); // Asserted on the bytes, not on a parsed field: the point is that not one // figure of the owner's reached the response. const raw = await res.text(); expect(raw).not.toContain("predicted"); expect(raw).not.toContain("ciLow"); expect(raw).not.toContain("dimension:a2"); expect(raw).not.toContain(String(rows[0].n)); expect(raw).not.toContain(rows[0].date); }); it("blanks the surface when the mood module is off", async () => { await seedUser(); await seedDays(40); await runJob(); await getPrismaClient().user.update({ where: { id: USER }, data: { modulePreferencesJson: { mood: false } }, }); await signIn(USER); const { GET } = await import("@/app/api/mood/prognosis/route"); const res = await GET( new NextRequest("http://localhost/api/mood/prognosis"), ); expect(res.status).toBe(403); }); });