fix(ui): изолировать операции восстановления Filters

This commit is contained in:
Denozordec committed 2026-09-30 11:42:57 +07:00
1 parent 7554f1804b
commit 1fba16e1f2
5 files changed
+145 -15

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
import { test, expect } from "@playwright/test"
for (const failure of ["http", "wrong-server", "stale"] as const) {
test(`Filters: замок восстановления и отказ чтения ${failure}`, async ({ page }) => {
const rule = { id: "rule-1", community: "65001:101", gateway: "", gatewayTunnelId: "", action: "blackhole", description: "Правило" }
let restores = 0, revisionReads = 0, readsAfterRestore = 0
let releaseRead: (() => void) | undefined, releaseHistory: (() => void) | undefined
const readGate = new Promise<void>(resolve => { releaseRead = resolve })
const historyGate = new Promise<void>(resolve => { releaseHistory = resolve })
let historyWaiting = false
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", async route => {
const path = new URL(route.request().url()).pathname
if (path.endsWith("/restore")) {
restores++
expect(route.request().postDataJSON()).toEqual({ serverId: "1" })
if (restores === 1) return route.fulfill({ status: 403, json: { error: "Откат запрещён тест" } })
return route.fulfill({ json: { ok: true } })
}
if (path === "/api/filters/revisions") {
revisionReads++
if (restores === 3) { historyWaiting = true; await historyGate }
return route.fulfill({ json: { revisions: [{ id: "rev/1", serverId: "1", section: "filters", source: "apply", fingerprint: "fixture", createdAt: "2026-09-29T00:00:00Z", note: null, itemCount: 1 }] } })
}
if (path === "/api/filters/rules" && restores >= 2) {
readsAfterRestore++
if (restores === 2) {
if (failure === "http") return route.fulfill({ status: 503, json: { error: "Чтение недоступно тест" } })
return route.fulfill({ json: { rulesets: [{ serverId: failure === "wrong-server" ? "2" : "1", rules: [] }], greTunnels: [], stale: failure === "stale" } })
}
await readGate
}
return route.fulfill({ json: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: [rule] }], greTunnels: [], live: true }
: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", site: "TEST", asn: "", status: "online", latency: 1 }]
: path === "/api/auth/config" ? { required: false }
: path === "/api/filters/gre-tunnels" ? { tunnels: [] }
: path === "/api/recursive-routes" ? { routes: [] } : {} })
})
await page.goto("/filters")
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(1)
await page.getByRole("button", { name: "История", exact: true }).click()
const sheet = page.getByRole("dialog")
await sheet.getByRole("button", { name: "Откатить", exact: true }).click()
const confirm = page.getByRole("alertdialog")
const restore = confirm.getByRole("button", { name: "Откатить", exact: true })
await restore.click()
await expect(confirm.getByRole("alert").filter({ hasText: "Откат запрещён тест" })).toBeVisible()
await restore.click()
await expect(confirm.getByRole("alert").filter({ hasText: "Версия применена, но актуальные правила не прочитаны" })).toBeVisible()
await expect(rows).toHaveCount(1)
await expect(page.getByText("Версия применена на роутер", { exact: true })).toHaveCount(0)
await expect(restore).toBeEnabled()
await restore.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() })
await expect.poll(() => restores).toBe(3)
await expect.poll(() => readsAfterRestore).toBe(2)
await expect(restore).toBeDisabled()
await expect(confirm.getByRole("button", { name: "Отмена" })).toBeDisabled()
for (const name of ["Обновить", "История", "Новое правило", "Копировать", "Редактировать правило", "Удалить правило"]) await expect(page.getByRole("button", { name, exact: true, includeHidden: true })).toBeDisabled()
await page.keyboard.press("Escape")
await expect(confirm).toBeVisible()
releaseRead!()
await expect.poll(() => historyWaiting).toBe(true)
await expect(restore).toBeDisabled()
await expect(page.getByRole("button", { name: "Обновить", exact: true, includeHidden: true })).toBeDisabled()
await expect(page.getByText("Версия применена на роутер", { exact: true })).toHaveCount(0)
releaseHistory!()
await expect(confirm).toHaveCount(0)
await expect(page.getByRole("button", { name: "Обновить", exact: true, includeHidden: true })).toBeEnabled()
await expect(page.getByRole("button", { name: "Новое правило", exact: true, includeHidden: true })).toBeEnabled()
expect(restores).toBe(3)
expect(revisionReads).toBe(2)
})
}
test("Filters: двойное обновление удерживает общий замок и освобождает его после ошибки", async ({ page }) => {
let deviceReads = 0
let release: (() => void) | undefined
const gate = new Promise<void>(resolve => { release = resolve })
const rule = { id: "rule-1", community: "65001:101", gateway: "", gatewayTunnelId: "", action: "blackhole", description: "Правило" }
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", async route => {
const url = new URL(route.request().url()), path = url.pathname
if (path === "/api/filters/rules" && url.searchParams.has("serverId")) {
deviceReads++
if (deviceReads === 2) { await gate; return route.fulfill({ status: 503, json: { error: "Сбой обновления тест" } }) }
}
return route.fulfill({ json: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: [rule] }], greTunnels: [], live: true }
: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", site: "TEST", asn: "", status: "online", latency: 1 }]
: path === "/api/auth/config" ? { required: false }
: path === "/api/filters/gre-tunnels" ? { tunnels: [] }
: path === "/api/recursive-routes" ? { routes: [] } : {} })
})
await page.goto("/filters")
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
await expect.poll(() => deviceReads).toBe(1)
const refresh = page.getByRole("button", { name: "Обновить", exact: true, includeHidden: true })
await refresh.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() })
await expect.poll(() => deviceReads).toBe(2)
for (const name of ["Обновить", "История", "Новое правило", "Копировать", "Редактировать правило", "Удалить правило"]) await expect(page.getByRole("button", { name, exact: true, includeHidden: true })).toBeDisabled()
release!()
await expect(refresh).toBeEnabled()
await expect(page.getByRole("button", { name: "Новое правило", exact: true, includeHidden: true })).toBeDisabled()
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
await refresh.click()
await expect.poll(() => deviceReads).toBe(3)
await expect(page.getByRole("button", { name: "Новое правило", exact: true, includeHidden: true })).toBeEnabled()
})