Merge branch 'main' into fix/v1396-small-fixes

This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-09-30 23:27:25 +02:00
commit 85d11bc364
39 files changed
+2981 -155

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");
});
});
+50 -6
View File
@@ -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")}
+35 -1
View File
@@ -3,6 +3,7 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
Activity,
Check,
ChevronDown,
Copy,
@@ -62,6 +63,7 @@ export function ApiSection() {
<div className="space-y-6">
<ApiEndpointsCard />
<MeasurementTokensCard />
<WorkoutTokensCard />
<DocumentTokensCard />
<ApiTokensCard />
</div>
@@ -95,6 +97,29 @@ function MeasurementTokensCard() {
);
}
/**
* Mint a token a workout bridge pushes through (#1054): a watch vendor's sync
* relayed by a small service, or a script replaying an export. Same card,
* same no-list reasoning as the measurement one above.
*/
function WorkoutTokensCard() {
const { t } = useTranslations();
return (
<IngestTokenCard
endpoint="/api/tokens/workouts"
icon={Activity}
slot="settings-workouts-token-created"
copy={{
title: t("settings.workoutsToken.title"),
description: t("settings.workoutsToken.description"),
detail: t("settings.workoutsToken.detail"),
scopeNote: t("settings.workoutsToken.scopeNote"),
createFailed: t("settings.workoutsToken.createFailed"),
}}
/>
);
}
/**
* Mint a token another document system uploads through (#1038): a Paperless
* workflow or the import script. Same card, same no-list reasoning as the
@@ -125,7 +150,10 @@ function IngestTokenCard({
slot,
copy,
}: {
endpoint: "/api/tokens/measurements" | "/api/tokens/documents";
endpoint:
| "/api/tokens/measurements"
| "/api/tokens/workouts"
| "/api/tokens/documents";
icon: LucideIcon;
slot: string;
copy: {
@@ -296,6 +324,12 @@ function ApiEndpointsCard() {
auth: "Authorization: Bearer hlk_...",
example: `{ "entries": [ { "hkIdentifier": "...", "value": 1, ... } ] }`,
},
{
method: "POST",
path: "/api/workouts/batch",
auth: "Authorization: Bearer hlk_...",
example: `{ "workouts": [ { "sportType": "running", "startedAt": "...", "endedAt": "...", "externalId": "..." } ] }`,
},
{
method: "POST",
path: "/api/documents/inbound",
@@ -71,6 +71,8 @@ describe.each([
["components/settings/share-link-create-form.tsx", "/api/share-links"],
["components/settings/mcp-section.tsx", "/api/mcp/tokens"],
["components/settings/api-section.tsx", "/api/tokens/measurements"],
["components/settings/api-section.tsx", "/api/tokens/documents"],
["components/settings/api-section.tsx", "/api/tokens/workouts"],
["components/admin/backups-section.tsx", "/download"],
["components/admin/backups-section.tsx", "/restore"],
["components/admin/backups-section.tsx", "/api/admin/backups/upload"],