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

+145
View File
@@ -0,0 +1,145 @@
import { expect, test } from "@playwright/test";
import pg from "pg";
import { E2E_USER, STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.31.0 — Today reacts in place.
*
* The milestone's actual moment: new data lands, and the ALREADY-OPEN
* dashboard starts reading differently on the cadence it already runs. No new
* poll, no push, no socket — a state change, not a notification.
*
* The spec drives that end-to-end against the real read path:
*
* 1. load the dashboard with nothing fresh — no chip, day is provisional;
* 2. land last night's sleep through the REAL batch endpoint, and the
* arrival marker the spine's worker would have written;
* 3. bring the tab back to focus — which is one of the three refetch
* triggers the digest hook ALREADY declares (`refetchOnWindowFocus`,
* alongside `staleTime` and the 120 s foreground poll). Using the focus
* trigger keeps the test inside the real cadence while staying well
* under the poll interval, which no test timeout would survive;
* 4. assert the chip appeared and the phase flipped — in place, no reload.
*
* The marker is seeded directly rather than waited for from pg-boss: whether
* the queue drains inside the e2e web server is the SPINE's contract and has
* its own tests. What this spec owns is everything downstream of a marker
* existing — the read path, the DTO, and the surface.
*
* Assertions anchor on `data-slot` / `data-phase`, never on visible text:
* responsive `sm:hidden` classes have broken `getByText` in this repo before.
*/
test.use({ storageState: STORAGE_STATE_PATH });
/** The user's local day, in the same profile-tz space the digest files under. */
function localDayKey(at: Date, timeZone: string): string {
// en-CA renders ISO-ordered YYYY-MM-DD, which is exactly the key shape.
return new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(at);
}
test("a fresh arrival surfaces the just-in chip and flips the day in place", async ({
page,
}) => {
const dbUrl = process.env.DATABASE_URL;
test.skip(!dbUrl, "DATABASE_URL is required to seed the arrival marker");
const pool = new pg.Pool({ connectionString: dbUrl });
try {
const { rows } = await pool.query<{ id: string; timezone: string | null }>(
"SELECT id, timezone FROM users WHERE username = $1",
[E2E_USER.username],
);
expect(rows.length, "seeded e2e user must exist").toBe(1);
const userId = rows[0].id;
const timezone = rows[0].timezone ?? "UTC";
// A clean slate: no marker, and last night's sleep not yet in. Both are
// preconditions for the "before" assertion to mean anything.
await pool.query("DELETE FROM arrival_reactions WHERE user_id = $1", [
userId,
]);
await pool.query(
"UPDATE users SET morning_digest_refreshed_on = NULL WHERE id = $1",
[userId],
);
// ── BEFORE ────────────────────────────────────────────────────────────
await page.goto("/");
const hero = page.locator('[data-slot="today-hero"]');
await expect(hero).toBeVisible({ timeout: 20_000 });
await expect(hero.locator('[data-slot="today-hero-just-in"]')).toHaveCount(
0,
);
// ── NEW DATA LANDS ────────────────────────────────────────────────────
// Last night's sleep through the real ingest route, on the session the
// storage state already carries.
const now = new Date();
const wokeAt = new Date(now.getTime() - 60 * 60_000);
const batch = await page.request.post("/api/measurements/batch", {
data: {
measurements: [
{
type: "SLEEP_DURATION",
value: 7,
unit: "h",
measuredAt: wokeAt.toISOString(),
externalId: `e2e-just-in-${now.getTime()}`,
source: "manual",
},
],
},
});
expect(
batch.ok(),
`sleep batch must be accepted (got ${batch.status()})`,
).toBe(true);
// The marker the spine's worker writes on a salient arrival, plus the
// morning-refresh stamp it rides alongside — the two rows that together
// make the day final and the arrival news.
const localDate = localDayKey(now, timezone);
await pool.query(
`INSERT INTO arrival_reactions
(id, user_id, kind, local_date, occurred_at, created_at)
VALUES ($1, $2, 'sleep_night', $3, $4, NOW())
ON CONFLICT (user_id, kind, local_date) DO UPDATE
SET occurred_at = EXCLUDED.occurred_at`,
[`c${now.getTime()}justin000000`, userId, localDate, wokeAt],
);
await pool.query(
"UPDATE users SET morning_digest_refreshed_on = $2 WHERE id = $1",
[userId, localDate],
);
// ── THE MOMENT ────────────────────────────────────────────────────────
// The tab regains focus — one of the digest hook's existing refetch
// triggers. Nothing new is introduced here and the page never reloads.
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
await expect(hero.locator('[data-slot="today-hero-just-in"]')).toBeVisible({
timeout: 20_000,
});
await expect(hero).toHaveAttribute("data-phase", "final", {
timeout: 20_000,
});
// Sleep is in, so the provisional freshness note is gone with it.
await expect(
hero.locator('[data-slot="today-hero-sleep-pending"]'),
).toHaveCount(0);
// The chip names the arrival kind — the stable attribute, not the copy.
await expect(
hero.locator('[data-slot="today-hero-just-in"]'),
).toHaveAttribute("data-just-in-kind", "sleep_night");
} finally {
await pool.end();
}
});
@@ -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,
);
});
});