fix(backups): отделить результат операций от ошибок журнала

This commit is contained in:
Denozordec committed 2026-10-01 15:11:32 +07:00
1 parent 3dcc7c9ea6
commit 5ce21e9a47
10 files changed
+113 -67

No files matched your search

@@ -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)
})
@@ -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<void>, warn: (error: unknown) => void): Promise<void> {
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<BackupMeta>
event: (input: AppendEventBody) => Promise<void>
}) {
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 } })
}