mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Merge pull request #1064 from MBombeck/fix/backup-summary-manifest
Answer the restore preview from counts kept with the backup
This commit is contained in:
19 files changed
+1605
-101
No files matched your search
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* The restore dialog's contents box asks once and says honestly why it has
|
||||
* nothing to show.
|
||||
*
|
||||
* A copy from an earlier version is read whole on the server, which took two
|
||||
* minutes for a large record; the dialog stopped waiting, retried, and the
|
||||
* server read the whole copy a second time (#1031). Pinned here: the preview
|
||||
* query is never retried and carries its own timeout, and a timeout reads as
|
||||
* "still being read", not as "could not be read".
|
||||
*
|
||||
* Mutation check: drop `retry: false` and the first case goes red; classify a
|
||||
* timeout as unavailable and the second goes red.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
queries: [] as Array<Record<string, unknown>>,
|
||||
apiGet: vi.fn(async () => ({ summary: {} })),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/admin/backups",
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/api/api-fetch")>();
|
||||
return { ...actual, apiGet: mocks.apiGet };
|
||||
});
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: (options: Record<string, unknown>) => {
|
||||
mocks.queries.push(options);
|
||||
return {
|
||||
data: {
|
||||
rows: [
|
||||
{
|
||||
id: "b1",
|
||||
userId: "u1",
|
||||
username: "self-hoster",
|
||||
type: "WEEKLY_AUTO",
|
||||
sizeBytes: 1024,
|
||||
createdAt: "2026-09-01T03:00:00.000Z",
|
||||
},
|
||||
],
|
||||
retentionDays: 30,
|
||||
schedule: {
|
||||
lastSuccessAt: "2026-09-01T03:00:00.000Z",
|
||||
lastSuccessAgeDays: 2,
|
||||
staleAfterDays: 10,
|
||||
stale: false,
|
||||
lastRun: null,
|
||||
lastRunFailed: false,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
};
|
||||
},
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
variables: undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", username: "testuser", role: "ADMIN" },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { BackupsSection, restorePreviewFailure } from "../backups-section";
|
||||
|
||||
describe("restore preview query", () => {
|
||||
it("is never retried and waits a bounded time", async () => {
|
||||
renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<BackupsSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
const key = JSON.stringify(queryKeys.adminBackupSummary("b1"));
|
||||
const preview = mocks.queries.find(
|
||||
(options) => JSON.stringify(options.queryKey) === key,
|
||||
);
|
||||
expect(preview).toBeDefined();
|
||||
expect(preview!.retry).toBe(false);
|
||||
|
||||
await (preview!.queryFn as () => Promise<unknown>)();
|
||||
const [path, init] = mocks.apiGet.mock.calls[0] as unknown as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(path).toBe("/api/admin/backups/b1/summary");
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restorePreviewFailure", () => {
|
||||
it("reads a timeout as a copy still being read", () => {
|
||||
const timeout = new DOMException("signal timed out", "TimeoutError");
|
||||
expect(restorePreviewFailure(timeout)).toBe("stillReading");
|
||||
});
|
||||
|
||||
it("names a missing key", () => {
|
||||
expect(
|
||||
restorePreviewFailure(
|
||||
new ApiError("key", 422, {
|
||||
errorCode: "backup.key.missing",
|
||||
keyIds: ["v1"],
|
||||
}),
|
||||
),
|
||||
).toBe("keyMissing");
|
||||
});
|
||||
|
||||
it("calls everything else unavailable", () => {
|
||||
expect(restorePreviewFailure(new ApiError("boom", 500))).toBe(
|
||||
"unavailable",
|
||||
);
|
||||
expect(restorePreviewFailure(new TypeError("offline"))).toBe("unavailable");
|
||||
});
|
||||
});
|
||||
@@ -90,6 +90,39 @@ import {
|
||||
* Mirrors the wipe dialog's pattern but adds the typed gate because the
|
||||
* blast radius is bigger (re-creates rows, not just deletes).
|
||||
*/
|
||||
/**
|
||||
* How long the restore dialog waits for the contents of a copy. A copy's
|
||||
* counts are kept with it and come back at once; this only runs out on a copy
|
||||
* from an earlier version that the server is still reading whole, and stays
|
||||
* under the minute a reverse proxy commonly allows a request.
|
||||
*/
|
||||
const PREVIEW_TIMEOUT_MS = 55_000;
|
||||
|
||||
/**
|
||||
* What the restore dialog says when the contents did not come back: the key
|
||||
* the copy needs and this server lacks, a copy from an earlier version still
|
||||
* being read when the dialog stopped waiting, or anything else.
|
||||
*/
|
||||
export function restorePreviewFailure(
|
||||
error: unknown,
|
||||
): "keyMissing" | "stillReading" | "unavailable" {
|
||||
if (
|
||||
error instanceof ApiError &&
|
||||
error.meta?.errorCode === "backup.key.missing"
|
||||
) {
|
||||
return "keyMissing";
|
||||
}
|
||||
// `AbortSignal.timeout` rejects the fetch with a DOMException of this name.
|
||||
if (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
(error as { name?: unknown }).name === "TimeoutError"
|
||||
) {
|
||||
return "stillReading";
|
||||
}
|
||||
return "unavailable";
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.37.20 — the sections a restore preview names, in render order. Each
|
||||
* entry sums one or more `BackupSummary` counters under one human label;
|
||||
@@ -151,18 +184,25 @@ function RestoreRowDialog({
|
||||
const [withInstanceSettings, setWithInstanceSettings] = useState(false);
|
||||
|
||||
// v1.37.20 — restore preview: fetch what the file contains the moment the
|
||||
// dialog opens, so the typed confirmation is an informed one. Derived from
|
||||
// the same decrypt + schema path the restore itself runs. A failed preview
|
||||
// never blocks the restore — it states its own absence instead.
|
||||
// dialog opens, so the typed confirmation is an informed one. A failed
|
||||
// preview never blocks the restore — it states its own absence instead.
|
||||
//
|
||||
// Never retried. The server answers from counts kept with the copy, at
|
||||
// once; only a copy from an earlier version is read whole, and that read
|
||||
// goes on (and is kept) after this request gives up. A retry would only
|
||||
// start the same read a second time (#1031).
|
||||
const preview = useQuery({
|
||||
queryKey: queryKeys.adminBackupSummary(row.id),
|
||||
queryFn: () =>
|
||||
apiGet<{ summary: Record<string, unknown> }>(
|
||||
`/api/admin/backups/${row.id}/summary`,
|
||||
{ signal: AbortSignal.timeout(PREVIEW_TIMEOUT_MS) },
|
||||
),
|
||||
enabled: open,
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
const previewFailure = restorePreviewFailure(preview.error);
|
||||
const numericSummary: Record<string, number> = {};
|
||||
if (preview.data?.summary) {
|
||||
for (const [key, value] of Object.entries(preview.data.summary)) {
|
||||
@@ -239,17 +279,21 @@ function RestoreRowDialog({
|
||||
</p>
|
||||
)}
|
||||
{preview.isError &&
|
||||
(preview.error instanceof ApiError &&
|
||||
preview.error.meta?.errorCode === "backup.key.missing" ? (
|
||||
(previewFailure === "keyMissing" &&
|
||||
preview.error instanceof ApiError ? (
|
||||
// The one preview failure the restore would repeat: say which
|
||||
// key, so the operator can put it back before trying.
|
||||
<p role="alert" className="text-destructive">
|
||||
{t("admin.section.backups.previewKeyMissing", {
|
||||
keys: Array.isArray(preview.error.meta.keyIds)
|
||||
keys: Array.isArray(preview.error.meta?.keyIds)
|
||||
? preview.error.meta.keyIds.map(String).join(", ")
|
||||
: "",
|
||||
})}
|
||||
</p>
|
||||
) : previewFailure === "stillReading" ? (
|
||||
<p className="text-muted-foreground">
|
||||
{t("admin.section.backups.previewStillReading")}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground">
|
||||
{t("admin.section.backups.previewUnavailable")}
|
||||
|
||||
Reference in new issue
Block a user