diff --git a/src/lib/analytics/mood-prognosis/read.ts b/src/lib/analytics/mood-prognosis/read.ts index 6bf0d16ab..480ea9fe5 100644 --- a/src/lib/analytics/mood-prognosis/read.ts +++ b/src/lib/analytics/mood-prognosis/read.ts @@ -57,7 +57,13 @@ export interface PrognosisPresent { current: boolean; /** Below the regular threshold the forecast is labelled provisional. */ provisional: boolean; - stage: PrognosisStage; + /** + * The rung the value is shown at. Only the three rungs that CARRY a value — + * a below-floor reading never reaches here, it becomes the absence shape — + * so the type matches the published enum exactly and an `insufficient` / + * `learning` stage cannot be constructed for a present reading. + */ + stage: Extract; /** Days of history the ladder was read against. */ entries: number; /** Whether weekday and seasonal comparisons have unlocked. */ @@ -166,18 +172,32 @@ export async function readMoodPrognosis( }; } - const [entries, entry] = await Promise.all([ - countQualifyingDays(userId, now), - prisma.moodEntry.findFirst({ - where: { userId, date: row.date, deletedAt: null, moodA1: { not: null } }, - orderBy: { moodLoggedAt: "desc" }, - select: { moodA1: true }, - }), - ]); + const entries = await countQualifyingDays(userId, now); + + // A row on disk is not enough to answer with. The job writes nightly, so + // between an entry deletion and the next pass a row outlives its own + // eligibility. The read path re-reads the ladder and refuses below the + // floor, exactly as the job would — otherwise this route serves a value the + // account no longer has the data to support for up to a day, and it serves + // it under a stage ("insufficient" / "learning") that is not in the + // published enum, which fails a strict client decode of the whole payload. + const gate = forecastGate(entries); + if (!gate.present) { + return { + present: false, + reason: gate.reason, + entries, + nextThreshold: gate.nextThreshold, + }; + } const today = moodDateKey(now, timezone ?? DEFAULT_TIMEZONE); const ageDays = dayKeyAgeInDays(row.date, today); - const gate = forecastGate(entries); + const entry = await prisma.moodEntry.findFirst({ + where: { userId, date: row.date, deletedAt: null, moodA1: { not: null } }, + orderBy: { moodLoggedAt: "desc" }, + select: { moodA1: true }, + }); const selfAssessment = entry?.moodA1 ?? null; const against = selfAssessment === null @@ -195,7 +215,11 @@ export async function readMoodPrognosis( computedAt: row.computedAt.toISOString(), ageDays, current: isCurrentForTodayClaim(ageDays), - provisional: gate.present ? gate.provisional : true, + // `gate` is the present variant here — the absence arm returned above — so + // the stage is one of provisional / regular / seasonal, never the + // out-of-enum "insufficient" or "learning" a stale row could otherwise + // carry onto the wire. + provisional: gate.provisional, stage: gate.stage, entries, seasonalUnlocked: seasonalComparisonsUnlocked(entries), diff --git a/tests/integration/mood-prognosis-ladder.test.ts b/tests/integration/mood-prognosis-ladder.test.ts index 53314ae2c..888e69581 100644 --- a/tests/integration/mood-prognosis-ladder.test.ts +++ b/tests/integration/mood-prognosis-ladder.test.ts @@ -317,6 +317,58 @@ describe("GET /api/mood/prognosis", () => { 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);