fix(mood): refuse a forecast below the data floor on the read path, not only in the job

The job clears a below-floor account's rows nightly, but the read path
trusted whatever row was on disk between passes. So for up to a day
after an account deleted entries back under the floor, GET
/api/mood/prognosis returned present:true with a value the account no
longer had the data to support — and worse, under a stage the row
implied ("insufficient" / "learning") that is not in the published enum,
which fails a strict client decode of the whole payload.

The read path now re-reads the ladder and returns the absence shape
below the floor, exactly as the job would, so the two agree and no
out-of-enum stage can reach the wire. The present type's stage is
narrowed to the three rungs that carry a value, so the compiler enforces
it too.

The integration case that missed this ran the job before reading, which
is precisely why it was silent. The new case deletes entries back into
the learning band and reads WITHOUT running the job, with the row still
proven on disk, and asserts present:false with no stage. Watched red:
before the fix it returned present:true with a predicted value.
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 15:30:00 +02:00
1 parent 8d48e3e262
commit 9a47b4db49
2 files changed
+87 -11

No files matched your search

+35 -11
View File
@@ -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<PrognosisStage, "provisional" | "regular" | "seasonal">;
/** 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),
@@ -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);