From 4c5604bf87077b420faf1faaa8c7feee56545a7e Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 1 Oct 2026 16:11:26 +0700 Subject: [PATCH] =?UTF-8?q?fix(backups):=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B6=D0=B4=D0=B0=D1=82=D1=8C=20=D0=BE=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B2=20=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D1=83=D1=89=D0=B5=D0=BC=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D1=81=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/backups/page.tsx | 92 +++++++++++-------- components/backups/backup-confirm-dialogs.tsx | 22 +++-- docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 11 ++- docs/ux-ui-stage-11a.md | 10 ++ shared/api/backups.test.ts | 11 ++- shared/api/backups.ts | 7 ++ tests/e2e/backups-context.spec.ts | 29 ++++++ tests/e2e/backups-operations.spec.ts | 39 ++++++++ 9 files changed, 174 insertions(+), 49 deletions(-) create mode 100644 tests/e2e/backups-operations.spec.ts diff --git a/app/(main)/backups/page.tsx b/app/(main)/backups/page.tsx index 80eb7cde..0ad58271 100644 --- a/app/(main)/backups/page.tsx +++ b/app/(main)/backups/page.tsx @@ -24,6 +24,7 @@ import { userApiError } from "@/shared/api/users" import { toFrontendServer } from "@/entities/server/model/mappers" import { deleteBackup, + downloadBackup, getBackupScheduleSettings, getBackupStorageSettings, listBackups, @@ -34,7 +35,7 @@ import { testBackupStorage, type BackupItem, } from "@/shared/api/backups" -import { ApiClientError, requestJson, requestBlob } from "@/shared/api/http-client" +import { ApiClientError, requestJson } from "@/shared/api/http-client" import { toast } from "sonner" import { useBackupJob } from "@/lib/use-backup-job" import { backupJobOutcome } from "@/lib/backup-job-poll" @@ -75,6 +76,9 @@ function BackupsContent() { const alive = useRef(true) const scheduleDirty = useRef(false) const storageDirty = useRef(false) + const operationLock = useRef(false) + const operationController = useRef(null) + const downloadControllers = useRef(new Map()) const settingsLock = useRef(false) const settingsController = useRef(null) const known = (resource: Resource) => !isLive || resources[resource].known @@ -160,8 +164,9 @@ function BackupsContent() { }, [backendUrl, isLive, canRead, canReadServers, mapApiBackupToUi]) useEffect(() => { alive.current = true + const downloads = downloadControllers.current queueMicrotask(() => { if (alive.current) void loadLive() }) - return () => { alive.current = false; reader.current?.abort(); settingsController.current?.abort() } + return () => { alive.current = false; reader.current?.abort(); settingsController.current?.abort(); operationController.current?.abort(); downloads.forEach(controller => controller.abort()) } }, [loadLive]) const job = useBackupJob(backendUrl, isLive, canRead, baseCanCreate && !opBusy && !scheduleSaveBusy, loadLive) @@ -171,7 +176,7 @@ function BackupsContent() { const canCreate = baseCanCreate && job.hydrated && !checkpoint && !job.storageError function startSettingsMutation() { - if (!alive.current || settingsLock.current || opBusy || backupJobId) return null + if (!alive.current || settingsLock.current || operationLock.current || backupJobId) return null settingsLock.current = true const abort = new AbortController() settingsController.current = abort @@ -261,46 +266,55 @@ function BackupsContent() { if (opError) toast.error(opError) }, [opError]) + function startOperation() { + if (!canWrite || !alive.current || operationLock.current || settingsLock.current || backupJobId) return null + operationLock.current = true + const abort = new AbortController(); operationController.current = abort + reader.current?.abort(); revision.current += 1; setLoading(false) + setOpBusy(true); setOpError(null) + return abort + } + function finishOperation(abort: AbortController) { + if (operationController.current !== abort) return + operationLock.current = false; operationController.current = null + if (alive.current) setOpBusy(false) + } async function handleDelete() { - if (!canWrite || !deleteTarget) return - setOpBusy(true) - setOpError(null) + if (!deleteTarget) return + const target = deleteTarget, abort = startOperation(); if (!abort) return try { - await deleteBackup(backendUrl, deleteTarget.id) - setDeleteTarget(null) + await deleteBackup(backendUrl, target.id, { signal: abort.signal }) + if (!alive.current || abort.signal.aborted) return + setBackupList(previous => previous.filter(backup => backup.id !== target.id)); setDeleteTarget(null) + toast.success("API подтвердил удаление резервной копии") await loadLive() - } catch (e) { - setOpError(e instanceof Error ? e.message : "Ошибка удаления бэкапа") - } finally { - setOpBusy(false) - } + } catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) } + finally { finishOperation(abort) } } - async function handleRestore() { - if (!canWrite || !restoreTarget) return - setOpBusy(true) - setOpError(null) + if (!restoreTarget || !restoreTarget.serverId) return + const target = restoreTarget, abort = startOperation(); if (!abort) return try { - await restoreBackup(backendUrl, restoreTarget.id) - toast.success(`Конфигурация восстановлена на ${restoreTarget.server}`) + const result = await restoreBackup(backendUrl, target.id, { signal: abort.signal }) + if (!alive.current || abort.signal.aborted) return + if (result.filename !== target.filename) throw new Error("Ответ восстановления относится к другому файлу") + toast.success("API подтвердил импорт " + result.filename + " на " + result.serverName) setRestoreTarget(null) - } catch (e) { - setOpError(e instanceof Error ? e.message : "Ошибка восстановления") - } finally { - setOpBusy(false) - } + } catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) } + finally { finishOperation(abort) } } - - async function handleDownload(id: string, fallbackFilename: string) { - if (!isLive || !canRead) return - const res = await requestBlob(backendUrl, `/api/backups/${id}/download`) - const blob = await res.blob() - const url = URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - a.download = fallbackFilename - a.click() - URL.revokeObjectURL(url) + async function handleDownload(id: string, filename: string) { + if (!isLive || !canRead || !alive.current || downloadControllers.current.has(id)) return + const abort = new AbortController(); downloadControllers.current.set(id, abort) + try { + const blob = await downloadBackup(backendUrl, id, { signal: abort.signal }) + if (!alive.current || abort.signal.aborted) return + const url = URL.createObjectURL(blob) + try { + const anchor = document.createElement("a"); anchor.href = url; anchor.download = filename; anchor.click() + } finally { URL.revokeObjectURL(url) } + } catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) } + finally { if (downloadControllers.current.get(id) === abort) downloadControllers.current.delete(id) } } const autoCount = backupList.filter((b) => b.kind === "auto").length @@ -457,11 +471,11 @@ function BackupsContent() { filters={advancedFilters} onFiltersChange={setAdvancedFilters} onDownload={handleDownload} - onRestore={setRestoreTarget} - onDelete={setDeleteTarget} + onRestore={backup => { setOpError(null); setRestoreTarget(backup) }} + onDelete={backup => { setOpError(null); setDeleteTarget(backup) }} onCreate={() => openCreate(false)} canCreate={canCreate} - canWrite={canWrite} + canWrite={canWrite && !opBusy && !scheduleSaveBusy && !backupJobId} canDownload={isLive && canRead} />} @@ -541,12 +555,14 @@ function BackupsContent() { void handleDelete()} onCancel={() => setDeleteTarget(null)} /> void handleRestore()} onCancel={() => setRestoreTarget(null)} /> diff --git a/components/backups/backup-confirm-dialogs.tsx b/components/backups/backup-confirm-dialogs.tsx index 61777785..92efc451 100644 --- a/components/backups/backup-confirm-dialogs.tsx +++ b/components/backups/backup-confirm-dialogs.tsx @@ -18,11 +18,13 @@ import { AlertCircleIcon, LoaderCircleIcon, TriangleAlertIcon } from "lucide-rea export function BackupDeleteDialog({ backup, busy, + error, onConfirm, onCancel, }: { backup: Backup | null busy: boolean + error?: string | null onConfirm: () => void onCancel: () => void }) { @@ -39,6 +41,7 @@ export function BackupDeleteDialog({ {backup?.storage === "s3" || backup?.storage === "both" ? " локально и из S3" : " с диска"}. + {error &&

