fix(backups): продолжать наблюдение без повторного создания

This commit is contained in:
Denozordec committed 2026-10-01 15:58:47 +07:00
1 parent 4aee2a34f1
commit 714af7e872
9 files changed
+265 -74

No files matched your search

+27
View File
@@ -1,4 +1,5 @@
import { test, expect } from "@playwright/test"
import { openBackup, backupJob, backupAck } from "./fixtures/backup-job"
import { backup, setupBackups } from "./fixtures/backups"
const headers = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET,POST,PUT,DELETE,OPTIONS", "access-control-allow-headers": "Content-Type,Authorization" }
for (const change of ["backend", "mode"]) for (const fails of [false, true]) test("Бэкапы: позднее чтение/" + change + "/" + fails, async ({ page }) => {
@@ -36,3 +37,29 @@ for (const kind of ["schedule", "storage"]) for (const fails of [false, true]) t
await expect(page.getByText(/СТАРОЕ СОХРАНЕНИЕ БЭКАПА/)).toHaveCount(0)
await expect(page.getByText(kind === "schedule" ? "Расписание сохранено" : "Хранилище сохранено", { exact: true })).toHaveCount(0)
})
for (const phase of ["POST", "poll"]) for (const change of ["backend", "mode"]) for (const fails of [false, true]) test("Бэкапы: поздний job/" + phase + "/" + change + "/" + fails, async ({ page }) => {
await setupBackups(page, headers)
let id = "", started = false, completed = false, release!: () => void; const gate = new Promise<void>(r => release = r)
await page.route("**/api/backups/create", async r => {
id = r.request().postDataJSON().requestId
if (phase !== "POST") return r.fulfill({ headers, status: 202, json: backupAck(id) })
started = true; await gate
try { await r.fulfill(fails ? { headers, status: 503, json: { error: "СТАРЫЙ JOB ОТКАЗ" } } : { headers, status: 202, json: backupAck(id) }) } catch {} finally { completed = true }
})
await page.route("**/api/backups/jobs/*", async r => {
if (phase !== "poll") return r.fulfill({ headers, json: backupJob(id) })
started = true; await gate
try { await r.fulfill(fails ? { headers, status: 503, json: { error: "СТАРЫЙ JOB ОТКАЗ" } } : { headers, json: backupJob(id) }) } catch {} finally { completed = true }
})
const dialog = await openBackup(page); await dialog.getByRole("button", { name: /Снять бэкап/ }).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(/СТАРЫЙ JOB ОТКАЗ/)).toHaveCount(0)
await expect(page.getByText("API подтвердил создание всех резервных копий", { exact: true })).toHaveCount(0)
await expect(page.getByRole("button", { name: "Задача резервного копирования", exact: true })).toHaveCount(0)
})
+77
View File
@@ -0,0 +1,77 @@
import { test, expect } from "@playwright/test"
import { setupBackups, backup } from "./fixtures/backups"
import { openBackup, backupJob, backupAck } from "./fixtures/backup-job"
test("Бэкапы job: single-flight POST и подтверждённый результат", async ({ page }) => {
await setupBackups(page); let posts = 0, id = "", release!: () => void; const gate = new Promise<void>(r => release = r)
await page.route("**/api/backups/create", async r => { posts++; id = r.request().postDataJSON().requestId; await gate; await r.fulfill({ status: 202, json: backupAck(id) }).catch(() => {}) })
await page.route("**/api/backups/jobs/*", r => r.fulfill({ json: backupJob(id) }))
const dialog = await openBackup(page); await dialog.getByRole("button", { name: /Снять бэкап/ }).dblclick()
await expect(dialog.getByRole("button", { name: "Проверить задачу", exact: true })).toBeDisabled()
release(); await expect(page.getByText("API подтвердил создание всех резервных копий", { exact: true })).toBeVisible()
expect(posts).toBe(1); await expect(dialog.getByRole("button", { name: "Новая задача", exact: true })).toBeVisible()
})
test("Бэкапы job: отклонённый POST сохраняет форму для исправления", async ({ page }) => {
await setupBackups(page); await page.route("**/api/backups/create", r => r.fulfill({ status: 403, json: { error: "Создание запрещено" } }))
const dialog = await openBackup(page); await dialog.getByRole("button", { name: /Снять бэкап/ }).click()
await expect(dialog.getByRole("button", { name: /Снять бэкап/ })).toBeEnabled()
await dialog.getByRole("button", { name: "Назад", exact: true }).click()
await expect(dialog.getByRole("textbox", { name: "Заметка резервной копии", exact: true })).toHaveValue("manual")
await expect(page.getByText("API подтвердил создание всех резервных копий", { exact: true })).toHaveCount(0)
})
for (const kind of ["device", "S3"]) test("Бэкапы job: partial " + kind + " не называется полным успехом", async ({ page }) => {
await setupBackups(page); let id = ""
await page.route("**/api/backups/create", r => { id = r.request().postDataJSON().requestId; return r.fulfill({ status: 202, json: backupAck(id) }) })
await page.route("**/api/backups/jobs/*", r => r.fulfill({ json: backupJob(id, kind === "device" ? { created: [], failures: [{ serverId: "1", error: "RouterOS offline" }] } : { created: [{ ...backup, uploadError: "S3 upload denied" }] }) }))
const dialog = await openBackup(page); await dialog.getByRole("button", { name: /Снять бэкап/ }).click()
await expect(page.getByRole("alert", { name: "Задача резервного копирования", exact: true })).toContainText(kind === "device" ? "RouterOS offline" : "S3 upload denied")
await expect(page.getByText("API подтвердил создание всех резервных копий", { exact: true })).toHaveCount(0)
await expect(dialog.getByRole("button", { name: "Новая задача", exact: true })).toBeVisible()
})
test("Бэкапы job: отказ GET и повтор только чтения той же задачи", async ({ page }) => {
await setupBackups(page); let posts = 0, id = "", fail = true
await page.route("**/api/backups/create", r => { posts++; id = r.request().postDataJSON().requestId; return r.fulfill({ status: 202, json: backupAck(id) }) })
await page.route("**/api/backups/jobs/*", r => r.fulfill(fail ? { status: 503, json: { error: "Наблюдение отказало" } } : { json: backupJob(id) }))
const dialog = await openBackup(page); await dialog.getByRole("button", { name: /Снять бэкап/ }).click()
await expect(dialog.getByRole("button", { name: "Проверить задачу", exact: true })).toBeEnabled()
await expect(page.getByRole("alert", { name: "Задача резервного копирования", exact: true })).toContainText("Наблюдение отказало")
fail = false; await dialog.getByRole("button", { name: "Проверить задачу", exact: true }).click()
await expect(page.getByText("API подтвердил создание всех резервных копий", { exact: true })).toBeVisible(); expect(posts).toBe(1)
})
test("Бэкапы job: потерянный POST/404 после reload не запускает второй POST", async ({ page }) => {
await setupBackups(page); let posts = 0
await page.route("**/api/backups/create", r => { posts++; return r.fulfill({ status: 503, json: { error: "Неизвестный результат POST" } }) })
await page.route("**/api/backups/jobs/*", r => r.fulfill({ status: 404, json: { error: "Job lost" } }))
const dialog = await openBackup(page); await dialog.getByRole("button", { name: /Снять бэкап/ }).click()
await expect(dialog.getByRole("button", { name: "Проверить задачу", exact: true })).toBeEnabled()
await page.reload(); await page.getByRole("button", { name: "Задача резервного копирования", exact: true }).click()
await page.getByRole("dialog").getByRole("button", { name: "Проверить задачу", exact: true }).click()
await expect(page.getByRole("alert", { name: "Задача резервного копирования", exact: true })).toContainText("После рестарта")
expect(posts).toBe(1); await expect(page.getByRole("dialog").getByRole("button", { name: "Новая задача", exact: true })).toHaveCount(0)
})
test("Бэкапы job: чужая цель не подтверждает завершение", async ({ page }) => {
await setupBackups(page); let id = ""
await page.route("**/api/backups/create", r => { id = r.request().postDataJSON().requestId; return r.fulfill({ status: 202, json: backupAck(id) }) })
await page.route("**/api/backups/jobs/*", r => r.fulfill({ json: backupJob(id, { notes: "foreign" }) }))
const dialog = await openBackup(page); await dialog.getByRole("button", { name: /Снять бэкап/ }).click()
await expect(page.getByRole("alert", { name: "Задача резервного копирования", exact: true })).toContainText("не соответствует")
await expect(dialog.getByRole("button", { name: "Новая задача", exact: true })).toHaveCount(0)
})
test("Бэкапы job: неизвестный POST восстанавливается GET без второго создания", async ({ page }) => {
await setupBackups(page); let posts = 0, id = ""
await page.route("**/api/backups/create", r => { posts++; id = r.request().postDataJSON().requestId; return r.fulfill({ status: 503, json: { error: "Ack lost" } }) })
await page.route("**/api/backups/jobs/*", r => r.fulfill({ json: backupJob(id) }))
const dialog = await openBackup(page); await dialog.getByRole("button", { name: /Снять бэкап/ }).click()
await expect(dialog.getByRole("button", { name: "Проверить задачу", exact: true })).toBeEnabled()
await dialog.getByRole("button", { name: "Проверить задачу", exact: true }).click()
await expect(page.getByText("API подтвердил создание всех резервных копий", { exact: true })).toBeVisible(); expect(posts).toBe(1)
})
test("Бэкапы job: невозможность checkpoint записи не отправляет POST", async ({ page }) => {
await setupBackups(page); let posts = 0
await page.addInitScript(() => { const set = Storage.prototype.setItem; Storage.prototype.setItem = function(key, value) { if (key.startsWith("mmapp:backup-job:")) throw new Error("denied"); return set.call(this, key, value) } })
await page.route("**/api/backups/create", r => { posts++; return r.fallback() })
const dialog = await openBackup(page); await dialog.getByRole("button", { name: /Снять бэкап/ }).click()
await expect(page.getByRole("alert", { name: "Задача резервного копирования", exact: true })).toContainText("Запрос не отправлен")
expect(posts).toBe(0)
})
+14
View File
@@ -0,0 +1,14 @@
import { expect, type Page } from "@playwright/test"
import { backup } from "./backups"
export async function openBackup(page: Page) {
await page.goto("/backups"); await page.getByRole("button", { name: "Новый бэкап", exact: true }).first().click()
const dialog = page.getByRole("dialog")
await dialog.getByRole("checkbox", { name: /Выбрать Роутер/ }).check()
await dialog.getByRole("button", { name: "Далее", exact: true }).click()
await dialog.getByRole("textbox", { name: "Заметка резервной копии", exact: true }).fill("manual")
await dialog.getByRole("button", { name: "Далее", exact: true }).click()
await expect(dialog.getByRole("button", { name: /Снять бэкап/ })).toBeEnabled()
return dialog
}
export function backupJob(id: string, patch: Record<string, unknown> = {}) { return { id, status: "done", serverIds: ["1"], notes: "manual", total: 1, completed: 1, created: [backup], failures: [], requestedAt: "2026-10-01T00:00:00Z", finishedAt: "2026-10-01T00:01:00Z", ...patch } }
export function backupAck(id: string) { return { jobId: id, status: "queued", serverIds: ["1"], total: 1, completed: 0 } }