mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
ca170925dc
commit
663b3530dd
4 files changed
+805
No files matched your search
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user