fix(backups): сохранить идентичность фоновой задачи

This commit is contained in:
Denozordec committed 2026-10-01 15:44:02 +07:00
1 parent 30bd479a6f
commit 4aee2a34f1
13 files changed
+157 -12

No files matched your search

@@ -19,6 +19,13 @@ for (let attempt = 0; attempt < 20; attempt++) {
await new Promise(resolve => setImmediate(resolve)); assert.ok(attempt < 19) await new Promise(resolve => setImmediate(resolve)); assert.ok(attempt < 19)
} }
assert.equal(created, 2); assert.ok(eventCalls >= 2) assert.equal(created, 2); assert.ok(eventCalls >= 2)
const requestId = "11111111-1111-4111-8111-111111111111", before = created
const repeated = await Promise.all([post({ requestId, serverIds: ["1"], notes: " manual " }), post({ requestId, serverIds: [1], notes: "manual" })])
assert.ok(repeated.every(response => response.statusCode === 202 && response.json().jobId === requestId))
await new Promise(resolve => setImmediate(resolve)); assert.equal(created, before + 1)
assert.equal((await post({ requestId, serverIds: ["1"], notes: "manual" })).statusCode, 202); assert.equal(created, before + 1)
assert.equal((await post({ requestId, serverIds: ["2"], notes: "manual" })).statusCode, 409)
assert.equal((await post({ requestId, serverIds: ["1"], notes: "other" })).statusCode, 409)
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) 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") 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") 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")
+15 -2
View File
@@ -94,13 +94,20 @@ export function createBackupsRoutes(deps: { listServers?: () => Promise<Array<{
app.post("/backups/create", { schema: { body: createBackupRequestSchema } }, async (req, reply) => { app.post("/backups/create", { schema: { body: createBackupRequestSchema } }, async (req, reply) => {
const inputIds = req.body.serverIds.map((x) => String(x)) const inputIds = req.body.serverIds.map((x) => String(x))
const notes = req.body.notes?.trim() || undefined const notes = req.body.notes?.trim() || undefined
const previous = req.body.requestId ? backupJobs.get(req.body.requestId) : undefined
if (previous) {
if (JSON.stringify(previous.serverIds) !== JSON.stringify(inputIds) || previous.notes !== notes) return reply.status(409).send({ error: "Request ID уже относится к другой задаче" })
return reply.status(202).send({ jobId: previous.id, status: previous.status, total: previous.total, completed: previous.completed, serverIds: previous.serverIds })
}
const existingServers = new Set((await (deps.listServers ?? listServersRead)()).map((s) => String(s.id))) const existingServers = new Set((await (deps.listServers ?? listServersRead)()).map((s) => String(s.id)))
const ids = [...new Set(inputIds)] const ids = [...new Set(inputIds)]
if (ids.some(id => !existingServers.has(id))) return reply.status(400).send({ error: "Выбранный сервер отсутствует в каталоге; обновите выбор" }) if (ids.some(id => !existingServers.has(id))) return reply.status(400).send({ error: "Выбранный сервер отсутствует в каталоге; обновите выбор" })
const jobId = randomUUID() const jobId = req.body.requestId ?? randomUUID()
const job: BackupJob = { const job: BackupJob = {
id: jobId, id: jobId,
serverIds: ids,
notes,
status: "queued", status: "queued",
requestedAt: new Date().toISOString(), requestedAt: new Date().toISOString(),
total: ids.length, total: ids.length,
@@ -108,8 +115,13 @@ export function createBackupsRoutes(deps: { listServers?: () => Promise<Array<{
created: [], created: [],
failures: [], failures: [],
} }
const raced = backupJobs.get(jobId)
if (raced) {
if (JSON.stringify(raced.serverIds) !== JSON.stringify(ids) || raced.notes !== notes) return reply.status(409).send({ error: "Request ID уже относится к другой задаче" })
return reply.status(202).send({ jobId, status: raced.status, total: raced.total, completed: raced.completed, serverIds: raced.serverIds })
}
backupJobs.set(jobId, job) backupJobs.set(jobId, job)
await event({ void event({
level: "info", level: "info",
eventType: "backups.job.started", eventType: "backups.job.started",
sourceModule: "backups", sourceModule: "backups",
@@ -151,6 +163,7 @@ export function createBackupsRoutes(deps: { listServers?: () => Promise<Array<{
status: job.status, status: job.status,
total: job.total, total: job.total,
completed: job.completed, completed: job.completed,
serverIds: job.serverIds,
}) })
}) })
@@ -4,7 +4,7 @@ import type { BackupJobStatusResponse } from "@mmapp/contracts/backups"
import { backupJobResponseSchema } from "@mmapp/contracts/backups" import { backupJobResponseSchema } from "@mmapp/contracts/backups"
import { processBackupJob, recordBackupEvent } from "./backup-job-execution.js" 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 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: [] }) const job = (): BackupJobStatusResponse => ({ id: "job", serverIds: ["1", "2"], status: "queued", requestedAt: "2026-10-01T00:00:00Z", total: 2, completed: 0, created: [], failures: [] })
test("event failure preserves done and actual created files", async () => { test("event failure preserves done and actual created files", async () => {
const row = job(), warnings: unknown[] = [] 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) }) }) 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) }) })
@@ -16,7 +16,7 @@ test("one failed device keeps partial files and completes every target", async (
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) 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 () => { test("S3 upload error remains a local copy and a warning rather than full success", async () => {
const row = job(); row.total = 1 const row = job(); row.total = 1; row.serverIds = ["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/) } }) 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) assert.equal(row.status, "done"); assert.equal(row.created[0].storage, "local"); assert.equal(row.created[0].uploadError, "S3 unavailable"); backupJobResponseSchema.parse(row)
}) })
+1 -1
View File
@@ -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/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) | | Контейнеры | [Обзор](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А.28 — создание/checkpoint/polling резервных копий и подтверждение partial результата, затем остальные операции/URL/приёмка. Настройки расписания/S3 реализованы в 11А.27.1–11А.27.2. Каталоги/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А ещё не приняты. Актуальная точка продолжения: 11А.28.2 — frontend checkpoint/создание/serial polling резервных копий и подтверждение partial результата, затем остальные операции/URL/приёмка. API/основа наблюдения реализованы в 11А.28.1. Настройки расписания/S3 реализованы в 11А.27.1–11А.27.2. Каталоги/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А ещё не приняты.
+10 -1
View File
@@ -27,7 +27,7 @@
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.27.2; пользователи/контейнеры/сертификаты приняты локально; резервные копии: каталоги/API/runner/настройки; далее 11А.28 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.28.1; пользователи/контейнеры/сертификаты приняты локально; резервные копии: настройки/идентичность job, далее 11А.28.2 |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,15 @@
## Блок 11А.28.1 — идентичность задачи и основа наблюдения (2026-10-01)
- Create request принимает необязательный UUID requestId; job/ack содержат canonical serverIds, job содержит заметку. Повтор той же цели/заметки в том же backend процессе возвращает прежний job; конфликт ID возвращает 409. Конкурентный повтор после async catalog lookup резервируется одним Map insert, без второй очереди. Старые клиенты без ID получают отдельные UUID.
- Событие запуска записывается независимо от очереди/ack, чтобы медленный журнал не держал запуск. GET job контракт проверяет цель, число устройств и принадлежащие ей created/failures. API ack проверяет request ID/все serverIds, неизвестное тело не подтверждает операцию.
- Добавлены строгий checkpoint по backend URL и serial polling helper: immutable request до POST, cross-check наблюдения, AbortSignal/срок чтения/наблюдения, read failure/timeout не становятся failed server job. Outcome отличает полный успех от device failures и uploadError.
- 16 frontend API/checkpoint/poll unit, backend test:backups с HTTP concurrent retry/conflict/RBAC, backend production build, полный TypeScript/профильный ESLint прошли. UI пока использует старое создание/setInterval; подключение в следующем блоке.
- Jobs/idempotency Map существуют только в текущем процессе. После рестарта API может вернуть 404 для ранее принятой операции; автоматический повтор неизвестного POST не реализован и недопустим без durable repository. Реальные DB/RouterOS/S3/рестарт не проверены.
- **Следующий блок 11А.28.2:** frontend checkpoint до POST, общий lock формы, GET-only resume/после reload/неизвестного ответа, точный partial результат, поздние ответы источника и профильные E2E. Затем остальные операции/URL/визуальная приёмка.
## Блок 11А.27.2 — достоверные настройки и последняя проверка S3 (2026-10-01) ## Блок 11А.27.2 — достоверные настройки и последняя проверка S3 (2026-10-01)
- S3 форма валидирует обязательные Bucket/Access key/Secret key или наличие сохранённого секрета, HTTP(S) Endpoint. Пустое secret поле оставляет существующий ключ; пробельный новый ключ отклоняется. Поля и toggles имеют доступные имена. Dirty настройка имеет статус «Не сохранено», показ/скрытие секрета не считается изменением конфигурации. - S3 форма валидирует обязательные Bucket/Access key/Secret key или наличие сохранённого секрета, HTTP(S) Endpoint. Пустое secret поле оставляет существующий ключ; пробельный новый ключ отклоняется. Поля и toggles имеют доступные имена. Dirty настройка имеет статус «Не сохранено», показ/скрытие секрета не считается изменением конфигурации.
+9
View File
@@ -345,3 +345,12 @@
- Совместимость строгого backup API с PostgreSQL подтверждена mapper unit: raw timestamp преобразуется в ISO того же момента. Null server/0 byte/uploadError сохраняются; invalid дата/размер отклоняются. Сравнение timestamp receipt учитывает представление PostgreSQL, а не только строковое равенство. - Совместимость строгого backup API с PostgreSQL подтверждена mapper unit: raw timestamp преобразуется в ISO того же момента. Null server/0 byte/uploadError сохраняются; invalid дата/размер отклоняются. Сравнение timestamp receipt учитывает представление PostgreSQL, а не только строковое равенство.
- 28 data/settings/storage E2E, 11 frontend helper/API unit, 3 DTO + 3 storage update + 3 job fault unit и HTTP/S3 профиль, frontend/backend production build, TypeScript/профильный ESLint прошли. Реальные PostgreSQL/RouterOS/S3 и конкурентные внешние клиенты не проверены. - 28 data/settings/storage E2E, 11 frontend helper/API unit, 3 DTO + 3 storage update + 3 job fault unit и HTTP/S3 профиль, frontend/backend production build, TypeScript/профильный ESLint прошли. Реальные PostgreSQL/RouterOS/S3 и конкурентные внешние клиенты не проверены.
- **Следующий блок 11А.28:** single-flight создание, immutable цель/checkpoint, наблюдение без setInterval гонок и ложного полного успеха при failures/uploadError. Jobs сейчас in-memory: рестарт backend означает потерю наблюдения; автоматический повтор неизвестного POST недопустим. Затем delete/restore/download, URL, scheduler cleanup и визуальная приёмка. - **Следующий блок 11А.28:** single-flight создание, immutable цель/checkpoint, наблюдение без setInterval гонок и ложного полного успеха при failures/uploadError. Jobs сейчас in-memory: рестарт backend означает потерю наблюдения; автоматический повтор неизвестного POST недопустим. Затем delete/restore/download, URL, scheduler cleanup и визуальная приёмка.
## Блок 11А.28.1 — идентичность задачи и основа наблюдения (2026-10-01)
- Create request принимает необязательный UUID requestId; job/ack содержат canonical serverIds, job содержит заметку. Повтор той же цели/заметки в том же backend процессе возвращает прежний job; конфликт ID возвращает 409. Конкурентный повтор после async catalog lookup резервируется одним Map insert, без второй очереди. Старые клиенты без ID получают отдельные UUID.
- Событие запуска записывается независимо от очереди/ack, чтобы медленный журнал не держал запуск. GET job контракт проверяет цель, число устройств и принадлежащие ей created/failures. API ack проверяет request ID/все serverIds, неизвестное тело не подтверждает операцию.
- Добавлены строгий checkpoint по backend URL и serial polling helper: immutable request до POST, cross-check наблюдения, AbortSignal/срок чтения/наблюдения, read failure/timeout не становятся failed server job. Outcome отличает полный успех от device failures и uploadError.
- 16 frontend API/checkpoint/poll unit, backend test:backups с HTTP concurrent retry/conflict/RBAC, backend production build, полный TypeScript/профильный ESLint прошли. UI пока использует старое создание/setInterval; подключение в следующем блоке.
- Jobs/idempotency Map существуют только в текущем процессе. После рестарта API может вернуть 404 для ранее принятой операции; автоматический повтор неизвестного POST не реализован и недопустим без durable repository. Реальные DB/RouterOS/S3/рестарт не проверены.
- **Следующий блок 11А.28.2:** frontend checkpoint до POST, общий lock формы, GET-only resume/после reload/неизвестного ответа, точный partial результат, поздние ответы источника и профильные E2E. Затем остальные операции/URL/визуальная приёмка.
+17
View File
@@ -0,0 +1,17 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { backupJobCheckpointSchema, createBackupJobCheckpoint, readBackupJobCheckpoint, backupJobStorageKey } from "./backup-job-checkpoint"
const id = "11111111-1111-4111-8111-111111111111"
test("checkpoint freezes a canonical request before acknowledgment", () => {
const ids = ["1", "2"], saved = createBackupJobCheckpoint({ serverIds: ids, notes: " manual " }, id); ids.push("3")
assert.deepEqual(saved.request.serverIds, ["1", "2"]); assert.equal(saved.request.notes, "manual"); assert.equal(saved.accepted, false); assert.deepEqual(readBackupJobCheckpoint(JSON.stringify(saved)), saved)
})
test("corrupt or foreign checkpoints cannot be restored", () => {
assert.throws(() => readBackupJobCheckpoint("bad")); assert.throws(() => createBackupJobCheckpoint({ serverIds: ["1", "1"] }, id))
const saved = createBackupJobCheckpoint({ serverIds: ["1"] }, id)
const job = { id: "other", status: "queued", serverIds: ["1"], total: 1, completed: 0, requestedAt: "2026-10-01T00:00:00Z", created: [], failures: [] }
assert.equal(backupJobCheckpointSchema.safeParse({ ...saved, accepted: true, job }).success, false)
})
test("storage keys preserve backend scope and absent storage stays absent", () => {
assert.notEqual(backupJobStorageKey("one"), backupJobStorageKey("two")); assert.equal(readBackupJobCheckpoint(null), null)
})
+14
View File
@@ -0,0 +1,14 @@
import { z } from "zod"
import { backupJobResponseSchema, createBackupRequestSchema } from "@mmapp/contracts/backups"
import type { CreateBackupRequest } from "@mmapp/contracts/backups"
const requestSchema = createBackupRequestSchema.extend({ requestId: z.string().uuid() })
export const backupJobCheckpointSchema = z.object({ request: requestSchema, accepted: z.boolean(), job: backupJobResponseSchema.nullable() }).superRefine((value, ctx) => {
const job = value.job
if (job && (!value.accepted || job.id !== value.request.requestId || JSON.stringify(job.serverIds) !== JSON.stringify(value.request.serverIds.map(String)) || (job.notes ?? "") !== (value.request.notes ?? ""))) ctx.addIssue({ code: "custom", message: "Наблюдение относится к другой задаче" })
})
export type BackupJobCheckpoint = z.infer<typeof backupJobCheckpointSchema>
export function createBackupJobCheckpoint(request: CreateBackupRequest, requestId: string): BackupJobCheckpoint {
return backupJobCheckpointSchema.parse({ request: { ...request, requestId, serverIds: request.serverIds.map(String), notes: request.notes?.trim() || undefined }, accepted: false, job: null })
}
export function readBackupJobCheckpoint(value: string | null): BackupJobCheckpoint | null { return value ? backupJobCheckpointSchema.parse(JSON.parse(value)) : null }
export function backupJobStorageKey(backendUrl: string) { return "mmapp:backup-job:" + backendUrl }
+32
View File
@@ -0,0 +1,32 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { pollBackupJob, backupJobOutcome, waitForBackupPoll } from "./backup-job-poll"
import type { BackupJobStatusResponse } from "@mmapp/contracts/backups"
const target = { serverIds: ["1"], notes: "manual" }
const baseJob: BackupJobStatusResponse = { id: "job", status: "queued", serverIds: ["1"], notes: "manual", total: 1, completed: 0, requestedAt: "2026-10-01T00:00:00Z", created: [], failures: [] }
const file = { 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 }
test("serial queued/running reads complete only on terminal observation", async () => {
let reads = 0, inFlight = 0, maximum = 0
const job = await pollBackupJob("base", "job", target, { signal: new AbortController().signal, wait: async () => {}, readJob: async () => { inFlight++; maximum = Math.max(maximum, inFlight); await Promise.resolve(); const status = ++reads < 3 ? "running" : "done"; inFlight--; return { ...baseJob, status, ...(status === "done" ? { completed: 1, created: [file], finishedAt: "2026-10-01T00:01:00Z" } : {}) } } })
assert.equal(reads, 3); assert.equal(maximum, 1); assert.equal(backupJobOutcome(job).fullSuccess, true)
})
test("partial device and upload errors never become full success", () => {
for (const patch of [{ created: [], failures: [{ serverId: "1", error: "offline" }] }, { created: [{ ...file, uploadError: "S3 unavailable" }], failures: [] }]) {
assert.equal(backupJobOutcome({ ...baseJob, status: "done", completed: 1, ...patch }).fullSuccess, false)
}
})
test("foreign target is rejected before onJob", async () => {
let observations = 0
await assert.rejects(() => pollBackupJob("base", "job", target, { signal: new AbortController().signal, onJob: () => { observations++ }, readJob: async () => ({ ...baseJob, serverIds: ["2"] }) }), /не соответствует/)
assert.equal(observations, 0)
})
test("read failure or exhausted observation budget is not a failed job", async () => {
await assert.rejects(() => pollBackupJob("base", "job", target, { signal: new AbortController().signal, readJob: async () => { throw new Error("GET unavailable") } }), /GET unavailable/)
await assert.rejects(() => pollBackupJob("base", "job", target, { signal: new AbortController().signal, attempts: 1, readJob: async () => baseJob }), /Время наблюдения/)
})
test("cancellation interrupts delay and suppresses late observation", async () => {
const abort = new AbortController(), wait = waitForBackupPoll(10_000, abort.signal); abort.abort(); await assert.rejects(wait)
const late = new AbortController(); let calls = 0
await assert.rejects(() => pollBackupJob("base", "job", target, { signal: late.signal, onJob: () => { calls++ }, readJob: async () => { late.abort(); return baseJob } }))
assert.equal(calls, 0)
})
+29
View File
@@ -0,0 +1,29 @@
import type { BackupJobStatusResponse, CreateBackupRequest } from "@mmapp/contracts/backups"
import { getBackupJob } from "@/shared/api/backups"
export function waitForBackupPoll(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) { reject(signal.reason); return }
const abort = () => { clearTimeout(timer); signal.removeEventListener("abort", abort); reject(signal.reason) }
const timer = setTimeout(() => { signal.removeEventListener("abort", abort); resolve() }, ms)
signal.addEventListener("abort", abort, { once: true })
})
}
export async function pollBackupJob(baseUrl: string, jobId: string, target: CreateBackupRequest, options: {
signal: AbortSignal; onJob?: (job: BackupJobStatusResponse) => void; readJob?: typeof getBackupJob; wait?: typeof waitForBackupPoll; attempts?: number; intervalMs?: number
}) {
const signal = AbortSignal.any([options.signal, AbortSignal.timeout(300_000)])
const readJob = options.readJob ?? getBackupJob, wait = options.wait ?? waitForBackupPoll, attempts = options.attempts ?? 150
for (let index = 0; index < attempts; index++) {
signal.throwIfAborted()
const job = await readJob(baseUrl, jobId, { signal }); signal.throwIfAborted()
if (job.id !== jobId || JSON.stringify(job.serverIds) !== JSON.stringify(target.serverIds.map(String)) || (job.notes ?? "") !== (target.notes?.trim() ?? "")) throw new Error("Ответ задачи не соответствует выбранным серверам или заметке")
options.onJob?.(job)
if (job.status === "done" || job.status === "failed") return job
if (index + 1 < attempts) await wait(options.intervalMs ?? 1200, signal)
}
throw new Error("Время наблюдения истекло. Задача может продолжаться; повторите проверку той же задачи")
}
export function backupJobOutcome(job: BackupJobStatusResponse) {
const uploadFailures = job.created.filter(item => item.uploadError)
return { fullSuccess: job.status === "done" && job.created.length === job.total && !job.failures.length && !uploadFailures.length, created: job.created.length, deviceFailures: job.failures, uploadFailures }
}
+9 -2
View File
@@ -85,8 +85,9 @@ const backupServerIdSchema = z.union([
z.number().int().positive().safe(), z.number().int().positive().safe(),
]) ])
export const createBackupRequestSchema = z.object({ export const createBackupRequestSchema = z.object({
requestId: z.string().uuid().optional(),
serverIds: z.array(backupServerIdSchema).min(1).refine(ids => new Set(ids.map(String)).size === ids.length, "Дубли серверов"), serverIds: z.array(backupServerIdSchema).min(1).refine(ids => new Set(ids.map(String)).size === ids.length, "Дубли серверов"),
notes: z.string().max(500).optional(), notes: z.string().trim().max(500).optional(),
}) })
export const backupJobStatusSchema = z.enum(["queued", "running", "done", "failed"]) export const backupJobStatusSchema = z.enum(["queued", "running", "done", "failed"])
const backupProgressSchema = z.object({ const backupProgressSchema = z.object({
@@ -94,11 +95,14 @@ const backupProgressSchema = z.object({
total: z.number().int().positive().safe(), total: z.number().int().positive().safe(),
completed: z.number().int().nonnegative().safe(), completed: z.number().int().nonnegative().safe(),
}) })
export const createBackupJobResponseSchema = backupProgressSchema.extend({ jobId: z.string().min(1) }).superRefine((job, ctx) => { export const createBackupJobResponseSchema = backupProgressSchema.extend({ jobId: z.string().min(1), serverIds: z.array(z.string().min(1)).min(1) }).superRefine((job, ctx) => {
if (job.serverIds.length !== job.total || new Set(job.serverIds).size !== job.total) ctx.addIssue({ code: "custom", message: "Цели не соответствуют числу устройств" })
if (job.completed > job.total || (job.status === "done" && job.completed !== job.total)) ctx.addIssue({ code: "custom", message: "Некорректный прогресс задачи" }) if (job.completed > job.total || (job.status === "done" && job.completed !== job.total)) ctx.addIssue({ code: "custom", message: "Некорректный прогресс задачи" })
}) })
export const backupJobResponseSchema = backupProgressSchema.extend({ export const backupJobResponseSchema = backupProgressSchema.extend({
id: z.string().min(1), id: z.string().min(1),
serverIds: z.array(z.string().regex(/^[1-9][0-9]*$/)).min(1),
notes: z.string().optional(),
requestedAt: z.string().datetime({ offset: true }), requestedAt: z.string().datetime({ offset: true }),
startedAt: z.string().datetime({ offset: true }).optional(), startedAt: z.string().datetime({ offset: true }).optional(),
finishedAt: z.string().datetime({ offset: true }).optional(), finishedAt: z.string().datetime({ offset: true }).optional(),
@@ -107,6 +111,7 @@ export const backupJobResponseSchema = backupProgressSchema.extend({
}).superRefine((job, ctx) => { }).superRefine((job, ctx) => {
if (job.completed > job.total || (job.status === "done" && job.completed !== job.total)) ctx.addIssue({ code: "custom", message: "Некорректный прогресс задачи" }) if (job.completed > job.total || (job.status === "done" && job.completed !== job.total)) ctx.addIssue({ code: "custom", message: "Некорректный прогресс задачи" })
if (["done", "failed"].includes(job.status) && !job.finishedAt) ctx.addIssue({ code: "custom", message: "Завершение не подтверждено временем" }) if (["done", "failed"].includes(job.status) && !job.finishedAt) ctx.addIssue({ code: "custom", message: "Завершение не подтверждено временем" })
if (job.created.some(item => item.serverId === null || !job.serverIds.includes(String(item.serverId))) || job.failures.some(failure => failure.serverId !== "job" && !job.serverIds.includes(failure.serverId))) ctx.addIssue({ code: "custom", message: "Результат относится к другому устройству" })
const deviceFailures = job.failures.filter(failure => failure.serverId !== "job") const deviceFailures = job.failures.filter(failure => failure.serverId !== "job")
if (job.created.length + deviceFailures.length !== job.completed) ctx.addIssue({ code: "custom", message: "Результаты не соответствуют прогрессу" }) if (job.created.length + deviceFailures.length !== job.completed) ctx.addIssue({ code: "custom", message: "Результаты не соответствуют прогрессу" })
if (new Set(job.created.map(item => item.id)).size !== job.created.length) ctx.addIssue({ code: "custom", message: "Дубли резервных копий" }) if (new Set(job.created.map(item => item.id)).size !== job.created.length) ctx.addIssue({ code: "custom", message: "Дубли резервных копий" })
@@ -116,3 +121,5 @@ export const restoreBackupResponseSchema = z.object({ filename: z.string().min(1
export const syncBackupsResponseSchema = z.object({ imported: z.number().int().nonnegative().safe(), skipped: z.number().int().nonnegative().safe() }) export const syncBackupsResponseSchema = z.object({ imported: z.number().int().nonnegative().safe(), skipped: z.number().int().nonnegative().safe() })
export type CreateBackupJobResponse = z.infer<typeof createBackupJobResponseSchema> export type CreateBackupJobResponse = z.infer<typeof createBackupJobResponseSchema>
export type BackupJobStatusResponse = z.infer<typeof backupJobResponseSchema> export type BackupJobStatusResponse = z.infer<typeof backupJobResponseSchema>
export type CreateBackupRequest = z.infer<typeof createBackupRequestSchema>
+9 -3
View File
@@ -6,7 +6,7 @@ const base = "https://api.example.test"
const item = { id: "backup-1", serverId: 1, serverName: "router", filename: "config.rsc", sizeBytes: 0, createdAt: "2026-10-01T00:00:00Z", kind: "manual", storage: "local" } const item = { id: "backup-1", serverId: 1, serverName: "router", filename: "config.rsc", sizeBytes: 0, createdAt: "2026-10-01T00:00:00Z", kind: "manual", storage: "local" }
const schedule = { enabled: false, frequency: "daily", hour: 0, minute: 0, weekDay: 0, monthDay: 1, keepCount: 7, format: "rsc", serverIds: [] } const schedule = { enabled: false, frequency: "daily", hour: 0, minute: 0, weekDay: 0, monthDay: 1, keepCount: 7, format: "rsc", serverIds: [] }
const storage = { provider: "local", s3Endpoint: "", s3Region: "", s3Bucket: "", s3Prefix: "", s3AccessKeyId: "", secretConfigured: false, s3ForcePathStyle: false, keepLocalCopy: false } const storage = { provider: "local", s3Endpoint: "", s3Region: "", s3Bucket: "", s3Prefix: "", s3AccessKeyId: "", secretConfigured: false, s3ForcePathStyle: false, keepLocalCopy: false }
const job = { id: "job-1", status: "done", total: 2, completed: 2, requestedAt: "2026-10-01T00:00:00Z", finishedAt: "2026-10-01T00:01:00Z", created: [item], failures: [{ serverId: "2", error: "RouterOS unavailable" }] } const job = { id: "job-1", serverIds: ["1", "2"], status: "done", total: 2, completed: 2, requestedAt: "2026-10-01T00:00:00Z", finishedAt: "2026-10-01T00:01:00Z", created: [item], failures: [{ serverId: "2", error: "RouterOS unavailable" }] }
function json(payload: unknown, status = 200) { return new Response(JSON.stringify(payload), { status }) } function json(payload: unknown, status = 200) { return new Response(JSON.stringify(payload), { status }) }
test("known empty and zero are preserved; malformed catalogue and duplicates rejected", async () => { test("known empty and zero are preserved; malformed catalogue and duplicates rejected", async () => {
@@ -35,7 +35,7 @@ test("settings require a confirmed response and preserve false rather than defau
}) })
test("invalid mutations fail before HTTP; create receives job acknowledgment", async () => { test("invalid mutations fail before HTTP; create receives job acknowledgment", async () => {
const original = globalThis.fetch; let calls = 0, body: unknown const original = globalThis.fetch; let calls = 0, body: unknown
globalThis.fetch = async (_url, init) => { calls++; body = JSON.parse(String(init?.body)); return json({ jobId: "job-1", status: "queued", total: 1, completed: 0 }, 202) } globalThis.fetch = async (_url, init) => { calls++; body = JSON.parse(String(init?.body)); return json({ jobId: "job-1", serverIds: ["1"], status: "queued", total: 1, completed: 0 }, 202) }
try { try {
await assert.rejects(() => createBackupsAsync(base, { serverIds: [] })) await assert.rejects(() => createBackupsAsync(base, { serverIds: [] }))
await assert.rejects(() => createBackupsAsync(base, { serverIds: ["1", "1"] })) await assert.rejects(() => createBackupsAsync(base, { serverIds: ["1", "1"] }))
@@ -44,7 +44,7 @@ test("invalid mutations fail before HTTP; create receives job acknowledgment", a
assert.equal(calls, 0) assert.equal(calls, 0)
assert.equal((await createBackupsAsync(base, { serverIds: ["1"], notes: "manual" })).jobId, "job-1") assert.equal((await createBackupsAsync(base, { serverIds: ["1"], notes: "manual" })).jobId, "job-1")
assert.deepEqual(body, { serverIds: ["1"], notes: "manual" }) 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({ jobId: "job-1", serverIds: ["1", "2"], 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"] })) globalThis.fetch = async () => json({ created: [item], failures: [] }); await assert.rejects(() => createBackupsAsync(base, { serverIds: ["1"] }))
} finally { globalThis.fetch = original } } finally { globalThis.fetch = original }
}) })
@@ -90,3 +90,9 @@ test("settings acknowledgment must match submitted fields and confirm a new secr
await assert.rejects(() => putBackupStorageSettings(base, { s3SecretAccessKey: "new-test-secret" }), /ключа/) await assert.rejects(() => putBackupStorageSettings(base, { s3SecretAccessKey: "new-test-secret" }), /ключа/)
} finally { globalThis.fetch = original } } finally { globalThis.fetch = original }
}) })
test("backup request ID is sent and a different acknowledgment is rejected", async () => {
const original = globalThis.fetch, requestId = "11111111-1111-4111-8111-111111111111"; let body: Record<string, unknown> = {}
globalThis.fetch = async (_url, init) => { body = JSON.parse(String(init?.body)); return json({ jobId: "other", serverIds: ["1"], status: "queued", total: 1, completed: 0 }, 202) }
try { await assert.rejects(() => createBackupsAsync(base, { requestId, serverIds: ["1"] }), /другому запросу/); assert.equal(body.requestId, requestId) } finally { globalThis.fetch = original }
})
+3 -1
View File
@@ -25,9 +25,11 @@ function backupJson(baseUrl: string, path: string, options?: RequestInit) {
export async function listBackups(baseUrl: string, options?: RequestInit) { export async function listBackups(baseUrl: string, options?: RequestInit) {
return backupsListResponseSchema.parse(await backupJson(baseUrl, "/api/backups", { ...options, method: "GET" })) return backupsListResponseSchema.parse(await backupJson(baseUrl, "/api/backups", { ...options, method: "GET" }))
} }
export async function createBackupsAsync(baseUrl: string, payload: { serverIds: string[]; notes?: string }, options?: RequestInit) { export async function createBackupsAsync(baseUrl: string, payload: { requestId?: string; serverIds: string[]; notes?: string }, options?: RequestInit) {
const body = createBackupRequestSchema.parse(payload) const body = createBackupRequestSchema.parse(payload)
const result = 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 (body.requestId && result.jobId !== body.requestId) throw new Error("Подтверждение относится к другому запросу резервного копирования")
if (JSON.stringify(result.serverIds) !== JSON.stringify(body.serverIds.map(String))) throw new Error("Не подтверждены выбранные серверы резервного копирования")
if (result.total !== body.serverIds.length) throw new Error("Не подтверждено резервное копирование всех выбранных серверов") if (result.total !== body.serverIds.length) throw new Error("Не подтверждено резервное копирование всех выбранных серверов")
return result return result
} }