test(backup): prove the skip report reaches the screen, not just the wire

The server names every unresolved catalogue key in its response and in the
audit row, and the integration suite holds that end. This is the other one:
a report nobody sees is the same silence the naming replaced.

The panel moves into its own exported component so it can be rendered
against a report directly. The SSR smoke tests in this directory cannot
drive a state transition, and a render proven only by reading it is not
proven. Pinned: every key is printed rather than summed away, each key
carries its own count and catalogue, an empty report renders nothing at
all, and the copy resolves in a second locale.
This commit is contained in:
Marc-André Bombeck committed 2026-08-02 09:10:58 +02:00
1 parent 9c4235b66d
commit 63075c47d4
2 files changed
+157 -54

No files matched your search

@@ -0,0 +1,81 @@
/**
* The restore's skip report has to reach a human.
*
* The server names every catalogue key it could not resolve, in the response
* and in the audit row, and the integration suite proves that end. This is the
* other end: a report that a person never sees is the same silence the naming
* exists to end, so the panel gets its own coverage rather than riding on the
* fact that a state setter is wired up somewhere.
*
* Pinned here:
* 1. every skipped key is printed, not just a total,
* 2. each key carries its own link count and its catalogue,
* 3. an empty report renders nothing at all — a panel that appears after a
* clean restore trains the operator to ignore it,
* 4. the copy resolves in more than one locale, so a self-hoster running the
* German UI is told the same thing.
*
* Mutation check: total the links instead of listing them (drop the `<ul>`) and
* cases 1 and 2 go red; return the panel unconditionally and case 3 goes red.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { RestoreSkipReport } from "@/components/admin/backups-section";
import type { RestoreSkipSummary } from "@/lib/export/restore-skips";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/admin/backups",
}));
const REPORT: RestoreSkipSummary = {
links: 4,
catalogueKeys: [
{ catalogue: "cycleSymptom", key: "cs.retired_symptom", links: 2 },
{ catalogue: "illnessSymptom", key: "ill.retired_symptom", links: 1 },
{ catalogue: "moodFactor", key: "mood.retired_factor", links: 1 },
],
};
function render(report: RestoreSkipSummary, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<RestoreSkipReport report={report} onDismiss={() => {}} />
</I18nProvider>,
);
}
describe("RestoreSkipReport", () => {
it("names every skipped key rather than only the total", () => {
const html = render(REPORT);
expect(html).toContain('data-slot="restore-skip-report"');
for (const entry of REPORT.catalogueKeys) {
expect(html).toContain(entry.key);
}
});
it("gives each key its own link count and catalogue", () => {
const html = render(REPORT);
// The cycle key cost two links and the other two cost one each. A panel
// that printed the grand total against every row would read as six.
expect(html).toContain("2 links");
expect(html).toContain("1 links");
expect(html).toContain("Cycle symptom");
expect(html).toContain("Illness symptom");
expect(html).toContain("Mood factor");
expect(html).toContain("4 links were not restored");
});
it("renders nothing when the restore dropped nothing", () => {
expect(render({ links: 0, catalogueKeys: [] })).toBe("");
});
it("speaks the operator's language", () => {
const german = render(REPORT, "de");
expect(german).toContain("4 Verknüpfungen wurden nicht wiederhergestellt");
expect(german).toContain("Zyklussymptom");
expect(german).toContain("cs.retired_symptom");
});
});
+76 -54
View File
@@ -197,6 +197,77 @@ function catalogueLabel(
return t("admin.section.backups.restoreSkippedMoodFactor");
}
/**
* What the last restore could not put back.
*
* Its own component, and exported, so it can be rendered against a report in a
* test instead of only through a state transition the SSR smoke tests here
* cannot drive. A report that reaches the response and never reaches the screen
* is the same silence the reporting exists to end, so the render is worth
* proving on its own.
*
* It names every key rather than totalling them away: an operator who cannot
* see WHICH symptom went missing cannot decide whether it mattered.
*/
export function RestoreSkipReport({
report,
onDismiss,
}: {
report: RestoreSkipSummary;
onDismiss: () => void;
}) {
const { t } = useTranslations();
if (report.links === 0) return null;
return (
<div
role="status"
data-slot="restore-skip-report"
className="border-warning/40 bg-warning/10 mt-4 rounded-md border px-3 py-2 text-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2">
<AlertTriangle
className="mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<div>
<p className="font-medium">
{t("admin.section.backups.restoreSkippedTitle", {
links: String(report.links),
})}
</p>
<p className="text-muted-foreground text-xs">
{t("admin.section.backups.restoreSkippedDescription")}
</p>
<ul className="mt-2 space-y-1">
{report.catalogueKeys.map((entry) => (
<li key={`${entry.catalogue}:${entry.key}`} className="text-xs">
<span className="font-mono">{entry.key}</span>{" "}
<span className="text-muted-foreground">
{catalogueLabel(entry.catalogue, t)}
{" · "}
{t("admin.section.backups.restoreSkippedLinks", {
count: String(entry.links),
})}
</span>
</li>
))}
</ul>
</div>
</div>
<Button
size="sm"
variant="ghost"
onClick={onDismiss}
className="min-h-11"
>
{t("common.dismiss")}
</Button>
</div>
</div>
);
}
export function BackupsSection() {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -513,60 +584,11 @@ export function BackupsSection() {
</div>
</div>
{/* The restore's honesty panel. Rendered only after a restore that
actually dropped something, and it names every key rather than
totalling them away — an operator who cannot see WHICH symptom went
missing cannot decide whether it mattered. */}
{skipped && skipped.links > 0 ? (
<div
role="status"
data-slot="restore-skip-report"
className="border-warning/40 bg-warning/10 mt-4 rounded-md border px-3 py-2 text-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2">
<AlertTriangle
className="mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<div>
<p className="font-medium">
{t("admin.section.backups.restoreSkippedTitle", {
links: String(skipped.links),
})}
</p>
<p className="text-muted-foreground text-xs">
{t("admin.section.backups.restoreSkippedDescription")}
</p>
<ul className="mt-2 space-y-1">
{skipped.catalogueKeys.map((entry) => (
<li
key={`${entry.catalogue}:${entry.key}`}
className="text-xs"
>
<span className="font-mono">{entry.key}</span>{" "}
<span className="text-muted-foreground">
{catalogueLabel(entry.catalogue, t)}
{" · "}
{t("admin.section.backups.restoreSkippedLinks", {
count: String(entry.links),
})}
</span>
</li>
))}
</ul>
</div>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => setSkipped(null)}
className="min-h-11"
>
{t("common.dismiss")}
</Button>
</div>
</div>
{skipped ? (
<RestoreSkipReport
report={skipped}
onDismiss={() => setSkipped(null)}
/>
) : null}
{isLoading ? (