test(workouts): pin the insight card's text-children rendering

The paragraph is model output. Rendering it as markup would be an XSS surface,
and this project ships no markdown library so the question stays closed — the
test is what keeps that from being treated as a formatting decision.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 14:48:08 +02:00
1 parent ac9170945d
commit 69b61770c4
2 files changed
+56 -11

No files matched your search

@@ -11,6 +11,7 @@ import {
WorkoutDetailSplits,
WorkoutDetailDayLinks,
} from "../workout-detail";
import { WorkoutInsightCard } from "../workout-detail/insight-slot";
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
import type { RouteCoordinate } from "@/lib/workouts/route-svg";
@@ -262,3 +263,42 @@ describe("<WorkoutDetailDayLinks>", () => {
expect(html).toContain('href="/insights/mood"');
});
});
/**
* The Activity Insight card.
*
* Two contracts worth pinning at the render layer: the paragraph is shown
* verbatim, and it is shown as TEXT. Model output rendered as markup is an XSS
* surface, and this project ships no markdown library precisely so that the
* question never arises — a test is what keeps someone from "improving" that.
*/
describe("<WorkoutInsightCard>", () => {
it("renders the paragraph under the card's title", () => {
const html = render(
<WorkoutInsightCard
insight={{
paragraph: "A steady, aerobic-leaning ride.",
generatedAt: "2026-05-15T07:35:00Z",
}}
/>,
);
expect(html).toContain("A steady, aerobic-leaning ride.");
expect(html).toContain('data-slot="workout-detail-insight"');
});
it("escapes markup rather than rendering it", () => {
const html = render(
<WorkoutInsightCard
insight={{
paragraph: '<img src=x onerror="alert(1)"> **bold**',
generatedAt: "2026-05-15T07:35:00Z",
}}
/>,
);
// The tag is escaped, not emitted, and the asterisks stay literal — there
// is no markdown renderer in the path and there must not be one.
expect(html).not.toContain("<img");
expect(html).toContain("&lt;img");
expect(html).toContain("**bold**");
});
});
@@ -7,18 +7,23 @@ import { TileHeader } from "@/components/insights/tile-header";
import { useTranslations } from "@/lib/i18n/context";
/**
* Reserved Activity-Insight seam (strategic-concept Wave C, bet 5).
* The per-workout Activity Insight card.
*
* NOTHING renders here today: the workout payload's `aiInsight` field is
* always `null`, so the page composes `{aiInsight ? <WorkoutInsightCard
* insight={aiInsight}/> : null}` and this component never mounts. The
* shape below is what the Phase-2 pg-boss job will write onto the
* workout row — when it lands, the server maps the row to `aiInsight`,
* the union in `use-workouts.ts` widens from `null` to
* `WorkoutActivityInsightData | null`, and the card mounts right under
* the hero header with zero layout rework. Non-diagnostic by
* construction: a descriptive paragraph, device-attributed, never a
* verdict.
* Mounts only when the workout payload carries a paragraph — the page composes
* `{aiInsight ? <WorkoutInsightCard insight={aiInsight}/> : null}`, and `null`
* is the common case rather than an error state: a paragraph exists only for a
* session that LANDED while the feature was live, ran over ten minutes, fell
* under the day's cap, and found a provider. Every workout that predates the
* feature, every re-synced one, and every one on a provider-less install
* renders nothing here, permanently. Opening this page never generates one.
*
* Non-diagnostic by construction: a descriptive paragraph, device-attributed,
* compared only to the user's own history, never a verdict and never a training
* prescription (the contract lives in `@/lib/ai/prompts/workout-insight`).
*
* The paragraph renders as React TEXT CHILDREN, deliberately. There is no
* markdown library in this project and adding one is a security decision, not a
* formatting one — model output rendered as markup is an XSS surface.
*/
export interface WorkoutActivityInsightData {
/** The cached, plain-text insight paragraph. Rendered as text children. */