fix(backups): подтверждать операции в текущем контексте
This commit is contained in:
1 parent
714af7e872
commit
4c5604bf87
9 files changed
+174
-49
No files matched your search
+54
-38
@@ -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<AbortController | null>(null)
|
||||
const downloadControllers = useRef(new Map<string, AbortController>())
|
||||
const settingsLock = useRef(false)
|
||||
const settingsController = useRef<AbortController | null>(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}
|
||||
/>}
|
||||
</TabsContent>
|
||||
@@ -541,12 +555,14 @@ function BackupsContent() {
|
||||
<BackupDeleteDialog
|
||||
backup={deleteTarget}
|
||||
busy={opBusy}
|
||||
error={opError}
|
||||
onConfirm={() => void handleDelete()}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
<BackupRestoreDialog
|
||||
backup={restoreTarget}
|
||||
busy={opBusy}
|
||||
error={opError}
|
||||
onConfirm={() => void handleRestore()}
|
||||
onCancel={() => setRestoreTarget(null)}
|
||||
/>
|
||||
|
||||
@@ -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" : " с диска"}.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{error && <p role="alert" className="text-sm text-destructive break-words">{error}</p>}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={busy} onClick={onCancel}>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" disabled={busy} onClick={onConfirm}>
|
||||
@@ -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({
|
||||
<TriangleAlertIcon />
|
||||
</AlertDialogMedia>
|
||||
<AlertDialogTitle>Восстановить конфигурацию?</AlertDialogTitle>
|
||||
<AlertDialogDescription className="flex flex-col gap-3">
|
||||
<AlertDialogDescription>
|
||||
<span>
|
||||
Файл <span className="font-mono text-foreground">{backup?.filename}</span> будет загружен
|
||||
на <span className="text-foreground">{backup?.server}</span> и импортирован.
|
||||
</span>
|
||||
<Alert variant="warning">
|
||||
<TriangleAlertIcon />
|
||||
<AlertTitle>Это изменит рабочую конфигурацию роутера</AlertTitle>
|
||||
<AlertDescription>
|
||||
Сессия может оборваться. Убедитесь, что выбран именно этот сервер и этот снимок.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</AlertDialogDescription>
|
||||
<Alert variant="warning">
|
||||
<TriangleAlertIcon />
|
||||
<AlertTitle>Это изменит рабочую конфигурацию роутера</AlertTitle>
|
||||
<AlertDescription>
|
||||
Сессия может оборваться. Убедитесь, что выбран именно этот сервер и этот снимок.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</AlertDialogHeader>
|
||||
{error && <p role="alert" className="text-sm text-destructive break-words">{error}</p>}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={busy} onClick={onCancel}>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction disabled={busy} onClick={onConfirm}>
|
||||
|
||||
+1
-1
@@ -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А ещё не приняты.
|
||||
+10
-1
@@ -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 создания черновик остаётся доступным для исправления.
|
||||
|
||||
@@ -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/приёмка.
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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<void>(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)
|
||||
})
|
||||
@@ -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<void>(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 ? "<html>Error</html>" : "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")
|
||||
})
|
||||
Reference in new issue
Block a user