200 lines
9.4 KiB
TypeScript
200 lines
9.4 KiB
TypeScript
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<Buffer> {
|
|
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<string> {
|
|
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<string> {
|
|
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<string, { status: number; sha256: string; size: number }> = {};
|
|
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<void> {
|
|
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<void> {
|
|
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<string, { sha256: string }>;
|
|
};
|
|
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);
|
|
}
|