fix(api): сохранять каталог speed-проб атомарно

This commit is contained in:
Denozordec committed 2026-10-04 10:41:03 +07:00
1 parent d4f2961f76
commit 908974302e
9 files changed
+204 -86

No files matched your search

+15 -24
View File
@@ -1,6 +1,6 @@
"use client"
import { speedHistoryResponseSchema, speedProbesResponseSchema } from "@mmapp/contracts"
import { speedHistoryResponseSchema, speedProbesResponseSchema, speedProbesWriteSchema, speedProbesSavedSchema } from "@mmapp/contracts"
import probeStyles from "./probe-rows.module.css"
@@ -1074,6 +1074,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
const [deleteTarget, setDeleteTarget] = useState<{ kind: "ping" | "speed"; id: string; label: string } | null>(null)
const draftProbeId = useRef("")
const [probeSaving, setProbeSaving] = useState(false)
const [probeSaveWarning, setProbeSaveWarning] = useState<string | null>(null)
const [probeSaveError, setProbeSaveError] = useState<string | null>(null)
const savingRef = useRef(false)
const saveContext = useRef(0)
@@ -1524,34 +1525,23 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
const persistSpeedProbes = useCallback(async (rows: SpeedProbeRow[]) => {
if (!liveApi) return
if (!speedCatalogReady) throw new Error("Каталог speed-проб не подтверждён. Обновите данные перед сохранением.")
await apiFetch("/api/uptime/speed-probes", {
method: "PUT",
signal: AbortSignal.timeout(30_000),
body: JSON.stringify({
probes: rows.map((p) => ({
id: p.id,
srcServerId: p.srcServerId,
dstServerId: p.dstServerId,
srcInterface: p.srcInterface || "",
dstInterface: p.dstInterface || "",
protocol: p.protocol,
direction: p.direction,
durationSec: Number.parseInt(p.durationSec, 10) || 10,
enabled: p.enabled,
lastRunAt: p.lastRunAt ?? null,
lastTxAvgMbps: p.lastTxAvgMbps ?? null,
lastRxAvgMbps: p.lastRxAvgMbps ?? null,
lastStatus: p.lastStatus ?? null,
lastError: p.lastError ?? null,
})),
}),
})
const payload = speedProbesWriteSchema.safeParse({ probes: rows.map(p => ({
id: p.id, srcServerId: p.srcServerId, dstServerId: p.dstServerId,
srcInterface: p.srcInterface || "", dstInterface: p.dstInterface || "",
protocol: p.protocol, direction: p.direction, durationSec: p.durationSec, enabled: p.enabled,
})) })
if (!payload.success) throw new Error("Проверьте speed-пробы: источник и назначение должны отличаться, длительность — целое число от 3 секунд.")
const saved = speedProbesSavedSchema.safeParse(await apiFetch<unknown>("/api/uptime/speed-probes", {
method: "PUT", signal: AbortSignal.timeout(30_000), body: JSON.stringify(payload.data),
}))
if (!saved.success) throw new Error("Ответ о сохранении speed-проб не подтверждён. Обновите список перед повторной попыткой.")
setProbeSaveWarning(saved.data.warning ?? null)
}, [apiFetch, liveApi, speedCatalogReady])
async function saveProbeForm(save: () => Promise<void>, confirm: () => void) {
if (!requireWrite()) return
savingRef.current = true
setProbeSaving(true); setProbeSaveError(null)
setProbeSaving(true); setProbeSaveError(null); setProbeSaveWarning(null)
overviewReqRef.current++
const context = ++saveContext.current
try {
@@ -2014,6 +2004,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
{!canWrite && <ContextAlert variant="info" role="status"><ContextTitle>Только просмотр</ContextTitle><ContextDescription>Изменение проб и запуск измерений требуют разрешения на запись. Обновление данных загружает сохранённые результаты.</ContextDescription></ContextAlert>}
{permissionError && !sheetOpen && !speedSheetOpen && !deleteTarget && <ContextAlert variant="destructive" role="alert"><ContextDescription>{permissionError}</ContextDescription></ContextAlert>}
{probeSaveWarning && <ContextAlert variant="warning" role="status"><ContextDescription>{probeSaveWarning}</ContextDescription></ContextAlert>}
{probeSaveError && !sheetOpen && !speedSheetOpen && !deleteTarget && <ContextAlert variant="destructive" role="alert"><ContextDescription>{probeSaveError}</ContextDescription></ContextAlert>}
{urlError && <ContextAlert variant="warning" role="alert"><ContextTitle>Параметры ссылки требуют внимания</ContextTitle><ContextDescription>{urlError}<Button type="button" variant="outline" size="sm" onClick={resetUrl}>Сбросить параметры мониторинга</Button></ContextDescription></ContextAlert>}
@@ -0,0 +1,66 @@
import assert from "node:assert/strict"
import Fastify from "fastify"
import { SignJWT } from "jose"
import { createSpeedProbesWriteRoute } from "./speed-probes-write.js"
import { speedProbesWriteSchema, type SpeedProbeWrite } from "@mmapp/contracts"
import { replaceSpeedProbes, type SpeedProbePatch, type SpeedProbeStore } from "../services/speed-probe-replacement.js"
import { permissionForRequest } from "../lib/permissions.js"
const probe = { id: "s1", srcServerId: "1", dstServerId: 2, srcInterface: " ether1 ", dstInterface: "", protocol: "tcp", direction: "both", durationSec: "10", enabled: false }
let transactions = 0, refreshes = 0, failWrite = false, failScheduler = false
let rows = new Map<string, SpeedProbePatch & { lastTxAvgMbps: number | null }>([["old", { ...speedProbesWriteSchema.parse({ probes: [probe] }).probes[0], id: "old", sortOrder: 0, updatedAt: "old", lastTxAvgMbps: 12 }]])
const store: SpeedProbeStore = { transaction: async run => {
transactions++
const draft = new Map(rows)
await run({ listIds: async () => [...draft.keys()], remove: async id => { draft.delete(id) }, upsert: async patch => {
draft.set(patch.id, { ...patch, lastTxAvgMbps: draft.get(patch.id)?.lastTxAvgMbps ?? null })
if (failWrite) throw new Error("database write failed")
} })
rows = draft
} }
const app = Fastify()
await app.register(createSpeedProbesWriteRoute({ replace: probes => replaceSpeedProbes(store, probes, "2026-10-04T00:00:00Z"), refreshScheduler: async () => { refreshes++; if (failScheduler) throw new Error("scheduler unavailable") } }), { prefix: "/api" })
const put = (payload: unknown) => app.inject({ method: "PUT", url: "/api/uptime/speed-probes", headers: { "content-type": "application/json" }, payload: JSON.stringify(payload) })
for (const payload of [null, {}, { probes: null }, { probes: {} }, { probes: [probe, probe] }, ...[
{ id: "" }, { srcServerId: "1junk" }, { srcServerId: 0 }, { srcServerId: 9007199254740992 }, { dstServerId: 1 },
{ durationSec: "10junk" }, { durationSec: 2 }, { durationSec: 3.5 }, { durationSec: 2147483648 },
{ protocol: "bad" }, { direction: "bad" }, { enabled: "true" }, { enabled: undefined }, { srcInterface: null },
].map(patch => ({ probes: [probe, { ...probe, id: "s2", ...patch }] }))]) {
assert.equal((await put(payload)).statusCode, 400)
}
assert.equal(transactions, 0); assert.equal(refreshes, 0); assert.ok(rows.has("old"))
failWrite = true
assert.equal((await put({ probes: [probe] })).statusCode, 500)
assert.ok(rows.has("old")); assert.equal(rows.size, 1); assert.equal(refreshes, 0)
failWrite = false
const saved = await put({ probes: [probe] }); assert.equal(saved.statusCode, 200); assert.deepEqual(saved.json(), { ok: true })
assert.equal(rows.has("old"), false); assert.equal(rows.get("s1")?.enabled, false); assert.equal(rows.get("s1")?.srcInterface, "ether1")
rows.get("s1")!.lastTxAvgMbps = 42
await put({ probes: [{ ...probe, durationSec: 20, lastTxAvgMbps: 0 }] })
assert.equal(rows.get("s1")?.lastTxAvgMbps, 42); assert.equal(rows.get("s1")?.durationSec, 20)
failScheduler = true
const warning = await put({ probes: [{ ...probe, durationSec: 30 }] }); assert.equal(warning.statusCode, 200)
assert.equal(warning.json().ok, true); assert.match(warning.json().warning, /Пробы сохранены/); assert.equal(rows.get("s1")?.durationSec, 30)
failScheduler = false
assert.equal((await put({ probes: [] })).statusCode, 200); assert.equal(rows.size, 0)
assert.equal(permissionForRequest("PUT", "/api/uptime/speed-probes"), "mm:uptime:write")
// An ordered full replacement strips observations, preserves caller order and does not mutate the input.
const input = speedProbesWriteSchema.parse({ probes: [probe, { ...probe, id: "s2", enabled: true }] }).probes as SpeedProbeWrite[]
await replaceSpeedProbes(store, input)
assert.deepEqual([...rows.values()].map(row => [row.id, row.sortOrder]), [["s1", 0], ["s2", 1]])
assert.equal("sortOrder" in input[0], false)
await app.close()
console.log("Speed probes HTTP/DI: invalid catalog causes no writes; modeled rollback; order/observations; scheduler warning after commit; explicit empty replacement")
process.env.AUTH_REQUIRED = "true"; process.env.AUTH_JWT_SECRET = "speed-probes-test-secret"; process.env.AUTH_ISSUER = "https://auth.test.local"; process.env.NODE_ENV = "test"
const { default: authPlugin, requireAuth } = await import("../plugins/auth.js")
const secured = Fastify(); await secured.register(authPlugin); secured.addHook("preHandler", requireAuth)
let authorizedWrites = 0
await secured.register(createSpeedProbesWriteRoute({ replace: async () => { authorizedWrites++ }, refreshScheduler: async () => undefined }), { prefix: "/api" })
const send = (token?: string) => secured.inject({ method: "PUT", url: "/api/uptime/speed-probes", payload: { probes: [] }, headers: token ? { authorization: "Bearer " + token } : {} })
assert.equal((await send()).statusCode, 401); assert.equal(authorizedWrites, 0)
for (const permission of ["mm:uptime:read", "mm:servers:write", "mm:uptime:write"]) {
const token = await new SignJWT({ sub: "test", apps: ["mm"], permissions: [permission] }).setProtectedHeader({ alg: "HS256" }).setIssuer("https://auth.test.local").setExpirationTime("1h").sign(new TextEncoder().encode("speed-probes-test-secret"))
assert.equal((await send(token)).statusCode, permission === "mm:uptime:write" ? 200 : 403)
}
assert.equal(authorizedWrites, 1); await secured.close()
console.log("Speed probes RBAC: signed JWT rejects missing/read/servers-write permissions before mutation")
+21
View File
@@ -0,0 +1,21 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { speedProbesWriteSchema, type SpeedProbeWrite } from "@mmapp/contracts"
export function createSpeedProbesWriteRoute(deps: {
replace: (probes: SpeedProbeWrite[]) => Promise<void>
refreshScheduler: () => Promise<unknown>
}): FastifyPluginAsyncZod {
return async app => {
app.put("/uptime/speed-probes", async (req, reply) => {
const parsed = speedProbesWriteSchema.safeParse(req.body)
if (!parsed.success) return reply.code(400).send({ error: "Список speed-проб некорректен. Проверьте устройства, длительность и уникальность записей. Изменения не сохранены." })
await deps.replace(parsed.data.probes)
try {
await deps.refreshScheduler()
return reply.send({ ok: true })
} catch (error) {
req.log.error({ err: error }, "Speed probes saved; scheduler refresh failed")
return reply.send({ ok: true, warning: "Пробы сохранены, но расписание не обновлено. Проверьте состояние сборщика данных." })
}
})
}
}
+18 -62
View File
@@ -1,7 +1,9 @@
import { createSpeedProbesWriteRoute } from "./speed-probes-write.js"
import { replaceSpeedProbes } from "../services/speed-probe-replacement.js"
import { mapSpeedProbeRead } from "../services/speed-probe-dto.js"
import { mapSpeedHistoryRun } from "../services/speed-history-dto.js"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { asc, desc, eq } from "drizzle-orm"
import { asc, desc, eq, sql } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers, serverSnapshots, uptimeSpeedProbes, uptimeSpeedTestRuns } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js"
@@ -198,68 +200,22 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
}
})
app.put("/uptime/speed-probes", async (req, reply) => {
const body = req.body as {
probes?: Array<{
id?: string
srcServerId?: string | number
dstServerId?: string | number
srcInterface?: string
dstInterface?: string
protocol?: "tcp" | "udp"
direction?: "transmit" | "receive" | "both"
durationSec?: string | number
enabled?: boolean
}>
}
const normalized = (body.probes ?? [])
.map((p, i) => ({
id: p.id || `sp-${Date.now()}-${i}`,
srcServerId: Number.parseInt(String(p.srcServerId ?? ""), 10),
dstServerId: Number.parseInt(String(p.dstServerId ?? ""), 10),
srcInterface: String(p.srcInterface ?? "").trim(),
dstInterface: String(p.dstInterface ?? "").trim(),
protocol: p.protocol === "udp" ? "udp" : "tcp",
direction: p.direction === "transmit" || p.direction === "receive" ? p.direction : "both",
durationSec: Math.max(3, Number.parseInt(String(p.durationSec ?? ""), 10) || 10),
enabled: p.enabled !== false,
}))
.filter((p) => Number.isFinite(p.srcServerId) && Number.isFinite(p.dstServerId) && p.srcServerId !== p.dstServerId)
const now = new Date().toISOString()
const existing = await db.select({ id: uptimeSpeedProbes.id }).from(uptimeSpeedProbes)
const nextIds = new Set(normalized.map((p) => p.id))
for (const row of existing) {
if (nextIds.has(row.id)) continue
await db.delete(uptimeSpeedProbes).where(eq(uptimeSpeedProbes.id, row.id))
}
for (let i = 0; i < normalized.length; i += 1) {
const p = normalized[i]
const patch = {
srcServerId: p.srcServerId,
dstServerId: p.dstServerId,
srcInterface: p.srcInterface,
dstInterface: p.dstInterface,
protocol: p.protocol as "tcp" | "udp",
direction: p.direction as "transmit" | "receive" | "both",
durationSec: p.durationSec,
enabled: p.enabled,
sortOrder: i,
updatedAt: now,
}
const updated = await db.update(uptimeSpeedProbes).set(patch).where(eq(uptimeSpeedProbes.id, p.id)).returning({ id: uptimeSpeedProbes.id })
if (updated.length > 0) continue
await db.insert(uptimeSpeedProbes).values({
id: p.id,
...patch,
createdAt: now,
await app.register(createSpeedProbesWriteRoute({
replace: probes => replaceSpeedProbes({ transaction: run => db.transaction(async tx => {
// Serialize complete replacements from all backend processes, including an empty catalog.
await tx.execute(sql`select pg_advisory_xact_lock(hashtext('uptime-speed-probes'))`)
await run({
listIds: async () => (await tx.select({ id: uptimeSpeedProbes.id }).from(uptimeSpeedProbes)).map(row => row.id),
remove: async id => { await tx.delete(uptimeSpeedProbes).where(eq(uptimeSpeedProbes.id, id)) },
upsert: async (row, createdAt) => {
const { id, ...patch } = row
await tx.insert(uptimeSpeedProbes).values({ id, ...patch, createdAt })
.onConflictDoUpdate({ target: uptimeSpeedProbes.id, set: patch })
},
})
}
await refreshScheduler()
return reply.send({ ok: true })
})
}) }, probes),
refreshScheduler,
}))
app.put("/uptime/probes", async (req, reply) => {
const body = req.body as {
@@ -0,0 +1,16 @@
import type { SpeedProbeWrite } from "@mmapp/contracts"
export type SpeedProbePatch = SpeedProbeWrite & { sortOrder: number; updatedAt: string }
export type SpeedProbeTransaction = {
listIds: () => Promise<string[]>
remove: (id: string) => Promise<void>
upsert: (row: SpeedProbePatch, createdAt: string) => Promise<void>
}
export type SpeedProbeStore = { transaction: (run: (tx: SpeedProbeTransaction) => Promise<void>) => Promise<void> }
/** The adapter must lock the catalog and commit all configuration changes atomically. Observations are never written here. */
export async function replaceSpeedProbes(store: SpeedProbeStore, probes: SpeedProbeWrite[], now = new Date().toISOString()): Promise<void> {
await store.transaction(async tx => {
const nextIds = new Set(probes.map(row => row.id))
for (const id of await tx.listIds()) if (!nextIds.has(id)) await tx.remove(id)
for (const [sortOrder, row] of probes.entries()) await tx.upsert({ ...row, sortOrder, updatedAt: now }, now)
})
}
+10
View File
@@ -36,6 +36,16 @@
## 12.3.23 — атомарная запись speed-проб (2026-10-04)
Общий write DTO требует явный probes массив, уникальные ID, безопасные положительные ID устройств, разные источник/назначение, целую длительность от3 секунд, protocol/direction/enabled. Missing/null/malformed каталог отклоняется400 целиком до обращения к хранилищу; явный probes:[] сохраняет намеренное удаление последней записи. Измерения из запроса исключены и не перезаписывают подтверждённые наблюдения.
Backend заменяет каталог в одной Drizzle transaction с pg_advisory_xact_lock для полной замены из разных процессов. Upsert обновляет только конфигурацию и порядок, timestamp создания сохранён. Ошибка refreshScheduler после commit возвращает ok:true с отдельным предупреждением; UI показывает предупреждение и требует подтверждённый ответ ok:true, сохраняет черновик при malformed acknowledgement. Неверная длительность отклоняется до PUT без parseInt fallback.
65 browser uptime/save/read сценариев прошли. HTTP/DI проверены malformed/no side effects, моделируемый rollback, explicit empty replacement, порядок и сохранение измерений, scheduler warning; signed JWT401/403/write проверен без PG. Typecheck, backend build, ESLint без ошибок (9 прежних uptime warnings) прошли. Production frontend build проверен отдельно. Реальный PostgreSQL rollback/advisory lock и работа scheduler на устройстве остаются внешней приёмкой12.5; модель DI не доказывает их.
Следующий блок12.3.24: большой OSPF route catalog — аудит фактического источника, пагинация50, поиск/сортировка по полному списку, refresh/error/recovery и320px. Затем остальные network grids и общая матрица12.4. Коммит локальный без push.
## 12.3.22 — общий каталог speed-проб и изоляция источника (2026-10-04)
Схема из probe-schedule-response перенесена в contracts: shared speedProbeReadSchema/speedProbesResponseSchema проверяют enums/duration/boolean/server IDs/уникальность ID и nullable observed values. Uptime и live schedule используют один контракт; ping-after-BT поля сохраняются, optional legacy отсутствие остаётся неизвестным. Pure backend mapSpeedProbeRead сохраняет null/0/false, нормализует DB timestamps; недостоверная запись возвращает502 вместо protocol/direction/duration/enabled fallback.
+11
View File
@@ -333,3 +333,14 @@ UptimeRoute keyed по probe ID/mode/backendUrl: каталог, история,
60 browser uptime и6 после финальной copy/a11y правки прошли;8 новых context scenarios backend/mode × probes/history × success/error прошли. Второй consumer/diagnostics/schedule/history47 browser прошли, включая1000 probes и1001 rail. Unit mapper3 + shared reader2 прошли без PG. Typecheck/ESLint без ошибок (9 прежних uptime warnings), backend build и frontend/contracts production34 страницы прошли; ReUI checklist и scoped axe error alert пройдены,320px снимок просмотрен. Context файл включён в агрегированную отдельную конфигурацию. Коммит локальный без push.
Следующий блок12.3.23: строгий PUT speed-probes до DB mutation, отказ без удаления malformed строк и транзакционная полная замена. Текущий backend PUT всё ещё имеет permissive normalize/filter и последовательные mutations; защита UI не заменяет серверную проверку. Затем network grids/полная main/context матрица12.4;12.5 реальная PG/RouterOS/integration приёмка отдельно.
## 12.3.23 — атомарная запись speed-проб (2026-10-04)
Общий write DTO требует явный probes массив, уникальные ID, безопасные положительные ID устройств, разные источник/назначение, целую длительность от3 секунд, protocol/direction/enabled. Missing/null/malformed каталог отклоняется400 целиком до обращения к хранилищу; явный probes:[] сохраняет намеренное удаление последней записи. Измерения из запроса исключены и не перезаписывают подтверждённые наблюдения.
Backend заменяет каталог в одной Drizzle transaction с pg_advisory_xact_lock для полной замены из разных процессов. Upsert обновляет только конфигурацию и порядок, timestamp создания сохранён. Ошибка refreshScheduler после commit возвращает ok:true с отдельным предупреждением; UI показывает предупреждение и требует подтверждённый ответ ok:true, сохраняет черновик при malformed acknowledgement. Неверная длительность отклоняется до PUT без parseInt fallback.
65 browser uptime/save/read сценариев прошли. HTTP/DI проверены malformed/no side effects, моделируемый rollback, explicit empty replacement, порядок и сохранение измерений, scheduler warning; signed JWT401/403/write проверен без PG. Typecheck, backend build, ESLint без ошибок (9 прежних uptime warnings) прошли. Production frontend build проверен отдельно. Реальный PostgreSQL rollback/advisory lock и работа scheduler на устройстве остаются внешней приёмкой12.5; модель DI не доказывает их.
Следующий блок12.3.24: большой OSPF route catalog — аудит фактического источника, пагинация50, поиск/сортировка по полному списку, refresh/error/recovery и320px. Затем остальные network grids и общая матрица12.4. Коммит локальный без push.
+11
View File
@@ -27,3 +27,14 @@ export const speedProbeReadSchema = z.object({
})
export const speedProbesResponseSchema = z.object({ probes: z.array(speedProbeReadSchema).refine(rows => new Set(rows.map(row => row.id)).size === rows.length, "Повторный ID speed-пробы") })
export type SpeedProbeRead = z.infer<typeof speedProbeReadSchema>
const speedPositiveInteger = z.union([z.number(), z.string().regex(/^[1-9][0-9]*$/).transform(Number)]).pipe(z.number().int().positive().max(Number.MAX_SAFE_INTEGER))
export const speedProbeWriteSchema = z.object({
id: z.string().trim().min(1), srcServerId: speedPositiveInteger, dstServerId: speedPositiveInteger,
srcInterface: z.string().trim(), dstInterface: z.string().trim(),
protocol: z.enum(["tcp", "udp"]), direction: z.enum(["both", "transmit", "receive"]),
durationSec: speedPositiveInteger.pipe(z.number().min(3).max(2147483647)), enabled: z.boolean(),
}).refine(row => row.srcServerId !== row.dstServerId, "Источник и назначение должны отличаться")
export const speedProbesWriteSchema = z.object({ probes: z.array(speedProbeWriteSchema).refine(rows => new Set(rows.map(row => row.id)).size === rows.length, "Повторный ID speed-пробы") })
export const speedProbesSavedSchema = z.object({ ok: z.literal(true), warning: z.string().min(1).optional() })
export type SpeedProbeWrite = z.infer<typeof speedProbeWriteSchema>
+36
View File
@@ -688,3 +688,39 @@ for (const active of [false, true]) test(`Speed interfaces: загрузка и
}
expect(interfaceCalls).toBe(2)
})
for (const invalid of ["2", "10junk", "3.5"]) test("Speed draft rejects invalid duration without PUT/" + invalid, async ({ page }) => {
const control = await setup(page, "/uptime", 0, false, "mm:uptime:write")
await page.getByRole("button", { name: "Пауза", exact: true }).click()
await page.getByRole("button", { name: /Скорость/ }).click()
await page.getByRole("button", { name: "Редактировать speed-пробу", exact: true }).click()
const dialog = page.getByRole("dialog"), input = dialog.locator("input").filter({ visible: true }).last()
await input.fill(invalid)
await dialog.getByRole("button", { name: "Сохранить", exact: true }).click()
await expect(dialog.getByRole("alert")).toContainText("Проверьте speed-пробы")
await expect(dialog).toBeVisible(); expect(control.writes).toEqual([])
})
for (const malformed of [false, true]) test("Speed save requires confirmation and shows scheduler warning/" + malformed, async ({ page }) => {
await setup(page, "/uptime", 0, false, "mm:uptime:write")
await page.getByRole("button", { name: "Пауза", exact: true }).click()
await page.getByRole("button", { name: /Скорость/ }).click()
let calls = 0
await page.route("**/api/uptime/speed-probes", route => {
if (route.request().method() !== "PUT") return route.fallback()
calls++; const body = route.request().postDataJSON()
expect(body.probes[0]).not.toHaveProperty("lastRunAt")
return route.fulfill({ json: malformed ? {} : { ok: true, warning: "Пробы сохранены, но расписание не обновлено. Проверьте состояние сборщика данных." } })
})
await page.getByRole("button", { name: "Редактировать speed-пробу", exact: true }).click()
const dialog = page.getByRole("dialog"), input = dialog.locator("input").filter({ visible: true }).last()
await input.fill("17")
await dialog.getByRole("button", { name: "Сохранить", exact: true }).click()
if (malformed) {
await expect(dialog.getByRole("alert")).toContainText("Ответ о сохранении speed-проб не подтверждён")
await expect(input).toHaveValue("17")
} else {
await expect(dialog).toBeHidden()
await expect(page.getByRole("status").filter({ hasText: "Пробы сохранены, но расписание" })).toBeVisible()
}
expect(calls).toBe(1)
})