{error}

} Отмена @@ -54,11 +57,13 @@ export function BackupDeleteDialog({ export function BackupRestoreDialog({ backup, busy, + error, onConfirm, onCancel, }: { backup: Backup | null busy: boolean + error?: string | null onConfirm: () => void onCancel: () => void }) { @@ -70,20 +75,21 @@ export function BackupRestoreDialog({ Восстановить конфигурацию? - + Файл {backup?.filename} будет загружен на {backup?.server} и импортирован. - - - Это изменит рабочую конфигурацию роутера - - Сессия может оборваться. Убедитесь, что выбран именно этот сервер и этот снимок. - - + + + Это изменит рабочую конфигурацию роутера + + Сессия может оборваться. Убедитесь, что выбран именно этот сервер и этот снимок. + + + {error &&

{error}

} Отмена diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 77a98950..02c9ef47 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -226,4 +226,4 @@ REST запрос → ожидание → подтверждённый output / | Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) | | Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) | -Актуальная точка продолжения: 11А.29 — delete/restore/download резервных копий, затем URL/Frame/визуальная приёмка и scheduler cleanup. Frontend checkpoint/создание/serial polling приняты локально в 11А.28.2. API/основа наблюдения реализованы в 11А.28.1. Настройки расписания/S3 реализованы в 11А.27.1–11А.27.2. Каталоги/source/права реализованы в 11А.25, backend результат ручных операций исправлен в 11А.26. Сертификаты приняты локально в 11А.24, backups API стал строгим. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Резервные копии и весь 11А ещё не приняты. +Актуальная точка продолжения: 11А.29.2 — backend cleanup удаления резервных копий, затем restore identity/неизвестный результат. Frontend delete/restore/download принят в 11А.29.1, затем URL/Frame/визуальная приёмка и scheduler cleanup. Frontend checkpoint/создание/serial polling приняты локально в 11А.28.2. API/основа наблюдения реализованы в 11А.28.1. Настройки расписания/S3 реализованы в 11А.27.1–11А.27.2. Каталоги/source/права реализованы в 11А.25, backend результат ручных операций исправлен в 11А.26. Сертификаты приняты локально в 11А.24, backups API стал строгим. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Резервные копии и весь 11А ещё не приняты. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 30ad3f67..a1596827 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -27,7 +27,7 @@ | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | -| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.28.2; пользователи/контейнеры/сертификаты приняты локально; резервные копии: настройки/создание/наблюдение, далее 11А.29 | +| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.29.1; пользователи/контейнеры/сертификаты приняты локально; резервные копии: операции frontend, далее 11А.29.2 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,15 @@ +## Блок 11А.29.1 — подтверждение операций и отмена старого контекста (2026-10-01) + +- Delete/restore используют синхронный общий lock и immutable цель. Ошибка API сохраняет диалог/файл и показывается внутри него; Escape/отмена заблокированы во время запроса. Только DELETE 204 удаляет подтверждённую строку и закрывает диалог. Ошибка отдельного refresh не отменяет подтверждение удаления. +- Restore сверяет filename и говорит об API-подтверждении импорта, а не о проверенной рабочей конфигурации. Полная идентичность serverId/backupId и неизвестный результат импорта требуют следующего backend/API блока. +- Download имеет single-flight по файлу, caller AbortSignal/срок чтения и проверку MIME. HTML/JSON 200 не сохраняются как бэкап; object URL освобождается. Unmount/source отменяют все три операции и подавляют поздние toast/скачивание/state. +- Предупреждение restore вынесено из HTML-абзаца, устранена ошибка вложенности/hydration. +- 9 API unit, 6 operations E2E (повтор после исправления HTML), все 28 context E2E, TypeScript, профильный ESLint и production frontend прошли. Реальные RouterOS/PostgreSQL/S3 не проверены. +- **Следующий блок 11А.29.2:** backend cleanup удаления: не игнорировать S3 ошибки, удалять запись после файлов, сохранить возможность повторить частичную очистку. Исходные bucket/endpoint в записи не хранятся — историческое происхождение S3 остаётся ограничением. Затем restore identity/неизвестный результат, URL/Frame/адаптивность и scheduler/приёмка. + ## Блок 11А.28.2 — сохраняемая задача создания резервных копий (2026-10-01) - До POST сохраняется immutable requestId/serverIds/заметка в sessionStorage по backend URL. Первичная ошибка записи не отправляет запрос. Форма блокирует изменение цели после передачи; закрытие не объявляется отменой server job. При подтверждённом 4xx создания черновик остаётся доступным для исправления. diff --git a/docs/ux-ui-stage-11a.md b/docs/ux-ui-stage-11a.md index c8daab6b..1e7cb758 100644 --- a/docs/ux-ui-stage-11a.md +++ b/docs/ux-ui-stage-11a.md @@ -363,3 +363,13 @@ - Late POST/poll/задержки отменяются при unmount и не меняют другой backend/mode, не показывают старый toast. Sheet адаптирован full width через data-side вариант; footer переносится, цель и job ID сохраняются. - 9 job E2E + 28 data/settings/storage регрессии, 16 context E2E, TypeScript/профильный ESLint/production frontend прошли; helper/backend проверки выполнены в 11А.28.1. Реальные PostgreSQL/RouterOS/S3/рестарт не проверены; durable job repository отсутствует. - **Следующий блок 11А.29:** delete/restore/download: synchronous locks, контекст/отмена, точное подтверждение цели и отделение API результата от refresh; backend partial cleanup и правила восстановления проверить. Затем URL, Frame/адаптивность, scheduler cleanup и приёмка backups. + +## Блок 11А.29.1 — подтверждение операций и отмена старого контекста (2026-10-01) + +- Delete/restore используют синхронный общий lock и immutable цель. Ошибка API сохраняет диалог/файл и показывается внутри него; Escape/отмена заблокированы во время запроса. Только DELETE 204 удаляет подтверждённую строку и закрывает диалог. Ошибка отдельного refresh не отменяет подтверждение удаления. +- Restore сверяет filename и говорит об API-подтверждении импорта, а не о проверенной рабочей конфигурации. Полная идентичность serverId/backupId и неизвестный результат импорта требуют следующего backend/API блока. +- Download имеет single-flight по файлу, caller AbortSignal/срок чтения и проверку MIME. HTML/JSON 200 не сохраняются как бэкап; object URL освобождается. Unmount/source отменяют все три операции и подавляют поздние toast/скачивание/state. +- Предупреждение restore вынесено из HTML-абзаца, устранена ошибка вложенности/hydration. +- 9 API unit, 6 operations E2E (повтор после исправления HTML), все 28 context E2E, TypeScript, профильный ESLint и production frontend прошли. Реальные RouterOS/PostgreSQL/S3 не проверены. +- **Следующий блок 11А.29.2:** backend cleanup удаления: не игнорировать S3 ошибки, удалять запись после файлов, сохранить возможность повторить частичную очистку. Исходные bucket/endpoint в записи не хранятся — историческое происхождение S3 остаётся ограничением. Затем restore identity/неизвестный результат, URL/Frame/адаптивность и scheduler/приёмка. + diff --git a/shared/api/backups.test.ts b/shared/api/backups.test.ts index 0b076229..91ea6283 100644 --- a/shared/api/backups.test.ts +++ b/shared/api/backups.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict" import { test } from "node:test" -import { listBackups, createBackupsAsync, getBackupJob, deleteBackup, restoreBackup, getBackupScheduleSettings, putBackupScheduleSettings, getBackupStorageSettings, putBackupStorageSettings, testBackupStorage, syncBackupsFromStorage } from "./backups" +import { listBackups, downloadBackup, createBackupsAsync, getBackupJob, deleteBackup, restoreBackup, getBackupScheduleSettings, putBackupScheduleSettings, getBackupStorageSettings, putBackupStorageSettings, testBackupStorage, syncBackupsFromStorage } from "./backups" const base = "https://api.example.test" const item = { id: "backup-1", serverId: 1, serverName: "router", filename: "config.rsc", sizeBytes: 0, createdAt: "2026-10-01T00:00:00Z", kind: "manual", storage: "local" } @@ -96,3 +96,12 @@ test("backup request ID is sent and a different acknowledgment is rejected", asy globalThis.fetch = async (_url, init) => { body = JSON.parse(String(init?.body)); return json({ jobId: "other", serverIds: ["1"], status: "queued", total: 1, completed: 0 }, 202) } try { await assert.rejects(() => createBackupsAsync(base, { requestId, serverIds: ["1"] }), /другому запросу/); assert.equal(body.requestId, requestId) } finally { globalThis.fetch = original } }) + +test("download encodes identity and refuses HTML/JSON success bodies", async () => { + const original = globalThis.fetch; let url = "" + try { + for (const type of ["text/html", "application/json", ""]) { globalThis.fetch = async input => { url = String(input); return new Response("wrong body", { headers: { "Content-Type": type } }) }; await assert.rejects(() => downloadBackup(base, "a/b"), /не подтверждён/) } + assert.ok(url.endsWith("a%2Fb/download")) + globalThis.fetch = async () => new Response("config", { headers: { "Content-Type": "text/plain; charset=utf-8" } }); assert.equal(await (await downloadBackup(base, "backup-1")).text(), "config") + } finally { globalThis.fetch = original } +}) diff --git a/shared/api/backups.ts b/shared/api/backups.ts index d06f9570..cd6bd837 100644 --- a/shared/api/backups.ts +++ b/shared/api/backups.ts @@ -77,3 +77,10 @@ export async function testBackupStorage(baseUrl: string, options?: RequestInit) export async function syncBackupsFromStorage(baseUrl: string, options?: RequestInit) { return syncBackupsResponseSchema.parse(await backupJson(baseUrl, "/api/backups/storage/sync", { ...options, method: "POST" })) } + +export async function downloadBackup(baseUrl: string, id: string, options?: RequestInit) { + const response = await requestBlob(baseUrl, "/api/backups/" + encodeURIComponent(id) + "/download", backupOptions({ ...options, method: "GET" })) + const type = response.headers.get("Content-Type")?.split(";")[0].trim().toLowerCase() + if (type !== "text/plain" && type !== "application/octet-stream") throw new Error("Файл резервной копии не подтверждён API") + return response.blob() +} diff --git a/tests/e2e/backups-context.spec.ts b/tests/e2e/backups-context.spec.ts index 385bf3bf..8ac2ac7a 100644 --- a/tests/e2e/backups-context.spec.ts +++ b/tests/e2e/backups-context.spec.ts @@ -63,3 +63,32 @@ for (const phase of ["POST", "poll"]) for (const change of ["backend", "mode"]) await expect(page.getByText("API подтвердил создание всех резервных копий", { exact: true })).toHaveCount(0) await expect(page.getByRole("button", { name: "Задача резервного копирования", exact: true })).toHaveCount(0) }) + +for (const operation of ["delete", "restore", "download"]) for (const change of ["backend", "mode"]) for (const fails of [false, true]) test("Бэкапы: поздняя операция/" + operation + "/" + change + "/" + fails, async ({ page }) => { + await setupBackups(page, headers) + let started = false, completed = false, downloads = 0, release!: () => void; const gate = new Promise(r => release = r) + page.on("download", () => downloads++) + const suffix = operation === "delete" ? "" : "/" + operation + await page.route("**/api/backups/backup-1" + suffix, async r => { + started = true; await gate + try { + if (fails) await r.fulfill({ headers, status: 503, json: { error: "СТАРАЯ ОПЕРАЦИЯ ОТКАЗАЛА" } }) + else if (operation === "delete") await r.fulfill({ headers, status: 204 }) + else if (operation === "restore") await r.fulfill({ headers, json: { filename: backup.filename, serverName: "СТАРЫЙ РОУТЕР" } }) + else await r.fulfill({ headers, contentType: "text/plain", body: "old config" }) + } catch {} finally { completed = true } + }) + await page.goto("/backups") + const label = operation === "delete" ? "Удалить" : operation === "restore" ? "Восстановить" : "Скачать" + await page.getByRole("button", { name: label + " " + backup.filename, exact: true }).click() + if (operation !== "download") await page.getByRole("alertdialog").getByRole("button", { name: label, exact: true }).click() + await expect.poll(() => started).toBe(true); await page.goto("/settings") + if (change === "backend") { const input = page.getByPlaceholder("http://localhost:8000"); await input.fill("http://localhost:8101"); await input.press("Enter") } + else await page.getByRole("button", { name: "Моковые", exact: true }).click() + await page.goto("/backups"); release(); await expect.poll(() => completed).toBe(true) + if (change === "backend") await expect(page.getByText(backup.filename, { exact: true })).toBeVisible() + else await expect(page.getByText("Демонстрационные данные. Операции и сохранение недоступны.", { exact: true })).toBeVisible() + await expect(page.getByText(/СТАРАЯ ОПЕРАЦИЯ ОТКАЗАЛА|СТАРЫЙ РОУТЕР/)).toHaveCount(0) + await expect(page.getByText("API подтвердил удаление резервной копии", { exact: true })).toHaveCount(0) + await expect(page.getByRole("alertdialog")).toHaveCount(0); expect(downloads).toBe(0) +}) diff --git a/tests/e2e/backups-operations.spec.ts b/tests/e2e/backups-operations.spec.ts new file mode 100644 index 00000000..42035166 --- /dev/null +++ b/tests/e2e/backups-operations.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from "@playwright/test" +import { backup, setupBackups } from "./fixtures/backups" + +test("Бэкапы: single-flight удаление, отказ refresh не отменяет 204", async ({ page }) => { + await setupBackups(page); let deletes = 0, deleted = false, release!: () => void; const gate = new Promise(r => release = r) + await page.route("**/api/backups", r => r.fulfill(deleted ? { status: 503, json: { error: "История после удаления отказала" } } : { json: [backup] })) + await page.route("**/api/backups/backup-1", async r => { deletes++; await gate; deleted = true; await r.fulfill({ status: 204 }).catch(() => {}) }) + await page.goto("/backups"); await page.getByRole("button", { name: "Удалить " + backup.filename, exact: true }).click() + const dialog = page.getByRole("alertdialog"); await dialog.getByRole("button", { name: "Удалить", exact: true }).dblclick() + await expect(dialog.getByRole("button", { name: "Отмена", exact: true })).toBeDisabled(); await page.keyboard.press("Escape"); await expect(dialog).toBeVisible() + release(); await expect(dialog).toBeHidden(); await expect(page.getByText(backup.filename, { exact: true })).toHaveCount(0) + await expect(page.getByText("API подтвердил удаление резервной копии", { exact: true })).toBeVisible(); expect(deletes).toBe(1) + await expect(page.getByRole("alert", { name: "Данные резервных копий: backups" })).toContainText("История после удаления отказала") +}) +for (const status of [403, 503, 200]) test("Бэкапы: удаление " + status + " сохраняет цель и диалог", async ({ page }) => { + await setupBackups(page); await page.route("**/api/backups/backup-1", r => r.fulfill({ status, json: { error: "Удаление не подтверждено" } })) + await page.goto("/backups"); await page.getByRole("button", { name: "Удалить " + backup.filename, exact: true }).click() + const dialog = page.getByRole("alertdialog"); await dialog.getByRole("button", { name: "Удалить", exact: true }).click() + await expect(dialog.getByRole("alert")).toBeVisible(); await expect(dialog).toContainText(backup.filename) + await expect(dialog.getByRole("button", { name: "Удалить", exact: true })).toBeEnabled() + await expect(page.getByText("API подтвердил удаление резервной копии", { exact: true })).toHaveCount(0) +}) +test("Бэкапы: ошибка restore и чужой filename сохраняют диалог", async ({ page }) => { + await setupBackups(page); let failure = true + await page.route("**/api/backups/backup-1/restore", r => r.fulfill(failure ? { status: 503, json: { error: "Импорт отказал" } } : { json: { filename: "other.rsc", serverName: "router" } })) + await page.goto("/backups"); await page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true }).click() + const dialog = page.getByRole("alertdialog"); await dialog.getByRole("button", { name: "Восстановить", exact: true }).click() + await expect(dialog.getByRole("alert").filter({ hasText: "Импорт отказал" })).toBeVisible() + failure = false; await dialog.getByRole("button", { name: "Восстановить", exact: true }).click() + await expect(dialog.getByRole("alert").filter({ hasText: "другому файлу" })).toBeVisible() + await expect(dialog).toContainText(backup.filename) +}) +test("Бэкапы: download получает файл, HTML 200 показывает ошибку", async ({ page }) => { + await setupBackups(page); let html = false + await page.route("**/api/backups/backup-1/download", r => r.fulfill({ contentType: html ? "text/html" : "text/plain; charset=utf-8", body: html ? "Error" : "config" })) + await page.goto("/backups"); const button = page.getByRole("button", { name: "Скачать " + backup.filename, exact: true }) + const download = page.waitForEvent("download"); await button.click(); expect((await download).suggestedFilename()).toBe(backup.filename) + html = true; await button.click(); await expect(page.getByRole("alert", { name: "Операция резервных копий" })).toContainText("не подтверждён API") +})