test(daily): pin the just-in window, the degrade floor, and the hydration edge

Three properties, each with a mutation-verified guard:

The chip and the line run on DIFFERENT clocks and must keep doing so. The chip
expires after three hours; the line stands for the rest of the local day.
Collapsing them onto one clock reads like a simplification and silently deletes
the day's read at lunchtime.

Degradation is the headline. For every reason a line can fail to exist — no
provider, no consent, an exhausted budget, a dead provider, an unusable output
— the row stays line-less, the reservation is reconciled, and the chip and the
provisional-to-final flip still work. The degraded digest is built through the
real composer rather than a hand-shaped fixture, so the assertions cannot agree
with themselves.

The server pass carries no wall-clock time. The hero paints on the SSR pass
because it is the LCP element, so a locale-formatted time inside it would differ
from the browser's — React #418. The tests render through the server snapshot,
which is exactly the pass that must stay clock-free.

The seeded end-to-end spec lands a sleep batch through the real ingest route,
writes the arrival marker, and asserts the chip and the phase flip in place on
a refetch trigger the digest hook already declares — no reload, no new cadence.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 14:13:50 +02:00
1 parent ca170925dc
commit 663b3530dd
4 files changed
+805

No files matched your search

@@ -0,0 +1,202 @@
/**
* The Today hero's "just in" chip and the reaction line's lead replacement.
*
* Two invariants, both of which have a specific failure this project has
* already paid for:
*
* 1. HYDRATION. The hero is deliberately NOT mount-gated — it paints from the
* server-dehydrated digest on the SSR pass because it is the LCP element
* (v1.30.9). So anything inside it that differs between the server and the
* browser is a React #418 mismatch. A wall-clock time is exactly that: the
* server's locale and timezone are not the reader's. The chip therefore
* renders its SLOT on both passes and fills the time in only after mount.
* These tests render through `renderToStaticMarkup`, which takes the SERVER
* snapshot of `useSyncExternalStore` — i.e. precisely the pass that must
* carry no formatted time.
*
* 2. NO LAYOUT SHIFT. The reaction line REPLACES the lead; it is never a second
* paragraph. The hero stays one lead line tall, so nothing below it moves
* when the line arrives on a poll.
*/
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { TodayHero } from "../today-hero";
import type { DailyDigest } from "@/lib/daily/digest";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
const client = new QueryClient();
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
</QueryClientProvider>,
);
}
const ARRIVED_AT = "2026-07-16T05:41:00.000Z";
function digest(over: Partial<DailyDigest> = {}): DailyDigest {
return {
generatedAt: "2026-07-16T06:00:00.000Z",
phase: "final",
sleepPending: false,
score: { value: 82, band: "green", delta: 3 },
topSignal: null,
briefingLead: "Your week is trending steady.",
line: "Your week is trending steady.",
worthALook: [],
justIn: null,
reactionLine: null,
...over,
};
}
/** Any hh:mm clock face — the thing the server must never emit. */
const CLOCK_FACE = /\d{1,2}:\d{2}/;
describe("TodayHero — the just-in chip", () => {
it("renders no chip when nothing landed", () => {
const html = render(<TodayHero digest={digest()} />);
expect(html).not.toContain('data-slot="today-hero-just-in"');
});
it("renders the chip slot on the server pass, carrying the kind", () => {
const html = render(
<TodayHero
digest={digest({ justIn: { kind: "sleep_night", at: ARRIVED_AT } })}
/>,
);
// The slot is present on the SSR pass, so the hydration render — which
// takes the same server snapshot — produces identical HTML.
expect(html).toContain('data-slot="today-hero-just-in"');
expect(html).toContain('data-just-in-kind="sleep_night"');
expect(html).toContain("Just in");
});
it("emits NO formatted time and no raw ISO instant on the server pass", () => {
const html = render(
<TodayHero
digest={digest({ justIn: { kind: "weight", at: ARRIVED_AT } })}
/>,
);
const chip = html.slice(html.indexOf('data-slot="today-hero-just-in"'));
// The mismatch itself: a wall clock the browser would render differently.
expect(chip).not.toMatch(CLOCK_FACE);
// And the raw instant must not leak either — it is machine data, not copy.
expect(html).not.toContain(ARRIVED_AT);
});
it("keeps the chip muted meta — never an accent, never an opacity modifier", () => {
const html = render(
<TodayHero
digest={digest({ justIn: { kind: "workout", at: ARRIVED_AT } })}
/>,
);
const row = html.slice(
0,
html.indexOf('data-slot="today-hero-just-in"') + 400,
);
expect(row).toContain("text-muted-foreground");
expect(row).toContain("text-xs");
// UI-STANDARDS §text: alpha on muted text drops below AA contrast.
expect(html).not.toContain("text-muted-foreground/");
expect(html).not.toContain("text-primary");
});
it("shows the chip alongside a still-pending night", () => {
// Legitimately co-occurring: a weight landed this morning while last
// night's sleep has not arrived yet.
const html = render(
<TodayHero
digest={digest({
phase: "provisional",
sleepPending: true,
justIn: { kind: "weight", at: ARRIVED_AT },
})}
/>,
);
expect(html).toContain('data-slot="today-hero-sleep-pending"');
expect(html).toContain('data-slot="today-hero-just-in"');
});
it("localises the chip", () => {
const html = render(
<TodayHero
digest={digest({ justIn: { kind: "sleep_night", at: ARRIVED_AT } })}
/>,
"de",
);
expect(html).toContain("Gerade eingetroffen");
expect(html).not.toContain("Just in");
});
});
describe("TodayHero — the reaction line replaces the lead", () => {
const REACTION = "A solid night, deeper than your recent stretch.";
it("renders the reaction line instead of the briefing lead", () => {
const html = render(
<TodayHero
digest={digest({
reactionLine: REACTION,
briefingLead: "Your week is trending steady.",
})}
/>,
);
expect(html).toContain(REACTION);
// The lead it replaced must be GONE, not pushed down into a second block.
expect(html).not.toContain("Your week is trending steady.");
});
it("stays exactly one lead line tall — no second paragraph, no shift", () => {
const html = render(
<TodayHero
digest={digest({
reactionLine: REACTION,
briefingLead: "Your week is trending steady.",
})}
/>,
);
const leadSlots = html.split('data-slot="today-hero-lead"').length - 1;
expect(leadSlots).toBe(1);
});
it("falls back to the briefing lead when no line was generated", () => {
const html = render(
<TodayHero
digest={digest({
reactionLine: null,
briefingLead: "Your week is trending steady.",
})}
/>,
);
expect(html).toContain("Your week is trending steady.");
});
it("still renders for an otherwise-bare account when a line exists", () => {
// The one moment this surface exists for must not be swallowed by the
// empty-account degrade.
const html = render(
<TodayHero
digest={digest({
score: null,
briefingLead: null,
worthALook: [],
reactionLine: REACTION,
justIn: { kind: "weight", at: ARRIVED_AT },
})}
/>,
);
expect(html).toContain('data-slot="today-hero"');
expect(html).toContain(REACTION);
});
});
@@ -0,0 +1,157 @@
/**
* The "just in" fields of the daily digest.
*
* Two clocks are under test and they are deliberately different, which is the
* whole point of this file: the CHIP expires with the news (three hours), the
* LINE stands for the rest of the local day. Collapsing them onto one clock is
* the most plausible future regression here — it reads like a simplification
* and it silently deletes the day's read at lunchtime.
*/
import { describe, it, expect } from "vitest";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { MedsTodayBlock } from "@/lib/dashboard/meds-today";
import {
buildDailyDigest,
JUST_IN_WINDOW_MS,
type DailyDigestArrival,
type DailyDigestInput,
} from "@/lib/daily/digest";
const t = getServerTranslator("en").t;
const NOW = new Date("2026-07-16T09:00:00.000Z");
function meds(): MedsTodayBlock {
return {
activeCount: 0,
scheduledToday: 0,
takenToday: 0,
skippedToday: 0,
nextDueAt: null,
nextDueOverdue: false,
nextDueMedicationName: null,
nextDueMedicationId: null,
};
}
function input(over: Partial<DailyDigestInput> = {}): DailyDigestInput {
return {
now: NOW,
modules: {},
score: { value: 82, band: "good", delta: 3 },
briefing: null,
medsToday: meds(),
sleepLastSeenDaysAgo: 0,
morningRefreshedToday: false,
syncIssues: [],
preventiveDue: [],
coachPlans: [],
tensionWindow: null,
todayLocalDate: "2026-07-16",
dismissedItemKeys: new Set<string>(),
...over,
};
}
/** An arrival `agoMs` before NOW. */
function arrival(
agoMs: number,
over: Partial<DailyDigestArrival> = {},
): DailyDigestArrival {
return {
kind: "sleep_night",
occurredAt: new Date(NOW.getTime() - agoMs),
line: null,
...over,
};
}
describe("buildDailyDigest — justIn", () => {
it("is null when nothing landed today", () => {
const digest = buildDailyDigest(input(), t);
expect(digest.justIn).toBeNull();
expect(digest.reactionLine).toBeNull();
});
it("names the arrival and carries an ISO instant, never a formatted time", () => {
const at = new Date(NOW.getTime() - 60_000);
const digest = buildDailyDigest(
input({ arrivals: [arrival(60_000, { kind: "weight" })] }),
t,
);
expect(digest.justIn).toEqual({ kind: "weight", at: at.toISOString() });
// The wire value must stay machine-readable. A server-formatted wall clock
// here is the React #418 bug in its exact original shape.
expect(digest.justIn?.at).toMatch(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z$/);
});
it("shows the chip inside the window and drops it after", () => {
const inside = buildDailyDigest(
input({ arrivals: [arrival(JUST_IN_WINDOW_MS - 60_000)] }),
t,
);
expect(inside.justIn).not.toBeNull();
const outside = buildDailyDigest(
input({ arrivals: [arrival(JUST_IN_WINDOW_MS + 60_000)] }),
t,
);
expect(outside.justIn).toBeNull();
});
it("keeps the reaction line after the chip has expired", () => {
// The load-bearing asymmetry: past the window the record is no longer
// NEWS, but the sentence is still the day's READ and must survive.
const digest = buildDailyDigest(
input({
arrivals: [
arrival(JUST_IN_WINDOW_MS + 60 * 60_000, {
line: "A solid night, deeper than your recent stretch.",
}),
],
}),
t,
);
expect(digest.justIn).toBeNull();
expect(digest.reactionLine).toBe(
"A solid night, deeper than your recent stretch.",
);
});
it("picks the newest arrival when several landed", () => {
const digest = buildDailyDigest(
input({
arrivals: [
arrival(2 * 60 * 60_000, { kind: "weight", line: "older" }),
arrival(10 * 60_000, { kind: "workout", line: "newer" }),
arrival(60 * 60_000, { kind: "blood_pressure", line: "middle" }),
],
}),
t,
);
expect(digest.justIn?.kind).toBe("workout");
expect(digest.reactionLine).toBe("newer");
});
it("treats a blank generated line as absent rather than shipping an empty lead", () => {
const digest = buildDailyDigest(
input({ arrivals: [arrival(60_000, { line: " " })] }),
t,
);
expect(digest.justIn).not.toBeNull();
expect(digest.reactionLine).toBeNull();
});
it("stays pure — the composer never mutates the arrivals it was handed", () => {
const arrivals = [arrival(60_000), arrival(30_000, { kind: "weight" })];
const snapshot = JSON.stringify(arrivals);
buildDailyDigest(input({ arrivals }), t);
expect(JSON.stringify(arrivals)).toBe(snapshot);
});
});
@@ -0,0 +1,301 @@
/**
* Degradation is the headline, not the AI.
*
* The milestone's actual moment is the STATE CHANGE: something landed, the
* record now reads differently, and the open dashboard shows it. The generated
* sentence is garnish on top of that. This file exists to keep those two
* things separable forever — because the tempting future refactor is to treat
* the line as the feature and let a provider-less install fall off the surface
* with it.
*
* So: for every reason a line can fail to exist — no provider, no consent, an
* exhausted budget, a dead provider, an unusable output — the row must stay
* line-less WITHOUT spending anything it did not reconcile, and the digest must
* still surface the chip. The last assertion of each case is the one that
* matters: `justIn` survives.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { buildDailyDigest, type DailyDigestInput } from "@/lib/daily/digest";
const findUnique = vi.fn();
const update = vi.fn();
const userFindUnique = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
arrivalReaction: {
findUnique: (...a: unknown[]) => findUnique(...a),
update: (...a: unknown[]) => update(...a),
},
user: { findUnique: (...a: unknown[]) => userFindUnique(...a) },
},
}));
const resolveProviderChain = vi.fn();
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a),
}));
const chainRequiresServerManagedConsent = vi.fn();
const hasActiveConsentForSurface = vi.fn();
vi.mock("@/lib/ai/consent-guard", () => ({
chainRequiresServerManagedConsent: (...a: unknown[]) =>
chainRequiresServerManagedConsent(...a),
hasActiveConsentForSurface: (...a: unknown[]) =>
hasActiveConsentForSurface(...a),
}));
const reserveBudget = vi.fn();
const reconcileSpend = vi.fn();
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: () => "2026-07-16",
reserveBudget: (...a: unknown[]) => reserveBudget(...a),
reconcileSpend: (...a: unknown[]) => reconcileSpend(...a),
resolveDailyCap: () => 200_000,
}));
const loadDailyDigest = vi.fn();
vi.mock("@/lib/daily/load-digest", () => ({
loadDailyDigest: (...a: unknown[]) => loadDailyDigest(...a),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/jobs/reminder/shared", () => ({ workerLog: vi.fn() }));
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
encryptToBytes: (s: string) => new TextEncoder().encode(s),
}));
import { runReactionLine } from "@/lib/jobs/reaction-line";
const JOB = {
userId: "u1",
kind: "sleep_night" as const,
localDate: "2026-07-16",
};
const t = getServerTranslator("en").t;
const NOW = new Date("2026-07-16T09:00:00.000Z");
/**
* The digest a user sees when the line was never written. Built from the REAL
* composer, not a hand-shaped object, so this asserts the actual degrade path
* rather than a fixture that agrees with the test.
*/
function degradedDigest() {
const input: DailyDigestInput = {
now: NOW,
modules: {},
score: { value: 82, band: "good", delta: 3 },
briefing: null,
medsToday: {
activeCount: 0,
scheduledToday: 0,
takenToday: 0,
skippedToday: 0,
nextDueAt: null,
nextDueOverdue: false,
nextDueMedicationName: null,
nextDueMedicationId: null,
},
sleepLastSeenDaysAgo: 0,
morningRefreshedToday: true,
syncIssues: [],
preventiveDue: [],
coachPlans: [],
tensionWindow: null,
todayLocalDate: "2026-07-16",
dismissedItemKeys: new Set<string>(),
// The marker exists; the line does not. This is the provider-less shape.
arrivals: [
{
kind: "sleep_night",
occurredAt: new Date(NOW.getTime() - 60_000),
line: null,
},
],
};
return buildDailyDigest(input, t);
}
/** Every degrade case must leave the surface fully functional. */
function expectSurfaceStillWorks() {
const digest = degradedDigest();
// The chip — the moment itself — is untouched by the missing sentence.
expect(digest.justIn).toEqual({
kind: "sleep_night",
at: new Date(NOW.getTime() - 60_000).toISOString(),
});
// The provisional→final flip is likewise unaffected.
expect(digest.phase).toBe("final");
expect(digest.sleepPending).toBe(false);
// And the hero still has a lead to render: the deterministic floor.
expect(digest.reactionLine).toBeNull();
expect(digest.line.length).toBeGreaterThan(0);
}
beforeEach(() => {
vi.clearAllMocks();
findUnique.mockResolvedValue({ id: "r1", generatedAt: null });
userFindUnique.mockResolvedValue({ id: "u1", locale: "en", timezone: "UTC" });
chainRequiresServerManagedConsent.mockReturnValue(false);
hasActiveConsentForSurface.mockResolvedValue(true);
reserveBudget.mockResolvedValue({
allowed: true,
reserved: 1_400,
totalAfter: 1_400,
});
reconcileSpend.mockResolvedValue(undefined);
loadDailyDigest.mockResolvedValue({
score: { value: 82, band: "good", delta: 3 },
topSignal: null,
briefingLead: "Steady week.",
});
});
describe("reaction line — degradation", () => {
it("no provider: writes nothing, spends nothing, surface intact", async () => {
resolveProviderChain.mockResolvedValue([]);
const outcome = await runReactionLine(JOB);
expect(outcome).toEqual({ status: "skipped", reason: "no_provider" });
expect(reserveBudget).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
expectSurfaceStillWorks();
});
it("no consent on a server-managed chain: refuses before reserving", async () => {
resolveProviderChain.mockResolvedValue([
{ providerType: "admin-openai", instance: {} },
]);
chainRequiresServerManagedConsent.mockReturnValue(true);
hasActiveConsentForSurface.mockResolvedValue(false);
const outcome = await runReactionLine(JOB);
expect(outcome).toEqual({ status: "skipped", reason: "consent_required" });
// A user without a receipt must not spend a token OR a ledger slot.
expect(reserveBudget).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
expectSurfaceStillWorks();
});
it("exhausted budget: refuses, writes nothing, surface intact", async () => {
resolveProviderChain.mockResolvedValue([
{ providerType: "openai", instance: {} },
]);
reserveBudget.mockResolvedValue({
allowed: false,
reserved: 1_400,
totalAfter: 999_999,
});
const outcome = await runReactionLine(JOB);
expect(outcome).toEqual({ status: "skipped", reason: "budget_exceeded" });
expect(update).not.toHaveBeenCalled();
expectSurfaceStillWorks();
});
it("provider throws: reconciles the reservation to zero and degrades", async () => {
resolveProviderChain.mockResolvedValue([
{
providerType: "openai",
instance: {
generateCompletion: vi.fn().mockRejectedValue(new Error("timeout")),
},
},
]);
const outcome = await runReactionLine(JOB);
expect(outcome).toEqual({ status: "skipped", reason: "provider_failed" });
// The failure path MUST reconcile — an abandoned reservation is a silent
// over-charge against the user's own daily ceiling.
expect(reconcileSpend).toHaveBeenCalledWith("u1", 1_400, 0, "2026-07-16");
expect(update).not.toHaveBeenCalled();
expectSurfaceStillWorks();
});
it("unusable output: reconciles the ACTUAL spend and still writes no line", async () => {
resolveProviderChain.mockResolvedValue([
{
providerType: "openai",
instance: {
generateCompletion: vi.fn().mockResolvedValue({
content: " ",
tokensUsed: 900,
cachedInputTokens: 0,
}),
},
},
]);
const outcome = await runReactionLine(JOB);
expect(outcome).toEqual({ status: "skipped", reason: "unusable_output" });
// The call happened, so the real tokens are billed — not zero.
expect(reconcileSpend).toHaveBeenCalledWith(
"u1",
1_400,
900,
"2026-07-16",
0,
);
expect(update).not.toHaveBeenCalled();
expectSurfaceStillWorks();
});
it("a committed line is never regenerated — the unique row is the throttle", async () => {
findUnique.mockResolvedValue({ id: "r1", generatedAt: new Date() });
resolveProviderChain.mockResolvedValue([
{ providerType: "openai", instance: {} },
]);
const outcome = await runReactionLine(JOB);
expect(outcome).toEqual({ status: "skipped", reason: "already_generated" });
// The refusal happens before the chain is even resolved: there is no code
// path to a second provider call for this kind today.
expect(resolveProviderChain).not.toHaveBeenCalled();
expect(reserveBudget).not.toHaveBeenCalled();
});
it("the happy path does write, so the degrade cases above are not vacuous", async () => {
resolveProviderChain.mockResolvedValue([
{
providerType: "openai",
instance: {
generateCompletion: vi.fn().mockResolvedValue({
content: "A solid night, deeper than your recent stretch.",
tokensUsed: 1_100,
cachedInputTokens: 100,
}),
},
},
]);
const outcome = await runReactionLine(JOB);
expect(outcome).toEqual({ status: "generated" });
expect(update).toHaveBeenCalledTimes(1);
const arg = update.mock.calls[0][0] as {
data: { generatedAt: Date; lineEncrypted: Uint8Array };
};
// Ciphertext and the commit stamp land together — `load-digest` requires
// both, so a half-written row can never surface a line.
expect(arg.data.generatedAt).toBeInstanceOf(Date);
expect(new TextDecoder().decode(arg.data.lineEncrypted)).toBe(
"A solid night, deeper than your recent stretch.",
);
expect(reconcileSpend).toHaveBeenCalledWith(
"u1",
1_400,
1_100,
"2026-07-16",
100,
);
});
});