fix(api): проверять цель и отменять наблюдение выпуска

This commit is contained in:
Denozordec committed 2026-10-01 14:10:33 +07:00
1 parent 1d50ec8d17
commit 299f1da90b
5 files changed
+88 -2

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import type { CertificateIssueJob } from "@mmapp/contracts/certificates"
import { pollCertificateIssue, waitForCertificatePoll } from "./certificate-issue-poll"
const target = { serverId: "1", certName: "cert", domainNames: ["test.example"] }
const queued: CertificateIssueJob = { id: "job-1", ...target, status: "queued", step: "queued", requestedAt: "2026-10-01T00:00:00Z" }
const signal = () => new AbortController().signal
test("queued/running are observations; only confirmed terminal job completes", async () => {
let reads = 0; const observed: string[] = []
const result = await pollCertificateIssue("", "job-1", target, { signal: signal(), wait: async () => {}, readJob: async () => ++reads === 1 ? queued : reads === 2 ? { ...queued, status: "running", step: "import" } : { ...queued, status: "done", step: "done" }, onJob: job => observed.push(job.status) })
assert.equal(result.status, "done"); assert.deepEqual(observed, ["queued", "running", "done"])
})
test("failed job preserves server error without resubmission", async () => {
const result = await pollCertificateIssue("", "job-1", target, { signal: signal(), readJob: async () => ({ ...queued, status: "failed", step: "failed", error: "Import failed" }) })
assert.equal(result.status, "failed"); assert.equal(result.error, "Import failed")
})
test("foreign target or inconsistent done cannot invoke onJob or claim success", async () => {
for (const patch of [{ id: "other" }, { serverId: "2" }, { certName: "other" }, { domainNames: ["other.example"] }, { status: "done", step: "import" }]) {
let observations = 0
await assert.rejects(() => pollCertificateIssue("", "job-1", target, { signal: signal(), readJob: async () => ({ ...queued, ...patch } as CertificateIssueJob), onJob: () => { observations++ } }))
assert.equal(observations, 0)
}
})
test("read failure and exhausted observation budget do not become failed server jobs", async () => {
await assert.rejects(() => pollCertificateIssue("", "job-1", target, { signal: signal(), readJob: async () => { throw new Error("HTTP failure") } }), /HTTP failure/)
let reads = 0
await assert.rejects(() => pollCertificateIssue("", "job-1", target, { signal: signal(), attempts: 2, wait: async () => {}, readJob: async () => { reads++; return queued } }), /может продолжаться/)
assert.equal(reads, 2)
})
test("caller cancellation interrupts delay and suppresses late observation", async () => {
const abort = new AbortController(); const waiting = waitForCertificatePoll(60_000, abort.signal); abort.abort(new Error("cancel")); await assert.rejects(() => waiting, /cancel/)
const late = new AbortController(); let observed = 0
await assert.rejects(() => pollCertificateIssue("", "job-1", target, { signal: late.signal, readJob: async () => { late.abort(new Error("source changed")); return { ...queued, status: "done", step: "done" } }, onJob: () => { observed++ } }), /source changed/)
assert.equal(observed, 0)
})
+36
View File
@@ -0,0 +1,36 @@
import type { CertificateIssueJob, CertificateIssueRequest } from "@mmapp/contracts/certificates"
import { getCertificateIssueJob } from "@/shared/api/certificates"
export function waitForCertificatePoll(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 pollCertificateIssue(baseUrl: string, jobId: string, target: CertificateIssueRequest, options: {
signal: AbortSignal
onJob?: (job: CertificateIssueJob) => void
readJob?: typeof getCertificateIssueJob
wait?: typeof waitForCertificatePoll
attempts?: number
intervalMs?: number
}) {
const signal = AbortSignal.any([options.signal, AbortSignal.timeout(300_000)])
const readJob = options.readJob ?? getCertificateIssueJob
const wait = options.wait ?? waitForCertificatePoll
const attempts = options.attempts ?? 120
for (let index = 0; index < attempts; index++) {
signal.throwIfAborted()
const job = await readJob(baseUrl, jobId, { signal })
signal.throwIfAborted()
if (job.id !== jobId || job.serverId !== String(target.serverId) || job.certName !== target.certName || JSON.stringify(job.domainNames) !== JSON.stringify(target.domainNames)) throw new Error("Ответ задачи не соответствует выбранному серверу, сертификату или доменам")
if (job.status === "done" && job.step !== "done") throw new Error("Завершение задачи не подтверждено её шагом")
options.onJob?.(job)
if (job.status === "done" || job.status === "failed") return job
if (index + 1 < attempts) await wait(options.intervalMs ?? 2000, signal)
}
throw new Error("Время наблюдения истекло. Задача может продолжаться на сервере; повторите проверку этой задачи.")
}