fix(backups): сохранять неизвестный результат восстановления
This commit is contained in:
1 parent
39570656c9
commit
6f152c8dfe
11 files changed
+159
-11
No files matched your search
@@ -37,6 +37,9 @@ import {
|
||||
} from "@/shared/api/backups"
|
||||
import { ApiClientError, requestJson } from "@/shared/api/http-client"
|
||||
import { toast } from "sonner"
|
||||
import { useBackupRestoreGuard } from "@/lib/use-backup-restore-guard"
|
||||
import { restoreWasNotApplied } from "@/lib/backup-restore-checkpoint"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { useBackupJob } from "@/lib/use-backup-job"
|
||||
import { backupJobOutcome } from "@/lib/backup-job-poll"
|
||||
import { backupSchedulePayload, backupStoragePayload } from "@/lib/backup-settings"
|
||||
@@ -91,6 +94,8 @@ function BackupsContent() {
|
||||
const [liveServers, setLiveServers] = useState<Server[]>(isLive ? [] : mockServers)
|
||||
const baseCanCreate = canWrite && canReadServers && liveServers.length > 0 && current("servers") && current("storage")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const restoreGuard = useBackupRestoreGuard(backendUrl, isLive)
|
||||
const [restoreReviewed, setRestoreReviewed] = useState(false)
|
||||
const [opBusy, setOpBusy] = useState(false)
|
||||
const [opError, setOpError] = useState<string | null>(null)
|
||||
const [scheduleSaveBusy, setScheduleSaveBusy] = useState(false)
|
||||
@@ -292,15 +297,23 @@ function BackupsContent() {
|
||||
finally { finishOperation(abort) }
|
||||
}
|
||||
async function handleRestore() {
|
||||
if (!restoreTarget || !restoreTarget.serverId) return
|
||||
if (!restoreTarget || !restoreTarget.serverId || !restoreGuard.canStart) return
|
||||
const target = restoreTarget, abort = startOperation(); if (!abort) return
|
||||
let sent = false
|
||||
try {
|
||||
if (!restoreGuard.begin({ backupId: target.id, serverId: Number(target.serverId), filename: target.filename, serverName: target.server })) return
|
||||
sent = true
|
||||
const result = await restoreBackup(backendUrl, target.id, { signal: abort.signal }, Number(target.serverId))
|
||||
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 (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
|
||||
restoreGuard.clear(); setRestoreTarget(null)
|
||||
} catch (error) {
|
||||
if (alive.current && !abort.signal.aborted) {
|
||||
if (sent && restoreWasNotApplied(error)) restoreGuard.clear()
|
||||
setOpError(userApiError(error) + (sent && !restoreWasNotApplied(error) ? ". Результат импорта неизвестен; проверьте роутер перед повтором" : ""))
|
||||
}
|
||||
}
|
||||
finally { finishOperation(abort) }
|
||||
}
|
||||
async function handleDownload(id: string, filename: string) {
|
||||
@@ -399,6 +412,14 @@ function BackupsContent() {
|
||||
<AlertDescription>{({ backups: "История", servers: "Серверы", schedule: "Расписание", storage: "Хранилище" })[resource]}: {resources[resource].error}</AlertDescription>
|
||||
</Alert>)}
|
||||
|
||||
{isLive && (restoreGuard.checkpoint || restoreGuard.error) && <Alert variant="warning" aria-label="Подтверждение восстановления">
|
||||
<AlertTitle>{opBusy && restoreTarget ? "Импорт ожидает подтверждения" : "Проверьте результат восстановления"}</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-3">
|
||||
<span>{restoreGuard.error || (restoreGuard.checkpoint ? restoreGuard.checkpoint.filename + " → " + restoreGuard.checkpoint.serverName + ". Ответ ещё не подтверждён. Закрытие страницы не отменяет импорт." : "")}</span>
|
||||
<label className="flex items-start gap-2"><Checkbox checked={restoreReviewed} disabled={opBusy || !canWrite} onCheckedChange={value => setRestoreReviewed(value === true)} />Я проверил состояние роутера и понимаю последствия повторного импорта</label>
|
||||
<Button type="button" variant="outline" disabled={!restoreReviewed || opBusy || !canWrite} onClick={() => { if (restoreGuard.clear()) { setRestoreReviewed(false); setOpError(null) } }}>Разрешить новое восстановление</Button>
|
||||
</AlertDescription>
|
||||
</Alert>}
|
||||
{opError && <Alert variant="warning" aria-label="Операция резервных копий"><AlertTitle>Операция не подтверждена</AlertTitle><AlertDescription>{opError}</AlertDescription></Alert>}
|
||||
{(job.error || job.storageError) && <Alert variant="warning" aria-label="Задача резервного копирования"><AlertTitle>{jobTerminal ? "Задача завершена с ограничениями" : "Результат задачи пока неизвестен"}</AlertTitle><AlertDescription>{job.error || job.storageError}</AlertDescription></Alert>}
|
||||
<KpiStatGrid
|
||||
@@ -476,6 +497,7 @@ function BackupsContent() {
|
||||
onCreate={() => openCreate(false)}
|
||||
canCreate={canCreate}
|
||||
canWrite={canWrite && !opBusy && !scheduleSaveBusy && !backupJobId}
|
||||
canRestore={restoreGuard.canStart}
|
||||
canDownload={isLive && canRead}
|
||||
/>}
|
||||
</TabsContent>
|
||||
@@ -562,6 +584,7 @@ function BackupsContent() {
|
||||
<BackupRestoreDialog
|
||||
backup={restoreTarget}
|
||||
busy={opBusy}
|
||||
blocked={!restoreGuard.canStart}
|
||||
error={opError}
|
||||
onConfirm={() => void handleRestore()}
|
||||
onCancel={() => setRestoreTarget(null)}
|
||||
|
||||
@@ -57,12 +57,14 @@ export function BackupDeleteDialog({
|
||||
export function BackupRestoreDialog({
|
||||
backup,
|
||||
busy,
|
||||
blocked = false,
|
||||
error,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
backup: Backup | null
|
||||
busy: boolean
|
||||
blocked?: boolean
|
||||
error?: string | null
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
@@ -91,8 +93,8 @@ export function BackupRestoreDialog({
|
||||
</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}>
|
||||
<AlertDialogCancel disabled={busy} onClick={onCancel}>{blocked && !busy ? "Закрыть" : "Отмена"}</AlertDialogCancel>
|
||||
<AlertDialogAction disabled={busy || blocked} onClick={onConfirm}>
|
||||
{busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
|
||||
Восстановить
|
||||
</AlertDialogAction>
|
||||
|
||||
@@ -29,6 +29,7 @@ export function BackupsHistory({
|
||||
onDelete,
|
||||
onCreate,
|
||||
canCreate = true,
|
||||
canRestore = true,
|
||||
canWrite = true,
|
||||
canDownload = true,
|
||||
}: {
|
||||
@@ -43,6 +44,7 @@ export function BackupsHistory({
|
||||
onRestore: (backup: Backup) => void
|
||||
onDelete: (backup: Backup) => void
|
||||
canCreate?: boolean
|
||||
canRestore?: boolean
|
||||
canWrite?: boolean
|
||||
canDownload?: boolean
|
||||
onCreate: () => void
|
||||
@@ -82,6 +84,7 @@ export function BackupsHistory({
|
||||
onDownload={onDownload}
|
||||
onRestore={onRestore}
|
||||
onDelete={onDelete}
|
||||
canRestore={canRestore}
|
||||
canWrite={canWrite}
|
||||
canDownload={canDownload}
|
||||
emptyTitle={isEmptyAll ? "Нет бэкапов" : "Ничего не найдено"}
|
||||
|
||||
@@ -32,6 +32,7 @@ interface BackupsDataGridProps {
|
||||
onDownload: (id: string, filename: string) => void
|
||||
onRestore: (backup: Backup) => void
|
||||
onDelete: (backup: Backup) => void
|
||||
canRestore?: boolean
|
||||
canWrite?: boolean
|
||||
canDownload?: boolean
|
||||
emptyAction?: ReactNode
|
||||
@@ -50,6 +51,7 @@ function BackupsDataGrid({
|
||||
onDownload,
|
||||
onRestore,
|
||||
onDelete,
|
||||
canRestore = true,
|
||||
canWrite = true,
|
||||
canDownload = true,
|
||||
emptyAction,
|
||||
@@ -207,7 +209,7 @@ function BackupsDataGrid({
|
||||
className="size-7"
|
||||
title="Восстановить"
|
||||
aria-label={`Восстановить ${b.filename}`}
|
||||
disabled={!canWrite || !b.serverId}
|
||||
disabled={!canWrite || !canRestore || !b.serverId}
|
||||
onClick={() => onRestore(b)}
|
||||
>
|
||||
<RefreshCwIcon className="size-3.5" />
|
||||
@@ -235,7 +237,7 @@ function BackupsDataGrid({
|
||||
},
|
||||
},
|
||||
],
|
||||
[onDelete, onDownload, onRestore, canWrite, canDownload],
|
||||
[onDelete, onDownload, onRestore, canWrite, canRestore, canDownload],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
|
||||
+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.5 — клиентский checkpoint restore и повтор после проверки роутера. Backend outcome неизвестного импорта принят в 11А.29.4. Идентичность receipt принята в 11А.29.3. Backend cleanup удаления принят в 11А.29.2. 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А ещё не приняты.
|
||||
Актуальная точка продолжения: 11А.30 — URL/Filters контекст резервных копий. Клиентский checkpoint restore принят в 11А.29.5. Backend outcome неизвестного импорта принят в 11А.29.4. Идентичность receipt принята в 11А.29.3. Backend cleanup удаления принят в 11А.29.2. 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А.29.4; пользователи/контейнеры/сертификаты приняты локально; резервные копии: неизвестный outcome backend, далее 11А.29.5 |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.29.5; пользователи/контейнеры/сертификаты приняты локально; резервные копии: checkpoint restore, далее 11А.30 |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
@@ -36,6 +36,15 @@
|
||||
|
||||
|
||||
|
||||
## Блок 11А.29.5 — отметка неизвестного restore до POST (2026-10-01)
|
||||
|
||||
- До POST записывается immutable backupId/serverId/filename/имя/время в sessionStorage по backend URL. Отказ записи не отправляет импорт. Синхронный guard и общий operation lock защищают повтор; hydration/corrupt checkpoint не допускают начало.
|
||||
- Обрыв, deadline, чужой receipt, backend unknown и legacy 400 без outcome сохраняют отметку. Reload/возврат не запускает POST и блокирует restore. Известный 400 not-applied/403/404/405 или подтверждённый receipt очищает отметку. Отказ очистки сохраняет блокировку, но не отменяет toast подтверждённого импорта.
|
||||
- Новый импорт требует явного checkbox «Я проверил состояние роутера…» и отдельного действия «Разрешить новое восстановление». Оно только снимает отметку, не отправляет запрос и не объявляет конфигурацию проверенной приложением. Read-only/занятость запрещают снятие; после unknown диалог закрывается кнопкой «Закрыть», это не отмена server operation.
|
||||
- 2 checkpoint/classification unit, 12 operations E2E, все 28 context E2E, TypeScript/профильный ESLint и production frontend прошли. Тесты уточнены: review выполняется после закрытия modal, checkpoint same-origin привязан к location.origin.
|
||||
- Durable backend restore repository отсутствует: отметка действует в текущей вкладке/sessionStorage, не синхронизирует разные браузеры/вкладки и не доказывает результат RouterOS. Реальные RouterOS/PostgreSQL/S3 не проверены; ручная проверка устройства остаётся внешним действием.
|
||||
- **Следующий блок 11А.30:** URL контекст истории/табов/Filters, затем Frame и визуальная адаптивная приёмка, unknown статус устройств, scheduler cleanup и локальная приёмка backups.
|
||||
|
||||
## Блок 11А.29.4 — неизвестный результат импорта на backend (2026-10-01)
|
||||
|
||||
- Import fallback /execute разрешён только при RouterOS HTTP 404/405 для /import. Таймаут/обрыв, невалидный JSON, 400/401/500 не запускают второй импорт. После отправки команды любая ошибка маркируется BackupRestoreUnknownError; HTTP 502/outcome unknown не объявляет конфигурацию неизменённой. Ошибка подготовки до импорта остаётся 400/outcome not-applied.
|
||||
|
||||
@@ -399,3 +399,13 @@
|
||||
- Реальный RouterOS не проверен; /execute receipt подтверждает ответ команды, не завершение всех изменений конфигурации. Backend durable restore/idempotency repository пока отсутствует; клиентский запрет слепого повтора нужен следующим блоком.
|
||||
- **Следующий блок 11А.29.5:** checkpoint restore до POST, неизвестный результат/после reload и явная проверка роутера перед новым импортом; затем URL/Frame/адаптивность, scheduler и приёмка backups.
|
||||
|
||||
|
||||
## Блок 11А.29.5 — отметка неизвестного restore до POST (2026-10-01)
|
||||
|
||||
- До POST записывается immutable backupId/serverId/filename/имя/время в sessionStorage по backend URL. Отказ записи не отправляет импорт. Синхронный guard и общий operation lock защищают повтор; hydration/corrupt checkpoint не допускают начало.
|
||||
- Обрыв, deadline, чужой receipt, backend unknown и legacy 400 без outcome сохраняют отметку. Reload/возврат не запускает POST и блокирует restore. Известный 400 not-applied/403/404/405 или подтверждённый receipt очищает отметку. Отказ очистки сохраняет блокировку, но не отменяет toast подтверждённого импорта.
|
||||
- Новый импорт требует явного checkbox «Я проверил состояние роутера…» и отдельного действия «Разрешить новое восстановление». Оно только снимает отметку, не отправляет запрос и не объявляет конфигурацию проверенной приложением. Read-only/занятость запрещают снятие; после unknown диалог закрывается кнопкой «Закрыть», это не отмена server operation.
|
||||
- 2 checkpoint/classification unit, 12 operations E2E, все 28 context E2E, TypeScript/профильный ESLint и production frontend прошли. Тесты уточнены: review выполняется после закрытия modal, checkpoint same-origin привязан к location.origin.
|
||||
- Durable backend restore repository отсутствует: отметка действует в текущей вкладке/sessionStorage, не синхронизирует разные браузеры/вкладки и не доказывает результат RouterOS. Реальные RouterOS/PostgreSQL/S3 не проверены; ручная проверка устройства остаётся внешним действием.
|
||||
- **Следующий блок 11А.30:** URL контекст истории/табов/Filters, затем Frame и визуальная адаптивная приёмка, unknown статус устройств, scheduler cleanup и локальная приёмка backups.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { ApiClientError } from "@/shared/api/http-client"
|
||||
import { backupRestoreStorageKey, readBackupRestoreCheckpoint, restoreWasNotApplied } from "./backup-restore-checkpoint"
|
||||
test("restore checkpoint preserves target and rejects corrupt or incomplete state", () => {
|
||||
const target = { backupId: "backup-1", serverId: 1, filename: "config.rsc", serverName: "router", requestedAt: "2026-10-01T00:00:00Z" }
|
||||
assert.deepEqual(readBackupRestoreCheckpoint(JSON.stringify(target)), target); assert.equal(readBackupRestoreCheckpoint(null), null)
|
||||
for (const payload of ["invalid JSON", JSON.stringify({}), JSON.stringify({ ...target, serverId: "1" }), JSON.stringify({ ...target, requestedAt: "bad" })]) assert.throws(() => readBackupRestoreCheckpoint(payload))
|
||||
assert.notEqual(backupRestoreStorageKey("first"), backupRestoreStorageKey("second"))
|
||||
})
|
||||
test("unknown response is not proof that import was not applied", () => {
|
||||
for (const error of [new Error("timeout"), new ApiClientError("old backend error", 400), new ApiClientError("lost after apply", 502, { outcome: "unknown" }), new ApiClientError("server error", 500)]) assert.equal(restoreWasNotApplied(error), false)
|
||||
assert.equal(restoreWasNotApplied(new ApiClientError("prepare failed", 400, { outcome: "not-applied" })), true)
|
||||
assert.equal(restoreWasNotApplied(new ApiClientError("forbidden", 403)), true)
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod"
|
||||
import { ApiClientError } from "@/shared/api/http-client"
|
||||
export const backupRestoreCheckpointSchema = z.object({ backupId: z.string().min(1), serverId: z.number().int().positive().safe(), filename: z.string().min(1), serverName: z.string().min(1), requestedAt: z.string().datetime({ offset: true }) })
|
||||
export type BackupRestoreCheckpoint = z.infer<typeof backupRestoreCheckpointSchema>
|
||||
export function readBackupRestoreCheckpoint(value: string | null) { return value ? backupRestoreCheckpointSchema.parse(JSON.parse(value)) : null }
|
||||
export function backupRestoreStorageKey(backendUrl: string) { return "mmapp:backup-restore:" + backendUrl }
|
||||
export function restoreWasNotApplied(error: unknown): boolean {
|
||||
if (!(error instanceof ApiClientError)) return false
|
||||
if ([403, 404, 405].includes(error.status)) return true
|
||||
return error.status === 400 && !!error.payload && typeof error.payload === "object" && "outcome" in error.payload && error.payload.outcome === "not-applied"
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { backupRestoreCheckpointSchema, backupRestoreStorageKey, readBackupRestoreCheckpoint, type BackupRestoreCheckpoint } from "./backup-restore-checkpoint"
|
||||
export function useBackupRestoreGuard(backendUrl: string, live: boolean) {
|
||||
const [checkpoint, setCheckpoint] = useState<BackupRestoreCheckpoint | null>(null), current = useRef<BackupRestoreCheckpoint | null>(null)
|
||||
const [hydrated, setHydrated] = useState(false), [error, setError] = useState<string | null>(null), blocked = useRef(false), alive = useRef(true)
|
||||
const key = backupRestoreStorageKey(backendUrl)
|
||||
useEffect(() => {
|
||||
alive.current = true
|
||||
queueMicrotask(() => {
|
||||
if (!alive.current) return
|
||||
try { const saved = live ? readBackupRestoreCheckpoint(sessionStorage.getItem(key)) : null; current.current = saved; setCheckpoint(saved) }
|
||||
catch { blocked.current = true; setError("Не удалось прочитать отметку импорта. Проверьте состояние роутера перед новым восстановлением") }
|
||||
setHydrated(true)
|
||||
})
|
||||
return () => { alive.current = false }
|
||||
}, [key, live])
|
||||
function begin(target: Omit<BackupRestoreCheckpoint, "requestedAt">) {
|
||||
if (!live || !hydrated || !alive.current || blocked.current || current.current) return false
|
||||
const value = backupRestoreCheckpointSchema.parse({ ...target, requestedAt: new Date().toISOString() })
|
||||
try { sessionStorage.setItem(key, JSON.stringify(value)) }
|
||||
catch { blocked.current = true; setError("Не удалось сохранить отметку импорта. Запрос не отправлен"); return false }
|
||||
current.current = value; setCheckpoint(value); return true
|
||||
}
|
||||
function clear() {
|
||||
if (!live || !hydrated || !alive.current) return false
|
||||
try { sessionStorage.removeItem(key) }
|
||||
catch { blocked.current = true; setError("Не удалось очистить отметку импорта. Новый импорт заблокирован"); return false }
|
||||
current.current = null; blocked.current = false; setCheckpoint(null); setError(null); return true
|
||||
}
|
||||
return { checkpoint, hydrated, error, begin, clear, canStart: hydrated && !checkpoint && !error }
|
||||
}
|
||||
@@ -22,7 +22,7 @@ for (const status of [403, 503, 200]) test("Бэкапы: удаление " + s
|
||||
})
|
||||
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: { backupId: "backup-1", serverId: 1, filename: "other.rsc", serverName: "router" } }))
|
||||
await page.route("**/api/backups/backup-1/restore", r => r.fulfill(failure ? { status: 400, json: { error: "Импорт отказал", outcome: "not-applied" } } : { json: { backupId: "backup-1", serverId: 1, 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()
|
||||
@@ -44,6 +44,47 @@ test("Бэкапы: restore требует правильный serverId и пр
|
||||
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()
|
||||
serverId = 1; await dialog.getByRole("button", { name: "Восстановить", exact: true }).dblclick()
|
||||
await expect(dialog.getByRole("button", { name: "Восстановить", exact: true })).toBeDisabled()
|
||||
await dialog.getByRole("button", { name: "Закрыть", exact: true }).click()
|
||||
await page.getByRole("checkbox", { name: /Я проверил состояние роутера/ }).check(); await page.getByRole("button", { name: "Разрешить новое восстановление", exact: true }).click()
|
||||
serverId = 1; await page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true }).click(); await dialog.getByRole("button", { name: "Восстановить", exact: true }).dblclick()
|
||||
await expect(dialog).toBeHidden(); await expect(page.getByText("API подтвердил импорт " + backup.filename + " на Новое имя роутера", { exact: true })).toBeVisible(); expect(calls).toBe(2)
|
||||
})
|
||||
|
||||
for (const outcome of ["unknown", "legacy-error"]) test("Бэкапы: неизвестный restore переживает reload/" + outcome, async ({ page }) => {
|
||||
await setupBackups(page); let calls = 0
|
||||
await page.route("**/api/backups/backup-1/restore", async r => { calls++; await r.fulfill({ status: outcome === "unknown" ? 502 : 400, json: { error: "Ответ импорта потерян", ...(outcome === "unknown" ? { outcome: "unknown" } : {}) } }) })
|
||||
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("button", { name: "Восстановить", exact: true })).toBeDisabled(); await expect(dialog).toContainText("Результат импорта неизвестен")
|
||||
await page.reload(); const alert = page.getByRole("alert", { name: "Подтверждение восстановления", exact: true })
|
||||
await expect(alert).toContainText(backup.filename); await expect(page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true })).toBeDisabled()
|
||||
await expect(alert.getByRole("button", { name: "Разрешить новое восстановление", exact: true })).toBeDisabled(); expect(calls).toBe(1)
|
||||
await alert.getByRole("checkbox", { name: /Я проверил состояние роутера/ }).check(); await alert.getByRole("button", { name: "Разрешить новое восстановление", exact: true }).click()
|
||||
await expect(alert).toBeHidden(); await expect(page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true })).toBeEnabled(); expect(calls).toBe(1)
|
||||
})
|
||||
test("Бэкапы: запрет записи checkpoint не отправляет restore", async ({ page }) => {
|
||||
await setupBackups(page); let calls = 0
|
||||
await page.addInitScript(() => { const original = Storage.prototype.setItem; Storage.prototype.setItem = function(key, value) { if (key.startsWith("mmapp:backup-restore:")) throw new Error("storage denied"); return original.call(this, key, value) } })
|
||||
await page.route("**/api/backups/backup-1/restore", async r => { calls++; await r.fulfill({ json: {} }) })
|
||||
await page.goto("/backups"); await page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true }).click()
|
||||
await page.getByRole("alertdialog").getByRole("button", { name: "Восстановить", exact: true }).click()
|
||||
await expect(page.getByRole("alert", { name: "Подтверждение восстановления", exact: true })).toContainText("Запрос не отправлен"); expect(calls).toBe(0)
|
||||
})
|
||||
test("Бэкапы: повреждённый checkpoint запрещает новый restore", async ({ page }) => {
|
||||
await setupBackups(page); await page.addInitScript(() => sessionStorage.setItem("mmapp:backup-restore:" + location.origin, "broken"))
|
||||
await page.goto("/backups"); await expect(page.getByRole("alert", { name: "Подтверждение восстановления", exact: true })).toContainText("Не удалось прочитать отметку")
|
||||
await expect(page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true })).toBeDisabled()
|
||||
})
|
||||
|
||||
test("Бэкапы: подтверждённый restore с отказом очистки checkpoint не допускает новый импорт", async ({ page }) => {
|
||||
await setupBackups(page); let calls = 0
|
||||
await page.addInitScript(() => { const original = Storage.prototype.removeItem; Storage.prototype.removeItem = function(key) { if (key.startsWith("mmapp:backup-restore:")) throw new Error("storage denied"); return original.call(this, key) } })
|
||||
await page.route("**/api/backups/backup-1/restore", async r => { calls++; await r.fulfill({ json: { backupId: backup.id, serverId: backup.serverId, filename: backup.filename, serverName: "router" } }) })
|
||||
await page.goto("/backups"); await page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true }).click()
|
||||
await page.getByRole("alertdialog").getByRole("button", { name: "Восстановить", exact: true }).click()
|
||||
await expect(page.getByRole("alertdialog")).toBeHidden(); await expect(page.getByText("API подтвердил импорт " + backup.filename + " на router", { exact: true })).toBeVisible()
|
||||
const alert = page.getByRole("alert", { name: "Подтверждение восстановления", exact: true }); await expect(alert).toContainText("Не удалось очистить отметку")
|
||||
await alert.getByRole("checkbox", { name: /Я проверил состояние роутера/ }).check(); await alert.getByRole("button", { name: "Разрешить новое восстановление", exact: true }).click()
|
||||
await expect(page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true })).toBeDisabled(); expect(calls).toBe(1)
|
||||
})
|
||||
Reference in new issue
Block a user