diff --git a/e2e/today-just-in.spec.ts b/e2e/today-just-in.spec.ts new file mode 100644 index 000000000..49e00ba81 --- /dev/null +++ b/e2e/today-just-in.spec.ts @@ -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(); + } +}); diff --git a/src/components/daily/__tests__/today-hero-just-in.test.tsx b/src/components/daily/__tests__/today-hero-just-in.test.tsx new file mode 100644 index 000000000..afb978cb1 --- /dev/null +++ b/src/components/daily/__tests__/today-hero-just-in.test.tsx @@ -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( + + {node} + , + ); +} + +const ARRIVED_AT = "2026-07-16T05:41:00.000Z"; + +function digest(over: Partial = {}): 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(); + 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( + , + ); + + // 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + "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( + , + ); + + 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( + , + ); + + 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( + , + ); + 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( + , + ); + + expect(html).toContain('data-slot="today-hero"'); + expect(html).toContain(REACTION); + }); +}); diff --git a/src/lib/daily/__tests__/digest-just-in.test.ts b/src/lib/daily/__tests__/digest-just-in.test.ts new file mode 100644 index 000000000..54a4f401a --- /dev/null +++ b/src/lib/daily/__tests__/digest-just-in.test.ts @@ -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 { + 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(), + ...over, + }; +} + +/** An arrival `agoMs` before NOW. */ +function arrival( + agoMs: number, + over: Partial = {}, +): 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); + }); +}); diff --git a/src/lib/jobs/__tests__/reaction-line-degradation.test.ts b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts new file mode 100644 index 000000000..b58546842 --- /dev/null +++ b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts @@ -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(), + // 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, + ); + }); +});