mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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");
|
||||
});
|
||||
});
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in new issue
Block a user