import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import ExcelJS from "exceljs"; /** * Jornada fictícia contra o ensaio de rollback, só em 127.0.0.1:3310. * Recusa a porta 3000 do compose principal. Não abre banco nem Docker. */ const ALPHA = "11111111-1111-4111-8111-111111111111"; const ANA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const base = process.env.ROLLBACK_PROBE_URL ?? "http://127.0.0.1:3310"; const mode = process.argv[2]; const expectedRaw = process.argv[3]; function assertTarget(): void { const url = new URL(base); if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || url.port !== "3310") { throw new Error("alvo recusado: o ensaio só fala com http://127.0.0.1:3310"); } } function sha256(body: Buffer): string { return createHash("sha256").update(body).digest("hex"); } async function xlsx(rows: string[][]): Promise { const book = new ExcelJS.Workbook(); const sheet = book.addWorksheet("Dados"); for (const row of rows) sheet.addRow(row); return Buffer.from(await book.xlsx.writeBuffer()) as Buffer; } async function login(): Promise { const response = await fetch(`${base}/api/dev/session`, { method: "POST", headers: { "content-type": "application/json", accept: "application/json" }, body: JSON.stringify({ userId: ANA, workspaceId: ALPHA }), signal: AbortSignal.timeout(30_000), }); if (response.status !== 200) throw new Error(`sessão local HTTP ${response.status}`); const lines = typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : []; const pair = lines.map((line) => line.split(";")[0]).find((line) => line.startsWith("adapter_dev_session=")); if (!pair) throw new Error("cookie de sessão ausente"); return pair; } async function request(path: string, cookie: string, init?: RequestInit): Promise<{ status: number; body: unknown; bytes: Buffer; headers: Headers }> { const headers = new Headers(init?.headers); headers.set("cookie", cookie); headers.set("accept", headers.get("accept") ?? "application/json"); const response = await fetch(`${base}${path}`, { ...init, headers, signal: AbortSignal.timeout(60_000) }); const bytes = Buffer.from(await response.arrayBuffer()); let body: unknown = bytes.toString("utf8"); try { body = JSON.parse(bytes.toString("utf8")); } catch { /* arquivo binário */ } return { status: response.status, body, bytes, headers: response.headers }; } async function waitJob(cookie: string, jobId: string): Promise { let status = ""; for (let attempt = 0; attempt < 45; attempt += 1) { const polled = await request(`/api/spreadsheet-adapter/jobs/${jobId}`, cookie); status = (polled.body as { job?: { status?: string } }).job?.status ?? ""; if (status === "completed" || status === "failed") return status; await new Promise((resolve) => setTimeout(resolve, 2000)); } return status || "timeout"; } async function downloads(cookie: string, jobId: string, sourceId: string) { const files: Record = {}; for (const format of ["xlsx", "csv", "pdf"]) { const file = await request(`/api/spreadsheet-adapter/jobs/${jobId}/download?format=${format}&sourceId=${sourceId}`, cookie, { headers: { accept: "*/*" }, }); files[format] = { status: file.status, sha256: sha256(file.bytes), size: file.bytes.length }; } return files; } async function seed(): Promise { const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); const suffix = Math.random().toString(36).slice(2, 8).padEnd(6, "0"); const modelName = `Rollback local ${stamp}-${suffix}`; const sentinel = `ROLLBACKLOCAL${suffix.toUpperCase()}`; const cookie = await login(); const modelBytes = await xlsx([["Nome", "Codigo"]]); const sourceBytes = await xlsx([ ["Nome", "Codigo"], ["Registro ficticio", sentinel], ]); const modelForm = new FormData(); modelForm.append("name", modelName); modelForm.append("requiredColumns", JSON.stringify(["Nome", "Codigo"])); modelForm.append("file", new File([new Uint8Array(modelBytes)], "modelo-ficticio.xlsx", { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" })); const created = await request("/api/spreadsheet-adapter/models", cookie, { method: "POST", body: modelForm, headers: { accept: "application/json" } }); const modelId = (created.body as { model?: { id?: string } }).model?.id ?? ""; if (created.status !== 201 || !modelId) throw new Error(`modelo HTTP ${created.status}`); const job = await request("/api/spreadsheet-adapter/jobs", cookie, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ modelId, idempotencyKey: `rollback-job-${suffix}` }), }); const jobId = (job.body as { job?: { id?: string } }).job?.id ?? ""; if (job.status !== 201 || !jobId) throw new Error(`operação HTTP ${job.status}`); const sourceForm = new FormData(); sourceForm.append("jobId", jobId); sourceForm.append("file", new File([new Uint8Array(sourceBytes)], "fonte-ficticia.xlsx", { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" })); const uploaded = await request("/api/spreadsheet-adapter/sources/validate", cookie, { method: "POST", body: sourceForm, headers: { accept: "application/json" } }); const sourceId = (uploaded.body as { job?: { sources?: { id?: string }[] } }).job?.sources?.[0]?.id ?? ""; if (uploaded.status !== 200 || !sourceId) throw new Error(`fonte HTTP ${uploaded.status}`); const mapped = await request(`/api/spreadsheet-adapter/jobs/${jobId}/mappings`, cookie, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ sourceId, mappings: [ { sourceColumn: "Nome", modelColumn: "Nome" }, { sourceColumn: "Codigo", modelColumn: "Codigo" }, ], }), }); if (mapped.status !== 200) throw new Error(`mapeamento HTTP ${mapped.status}`); const preview = await request(`/api/spreadsheet-adapter/jobs/${jobId}/preview`, cookie, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sourceId }), }); const previewRows = (preview.body as { rows?: string[][] }).rows ?? []; if (preview.status !== 200 || !previewRows.some((row) => row.includes(sentinel))) throw new Error(`prévia HTTP ${preview.status}`); const queued = await request(`/api/spreadsheet-adapter/jobs/${jobId}/process`, cookie, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sourceId, idempotencyKey: `rollback-proc-${suffix}` }), }); const immediate = (queued.body as { job?: { status?: string } }).job?.status ?? ""; if (queued.status !== 200 || (immediate !== "queued" && immediate !== "processing")) throw new Error(`processo HTTP ${queued.status} status=${immediate}`); const finalStatus = await waitJob(cookie, jobId); if (finalStatus !== "completed") throw new Error(`worker status=${finalStatus}`); const files = await downloads(cookie, jobId, sourceId); if (Object.values(files).some((file) => file.status !== 200 || file.size < 1)) throw new Error("download incompleto"); console.log(JSON.stringify({ modelName, modelId, jobId, sourceId, modelSha256: sha256(modelBytes), sentinel, files })); } async function verify(): Promise { if (!expectedRaw) throw new Error("informe o JSON gravado na subida"); const expected = JSON.parse(expectedRaw.startsWith("{") ? expectedRaw : readFileSync(expectedRaw, "utf8")) as { modelName: string; modelId: string; jobId: string; sourceId: string; modelSha256: string; files: Record; }; const cookie = await login(); const ready = await request("/api/health/ready", cookie); const models = await request("/api/spreadsheet-adapter/models", cookie); const list = (models.body as { models?: { id?: string; name?: string }[] }).models ?? []; const found = list.some((model) => model.id === expected.modelId && model.name === expected.modelName); const job = await request(`/api/spreadsheet-adapter/jobs/${expected.jobId}`, cookie); const status = (job.body as { job?: { status?: string } }).job?.status ?? ""; const files = await downloads(cookie, expected.jobId, expected.sourceId); const stored = (["xlsx", "csv"] as const).every((format) => files[format].sha256 === expected.files[format].sha256 && files[format].status === 200); const pdf = files.pdf.status === 200 && files.pdf.size > 4; const report = { ready: (ready.body as { status?: string }).status ?? ready.status, modelFound: found, jobStatus: status, storedFilesMatch: stored, pdfRegenerated: pdf, sha256: { xlsx: files.xlsx.sha256 === expected.files.xlsx.sha256, csv: files.csv.sha256 === expected.files.csv.sha256, pdf: files.pdf.sha256 === expected.files.pdf.sha256, }, }; console.log(JSON.stringify(report)); if (report.ready !== "ready" || !found || status !== "completed" || !stored || !pdf) process.exitCode = 1; } assertTarget(); if (mode === "seed") { seed().catch((error: unknown) => { console.error(error instanceof Error ? error.message : "erro"); process.exit(1); }); } else if (mode === "verify") { verify().catch((error: unknown) => { console.error(error instanceof Error ? error.message : "erro"); process.exit(1); }); } else { console.error("uso: rollback-local-probe.ts seed|verify [json]"); process.exit(1); }