diff --git a/app/(main)/uptime/page.tsx b/app/(main)/uptime/page.tsx index eed0ba78..40d336e4 100644 --- a/app/(main)/uptime/page.tsx +++ b/app/(main)/uptime/page.tsx @@ -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(null) const [probeSaveError, setProbeSaveError] = useState(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("/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, 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 && Только просмотрИзменение проб и запуск измерений требуют разрешения на запись. Обновление данных загружает сохранённые результаты.} {permissionError && !sheetOpen && !speedSheetOpen && !deleteTarget && {permissionError}} + {probeSaveWarning && {probeSaveWarning}} {probeSaveError && !sheetOpen && !speedSheetOpen && !deleteTarget && {probeSaveError}} {urlError && Параметры ссылки требуют внимания{urlError}} diff --git a/backend/src/routes/speed-probes-write.test.ts b/backend/src/routes/speed-probes-write.test.ts new file mode 100644 index 00000000..f6cc49d1 --- /dev/null +++ b/backend/src/routes/speed-probes-write.test.ts @@ -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([["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") diff --git a/backend/src/routes/speed-probes-write.ts b/backend/src/routes/speed-probes-write.ts new file mode 100644 index 00000000..23771923 --- /dev/null +++ b/backend/src/routes/speed-probes-write.ts @@ -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 + refreshScheduler: () => Promise +}): 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: "Пробы сохранены, но расписание не обновлено. Проверьте состояние сборщика данных." }) + } + }) + } +} diff --git a/backend/src/routes/uptime.ts b/backend/src/routes/uptime.ts index 87fbd549..38f2bcc7 100644 --- a/backend/src/routes/uptime.ts +++ b/backend/src/routes/uptime.ts @@ -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 { diff --git a/backend/src/services/speed-probe-replacement.ts b/backend/src/services/speed-probe-replacement.ts new file mode 100644 index 00000000..96676e8b --- /dev/null +++ b/backend/src/services/speed-probe-replacement.ts @@ -0,0 +1,16 @@ +import type { SpeedProbeWrite } from "@mmapp/contracts" +export type SpeedProbePatch = SpeedProbeWrite & { sortOrder: number; updatedAt: string } +export type SpeedProbeTransaction = { + listIds: () => Promise + remove: (id: string) => Promise + upsert: (row: SpeedProbePatch, createdAt: string) => Promise +} +export type SpeedProbeStore = { transaction: (run: (tx: SpeedProbeTransaction) => Promise) => Promise } +/** 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 { + 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) + }) +} diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index f88b87e6..3dac84b5 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -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. diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index 8b598845..5a4e5713 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -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. + diff --git a/packages/contracts/src/uptime-speed.ts b/packages/contracts/src/uptime-speed.ts index 77afb330..13e3616c 100644 --- a/packages/contracts/src/uptime-speed.ts +++ b/packages/contracts/src/uptime-speed.ts @@ -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 + +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 diff --git a/tests/e2e/uptime-links.spec.ts b/tests/e2e/uptime-links.spec.ts index a945a952..f02cd5b5 100644 --- a/tests/e2e/uptime-links.spec.ts +++ b/tests/e2e/uptime-links.spec.ts @@ -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) +})