From 5ce21e9a47cb5b425af3097b56a84e83367350e7 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 1 Oct 2026 15:11:32 +0700 Subject: [PATCH] =?UTF-8?q?fix(backups):=20=D0=BE=D1=82=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=80=D0=B5=D0=B7=D1=83=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D0=B0=D1=82=20=D0=BE=D0=BF=D0=B5=D1=80=D0=B0=D1=86=D0=B8=D0=B9?= =?UTF-8?q?=20=D0=BE=D1=82=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE=D0=BA=20=D0=B6?= =?UTF-8?q?=D1=83=D1=80=D0=BD=D0=B0=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package.json | 2 +- backend/src/routes/backups-operations.test.ts | 25 ++++++ backend/src/routes/backups.ts | 82 +++++-------------- .../src/services/backup-job-execution.test.ts | 22 +++++ backend/src/services/backup-job-execution.ts | 24 ++++++ docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 10 ++- docs/ux-ui-stage-11a.md | 8 ++ shared/api/backups.test.ts | 1 + shared/api/backups.ts | 4 +- 10 files changed, 113 insertions(+), 67 deletions(-) create mode 100644 backend/src/routes/backups-operations.test.ts create mode 100644 backend/src/services/backup-job-execution.test.ts create mode 100644 backend/src/services/backup-job-execution.ts diff --git a/backend/package.json b/backend/package.json index 866876bb..616bd691 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,7 +24,7 @@ "test:recursive-routes": "tsx src/routes/recursive-routes.test.ts && tsx src/services/recursive-route-catalog.test.ts", "test:config-sync": "tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts", "test:ros-file": "tsx src/services/ros-file-contents.test.ts", - "test:backups": "tsx src/services/s3-backup-client.test.ts", + "test:backups": "tsx src/services/backup-job-execution.test.ts && tsx src/routes/backups-operations.test.ts && tsx src/services/s3-backup-client.test.ts", "test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts && tsx src/routes/ospf-catalog.test.ts && tsx src/services/ospf-observed-values.test.ts", "test": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers && npm run test:certificates", "test:geoip": "tsx src/services/traffic-flow-geoip.test.ts", diff --git a/backend/src/routes/backups-operations.test.ts b/backend/src/routes/backups-operations.test.ts new file mode 100644 index 00000000..0d1b8caf --- /dev/null +++ b/backend/src/routes/backups-operations.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict" +import Fastify from "fastify" +import { validatorCompiler, serializerCompiler } from "@fastify/type-provider-zod" +import { backupJobResponseSchema } from "@mmapp/contracts/backups" +import { createBackupsRoutes } from "./backups.js" +import { permissionForRequest } from "../lib/permissions.js" +const app = Fastify(); app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler) +let created = 0, restored = 0, eventCalls = 0, restoreFail = false +await app.register(createBackupsRoutes({ listServers: async () => [{ id: 1 }, { id: 2 }], create: async id => { created++; if (id === "2") throw new Error("RouterOS unavailable"); return { id: "backup-1", serverId: 1, serverName: "router", filename: "config.rsc", sizeBytes: 0, createdAt: "2026-10-01T00:00:00Z", kind: "manual", storage: "local" } }, restore: async () => { restored++; if (restoreFail) throw new Error("import failed"); return { filename: "config.rsc", serverName: "router" } }, appendEvent: async () => { eventCalls++; throw new Error("event DB unavailable") } }), { prefix: "/api" }) +const post = (payload: unknown) => app.inject({ method: "POST", url: "/api/backups/create", payload: payload as Record }) +for (const serverIds of [[], ["1", "missing"], ["1", "3"], ["1", "1"], ["0"], ["1junk"], [9007199254740992]]) assert.equal((await post({ serverIds })).statusCode, 400) +assert.equal(created, 0) +const ack = await post({ serverIds: [1, "2"] }); assert.equal(ack.statusCode, 202) +const id = ack.json().jobId +for (let attempt = 0; attempt < 20; attempt++) { + const observation = await app.inject({ method: "GET", url: "/api/backups/jobs/" + id }) + const job = backupJobResponseSchema.parse(observation.json()) + if (job.status === "done") { assert.equal(job.completed, 2); assert.equal(job.created.length, 1); assert.deepEqual(job.failures, [{ serverId: "2", error: "RouterOS unavailable" }]); break } + await new Promise(resolve => setImmediate(resolve)); assert.ok(attempt < 19) +} +assert.equal(created, 2); assert.ok(eventCalls >= 2) +const success = await app.inject({ method: "POST", url: "/api/backups/backup-1/restore" }); assert.equal(success.statusCode, 200); assert.deepEqual(success.json(), { filename: "config.rsc", serverName: "router" }); assert.equal(restored, 1) +restoreFail = true; const failure = await app.inject({ method: "POST", url: "/api/backups/backup-1/restore" }); assert.equal(failure.statusCode, 400); assert.equal(failure.json().error, "import failed") +assert.equal(permissionForRequest("POST", "/api/backups/create"), "mm:backups:write"); assert.equal(permissionForRequest("POST", "/api/backups/backup-1/restore"), "mm:backups:write"); assert.equal(permissionForRequest("GET", "/api/backups/jobs/job"), "mm:backups:read") +await app.close(); console.log("Backups HTTP: event failure does not relabel created/restore, invalid targets have no side effects, partial results and RBAC") diff --git a/backend/src/routes/backups.ts b/backend/src/routes/backups.ts index c5c0c15b..fe3a00a5 100644 --- a/backend/src/routes/backups.ts +++ b/backend/src/routes/backups.ts @@ -2,6 +2,8 @@ import { randomUUID } from "node:crypto" import { z } from "zod" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import { + createBackupRequestSchema, + type BackupJobStatusResponse, putBackupScheduleSettingsSchema, putBackupStorageSettingsSchema, } from "@mmapp/contracts/backups" @@ -21,28 +23,11 @@ import { testBackupStorageConnection, updateBackupScheduleSettings, updateBackupStorageSettings, - type BackupMeta, } from "../services/backup-service.js" -type BackupJobStatus = "queued" | "running" | "done" | "failed" -type BackupJob = { - id: string - status: BackupJobStatus - requestedAt: string - startedAt?: string - finishedAt?: string - total: number - completed: number - created: BackupMeta[] - failures: Array<{ serverId: string; error: string }> -} +import { processBackupJob, recordBackupEvent } from "../services/backup-job-execution.js" -const backupJobs = new Map() - -const CreateBackupBodySchema = z.object({ - serverIds: z.array(z.union([z.string(), z.number()])).min(1), - notes: z.string().max(500).optional(), -}) +type BackupJob = BackupJobStatusResponse const BackupIdParamSchema = z.object({ id: z.string().min(1), @@ -51,40 +36,10 @@ const BackupJobIdParamSchema = z.object({ jobId: z.string().min(1), }) -async function processBackupJob(job: BackupJob, ids: string[], notes?: string) { - job.status = "running" - job.startedAt = new Date().toISOString() - for (const id of ids) { - try { - const meta = await runBackupForServer(id, "manual", notes) - job.created.push(meta) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - job.failures.push({ serverId: id, error: message }) - } finally { - job.completed += 1 - } - } - job.status = "done" - job.finishedAt = new Date().toISOString() - await appendEvent({ - level: job.failures.length > 0 ? "warning" : "info", - eventType: "backups.job.done", - sourceModule: "backups", - title: job.failures.length > 0 ? "Бэкап завершен с ошибками" : "Бэкап завершен", - message: `Создано: ${job.created.length}, ошибок: ${job.failures.length}`, - entityType: "backup_job", - entityId: job.id, - payload: { - total: job.total, - completed: job.completed, - failures: job.failures, - notes: notes ?? null, - }, - }) -} - -const backupsRoutes: FastifyPluginAsyncZod = async (app) => { +export function createBackupsRoutes(deps: { listServers?: () => Promise>; create?: typeof runBackupForServer; restore?: typeof restoreBackupToDevice; appendEvent?: typeof appendEvent } = {}): FastifyPluginAsyncZod { + return async (app) => { + const backupJobs = new Map() + const event = (input: Parameters[0]) => recordBackupEvent(input, deps.appendEvent ?? appendEvent, error => app.log.warn({ err: error }, "Не удалось записать событие резервных копий")) app.get("/backups", async (_req, reply) => { return reply.send(await listBackups()) }) @@ -136,12 +91,12 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => { } }) - app.post("/backups/create", { schema: { body: CreateBackupBodySchema } }, async (req, reply) => { + app.post("/backups/create", { schema: { body: createBackupRequestSchema } }, async (req, reply) => { const inputIds = req.body.serverIds.map((x) => String(x)) const notes = req.body.notes?.trim() || undefined - const existingServers = new Set((await listServersRead()).map((s) => String(s.id))) - const ids = [...new Set(inputIds)].filter((id) => existingServers.has(id)) - if (ids.length === 0) return reply.status(400).send({ error: "Не выбраны валидные серверы" }) + const existingServers = new Set((await (deps.listServers ?? listServersRead)()).map((s) => String(s.id))) + const ids = [...new Set(inputIds)] + if (ids.some(id => !existingServers.has(id))) return reply.status(400).send({ error: "Выбранный сервер отсутствует в каталоге; обновите выбор" }) const jobId = randomUUID() const job: BackupJob = { @@ -154,7 +109,7 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => { failures: [], } backupJobs.set(jobId, job) - await appendEvent({ + await event({ level: "info", eventType: "backups.job.started", sourceModule: "backups", @@ -168,7 +123,7 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => { }, }) queueMicrotask(async () => { - void processBackupJob(job, ids, notes).catch(async (err) => { + void processBackupJob(job, ids, notes, { create: deps.create ?? runBackupForServer, event }).catch(async (err) => { job.status = "failed" job.finishedAt = new Date().toISOString() const failure = { @@ -176,7 +131,7 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => { error: err instanceof Error ? err.message : String(err), } job.failures.push(failure) - await appendEvent({ + await event({ level: "critical", eventType: "backups.job.failed", sourceModule: "backups", @@ -220,8 +175,8 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => { app.post("/backups/:id/restore", { schema: { params: BackupIdParamSchema } }, async (req, reply) => { try { - const result = await restoreBackupToDevice(req.params.id) - await appendEvent({ + const result = await (deps.restore ?? restoreBackupToDevice)(req.params.id) + await event({ level: "warning", eventType: "backups.restore", sourceModule: "backups", @@ -243,6 +198,7 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => { if (!hit) return reply.status(404).send({ error: "Бэкап не найден" }) return reply.status(204).send() }) + } } -export default backupsRoutes +export default createBackupsRoutes() diff --git a/backend/src/services/backup-job-execution.test.ts b/backend/src/services/backup-job-execution.test.ts new file mode 100644 index 00000000..662d2c5f --- /dev/null +++ b/backend/src/services/backup-job-execution.test.ts @@ -0,0 +1,22 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import type { BackupJobStatusResponse } from "@mmapp/contracts/backups" +import { backupJobResponseSchema } from "@mmapp/contracts/backups" +import { processBackupJob, recordBackupEvent } from "./backup-job-execution.js" +const item = { id: "backup", serverId: 1, serverName: "router", filename: "config.rsc", sizeBytes: 0, createdAt: "2026-10-01T00:00:00Z", kind: "manual" as const, storage: "local" as const } +const job = (): BackupJobStatusResponse => ({ id: "job", status: "queued", requestedAt: "2026-10-01T00:00:00Z", total: 2, completed: 0, created: [], failures: [] }) +test("event failure preserves done and actual created files", async () => { + const row = job(), warnings: unknown[] = [] + await processBackupJob(row, ["1", "2"], undefined, { create: async id => ({ ...item, id, serverId: Number(id) }), event: input => recordBackupEvent(input, async () => { throw new Error("event unavailable") }, error => { warnings.push(error) }) }) + assert.equal(row.status, "done"); assert.equal(row.created.length, 2); assert.equal(row.failures.length, 0); assert.equal(warnings.length, 1); backupJobResponseSchema.parse(row) +}) +test("one failed device keeps partial files and completes every target", async () => { + const row = job() + await processBackupJob(row, ["1", "2"], undefined, { create: async id => { if (id === "2") throw new Error("device unavailable"); return item }, event: async input => { assert.equal(input.level, "warning") } }) + assert.equal(row.status, "done"); assert.equal(row.completed, 2); assert.equal(row.created.length, 1); assert.deepEqual(row.failures, [{ serverId: "2", error: "device unavailable" }]); backupJobResponseSchema.parse(row) +}) +test("S3 upload error remains a local copy and a warning rather than full success", async () => { + const row = job(); row.total = 1 + await processBackupJob(row, ["1"], undefined, { create: async () => ({ ...item, uploadError: "S3 unavailable" }), event: async input => { assert.equal(input.level, "warning"); assert.match(input.message, /ошибок загрузки: 1/) } }) + assert.equal(row.status, "done"); assert.equal(row.created[0].storage, "local"); assert.equal(row.created[0].uploadError, "S3 unavailable"); backupJobResponseSchema.parse(row) +}) diff --git a/backend/src/services/backup-job-execution.ts b/backend/src/services/backup-job-execution.ts new file mode 100644 index 00000000..e1f14fca --- /dev/null +++ b/backend/src/services/backup-job-execution.ts @@ -0,0 +1,24 @@ +import type { BackupJobStatusResponse } from "@mmapp/contracts/backups" +import type { AppendEventBody } from "@mmapp/contracts/events" +import type { BackupMeta } from "./backup-service.js" + +export async function recordBackupEvent(input: AppendEventBody, append: (input: AppendEventBody) => Promise, warn: (error: unknown) => void): Promise { + try { await append(input) } catch (error) { warn(error) } +} +export async function processBackupJob(job: BackupJobStatusResponse, ids: string[], notes: string | undefined, deps: { + create: (id: string, kind: "manual", notes?: string) => Promise + event: (input: AppendEventBody) => Promise +}) { + job.status = "running" + job.startedAt = new Date().toISOString() + for (const id of ids) { + try { job.created.push(await deps.create(id, "manual", notes)) } + catch (error) { job.failures.push({ serverId: id, error: error instanceof Error ? error.message : String(error) }) } + finally { job.completed += 1 } + } + job.status = "done" + job.finishedAt = new Date().toISOString() + const uploadFailures = job.created.filter(item => item.uploadError) + const partial = job.failures.length > 0 || uploadFailures.length > 0 + await deps.event({ level: partial ? "warning" : "info", eventType: "backups.job.done", sourceModule: "backups", title: partial ? "Бэкап завершен с ошибками" : "Бэкап завершен", message: "Создано: " + job.created.length + ", ошибок устройств: " + job.failures.length + ", ошибок загрузки: " + uploadFailures.length, entityType: "backup_job", entityId: job.id, payload: { total: job.total, completed: job.completed, failures: job.failures, uploadFailures: uploadFailures.map(item => ({ id: item.id, error: item.uploadError })), notes: notes ?? null } }) +} diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index b37a00c1..406877b3 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -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А.26 — достоверность backend операций резервных копий при отказах событий, затем формы настроек и операции. Каталоги/source/права реализованы в 11А.25. Сертификаты приняты локально в 11А.24, backups API стал строгим. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Резервные копии и весь 11А ещё не приняты. +Актуальная точка продолжения: 11А.27 — достоверные формы расписания/хранилища резервных копий, затем создание/checkpoint/polling и остальные операции. Каталоги/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А ещё не приняты. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 8686d96b..3e5a5288 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -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А.25; пользователи/контейнеры/сертификаты приняты локально, каталоги резервных копий подключены; далее 11А.26 | +| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.26; пользователи/контейнеры/сертификаты приняты локально, каталоги/API/ручной runner резервных копий исправлены; далее 11А.27 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,14 @@ +## Блок 11А.26 — результат операций отделён от событий (2026-10-01) + +- Ручной job и restore используют безопасную запись событий: отказ журнала не прерывает запуск, не превращает созданные файлы в failed и не возвращает ошибку после подтверждённого импорта RouterOS. Process job вынесен в тестируемую функцию; map задач локален экземпляру routes. +- Создание использует общий строгий request контракт, поддерживает прежние numeric IDs. Дубли/невалидные/отсутствующие выбранные серверы отклоняются до создания задачи; молчаливое выполнение только части запроса удалено. Frontend ack проверяет total всех выбранных целей. +- Персональные ошибки остаются partial done с created/failures. Ошибка S3 загрузки сохраняет локальную копию и uploadError; итоговое событие имеет warning и число ошибок загрузки. Frontend polling пока требует отдельной доработки, чтобы не называть такой done полным успехом. +- 3 fault unit job, HTTP handler проверки create/restore при отказах событий, invalid target/no side effect/partial/RBAC, test:backups (включая S3 client), backend production build, 6 frontend API unit, TypeScript/профильный lint прошли. Реальный PostgreSQL/RouterOS/S3 не проверен; jobs in-memory теряются после рестарта, requestId/idempotency ещё не реализованы. +- **Следующий блок 11А.27:** достоверные формы расписания/хранилища, independent known состояния, dirty/секреты/partial save/общий mutation lock и S3 test без скрытого сохранения. Далее 11А.28 создание/checkpoint/polling; scheduler event/setup cleanup дополнительно проверить в 11А.26.1 или 11Б. + ## Блок 11А.25 — независимые каталоги резервных копий (2026-10-01) - После hydration страница привязана к mode/backend. Live не начинает с демонстрационных строк; mock использует локальный каталог с явной подписью и отключёнными операциями. Запросы отменяются при unmount/новом чтении, revision подавляет поздние ответы. diff --git a/docs/ux-ui-stage-11a.md b/docs/ux-ui-stage-11a.md index d187c62c..650db026 100644 --- a/docs/ux-ui-stage-11a.md +++ b/docs/ux-ui-stage-11a.md @@ -320,3 +320,11 @@ - Фоновое чтение не сбрасывает dirty черновики. Сводка расписания/назначения берётся из подтверждённых DTO; draft не называется сохранённым. UI настроек пока ожидает оба DTO, отдельные формы/валидация/частичное сохранение входят в следующий блок. - 12 data E2E + 2 существующих table/Filters E2E + 4 context E2E, полный TypeScript, профильный ESLint и production frontend прошли. Реальные PostgreSQL/RouterOS/S3 не проверены. - **Следующий блок 11А.26:** backend достоверность резервного копирования/restore при сбоях журнала событий. Затем 11А.27 формы настроек, dirty/секреты/проверка S3/общий lock, создание и polling, delete/restore/download, URL и визуальная приёмка. Весь backups ещё не принят. + +## Блок 11А.26 — результат операций отделён от событий (2026-10-01) + +- Ручной job и restore используют безопасную запись событий: отказ журнала не прерывает запуск, не превращает созданные файлы в failed и не возвращает ошибку после подтверждённого импорта RouterOS. Process job вынесен в тестируемую функцию; map задач локален экземпляру routes. +- Создание использует общий строгий request контракт, поддерживает прежние numeric IDs. Дубли/невалидные/отсутствующие выбранные серверы отклоняются до создания задачи; молчаливое выполнение только части запроса удалено. Frontend ack проверяет total всех выбранных целей. +- Персональные ошибки остаются partial done с created/failures. Ошибка S3 загрузки сохраняет локальную копию и uploadError; итоговое событие имеет warning и число ошибок загрузки. Frontend polling пока требует отдельной доработки, чтобы не называть такой done полным успехом. +- 3 fault unit job, HTTP handler проверки create/restore при отказах событий, invalid target/no side effect/partial/RBAC, test:backups (включая S3 client), backend production build, 6 frontend API unit, TypeScript/профильный lint прошли. Реальный PostgreSQL/RouterOS/S3 не проверен; jobs in-memory теряются после рестарта, requestId/idempotency ещё не реализованы. +- **Следующий блок 11А.27:** достоверные формы расписания/хранилища, independent known состояния, dirty/секреты/partial save/общий mutation lock и S3 test без скрытого сохранения. Далее 11А.28 создание/checkpoint/polling; scheduler event/setup cleanup дополнительно проверить в 11А.26.1 или 11Б. diff --git a/shared/api/backups.test.ts b/shared/api/backups.test.ts index e9cc5ffb..f00d8883 100644 --- a/shared/api/backups.test.ts +++ b/shared/api/backups.test.ts @@ -44,6 +44,7 @@ test("invalid mutations fail before HTTP; create receives job acknowledgment", a assert.equal(calls, 0) assert.equal((await createBackupsAsync(base, { serverIds: ["1"], notes: "manual" })).jobId, "job-1") assert.deepEqual(body, { serverIds: ["1"], notes: "manual" }) + globalThis.fetch = async () => json({ jobId: "job-1", status: "queued", total: 2, completed: 0 }, 202); await assert.rejects(() => createBackupsAsync(base, { serverIds: ["1"] }), /всех выбранных/); globalThis.fetch = async () => json({ created: [item], failures: [] }); await assert.rejects(() => createBackupsAsync(base, { serverIds: ["1"] })) } finally { globalThis.fetch = original } }) diff --git a/shared/api/backups.ts b/shared/api/backups.ts index 3593b21e..281a940f 100644 --- a/shared/api/backups.ts +++ b/shared/api/backups.ts @@ -27,7 +27,9 @@ export async function listBackups(baseUrl: string, options?: RequestInit) { } export async function createBackupsAsync(baseUrl: string, payload: { serverIds: string[]; notes?: string }, options?: RequestInit) { const body = createBackupRequestSchema.parse(payload) - return createBackupJobResponseSchema.parse(await backupJson(baseUrl, "/api/backups/create", { ...options, method: "POST", body: JSON.stringify(body) })) + const result = createBackupJobResponseSchema.parse(await backupJson(baseUrl, "/api/backups/create", { ...options, method: "POST", body: JSON.stringify(body) })) + if (result.total !== body.serverIds.length) throw new Error("Не подтверждено резервное копирование всех выбранных серверов") + return result } export async function getBackupJob(baseUrl: string, jobId: string, options?: RequestInit) { const result = backupJobResponseSchema.parse(await backupJson(baseUrl, "/api/backups/jobs/" + encodeURIComponent(jobId), { ...options, method: "GET" }))