import ExcelJS from "exceljs"; import { describe, expect, it } from "vitest"; import { addSource, createJob, createModel, deleteModel, getSettings, previewSource, rejectWorkspaceForgery, saveMappings, startProcess, updateSettings, updateSource, } from "@/server/spreadsheet-adapter/service"; import { cleanupExpired, downloadOutput } from "@/server/spreadsheet-adapter/process-job"; import { UnavailableCoordinator } from "@/infra/redis/memory"; import { ctxOf, createReadyDb, depsOf, IDS } from "../helpers/world"; async function workbook(sheets: Record): Promise { const book = new ExcelJS.Workbook(); for (const [name, rows] of Object.entries(sheets)) { const sheet = book.addWorksheet(name); rows.forEach((row) => sheet.addRow(row)); } return Buffer.from(await book.xlsx.writeBuffer()); } describe("jornada do adaptador", () => { it("cadastra modelo, mapeia, revisa, processa e baixa sem alterar o modelo", async () => { const db = await createReadyDb(); const deps = depsOf(db); const ana = ctxOf("ana", "alpha", "owner", true); const model = await createModel(deps, ana, { name: "Clientes", filename: "modelo.csv", bytes: Buffer.from("Nome,Valor\n", "utf8"), requiredColumns: ["Nome", "Valor"], }); const original = await deps.storage.get(`workspaces/${IDS.alpha}/models/${model.id}/original`); const job = await createJob(deps, ana, model.id, "job-1"); const source = await addSource(deps, ana, job.id, { filename: "fonte.csv", bytes: Buffer.from("Nome,Valor\n=CMD(),10\nBruno,20\n", "utf8"), }); expect(source.httpStatus).toBe(200); const sourceId = source.job.sources[0].id; await saveMappings(deps, ana, job.id, sourceId, [ { sourceColumn: "Nome", modelColumn: "Nome" }, { sourceColumn: "Valor", modelColumn: "Valor" }, ]); const preview = await previewSource(deps, ana, job.id, sourceId); expect(preview.preview).toBe(true); expect(preview.columns.map((column) => column.name)).toEqual(["Nome", "Valor"]); expect(preview.rows[0]).toEqual(["=CMD()", "10"]); const done = await startProcess(deps, ana, job.id, sourceId, "proc-1"); expect(done.status).toBe("completed"); expect(done.progress?.percent).toBe(100); const again = await startProcess(deps, ana, job.id, sourceId, "proc-1"); expect(again.status).toBe("completed"); const csv = await downloadOutput(deps, ana, job.id, sourceId, "csv"); expect(csv.body.toString("utf8")).toContain("'=CMD()"); expect(csv.filename.endsWith(".csv")).toBe(true); const xlsx = await downloadOutput(deps, ana, job.id, sourceId, "xlsx"); expect(xlsx.body.subarray(0, 2).toString()).toBe("PK"); const pdf = await downloadOutput(deps, ana, job.id, sourceId, "pdf"); expect(pdf.body.subarray(0, 4).toString()).toBe("%PDF"); const after = await deps.storage.get(`workspaces/${IDS.alpha}/models/${model.id}/original`); expect(after.equals(original)).toBe(true); const audits = await db.withService((query) => query.query<{ metadata: unknown }>(`select metadata::text as metadata from public.audit_log`)); const blob = JSON.stringify(audits); expect(blob).not.toContain("=CMD()"); expect(blob).not.toContain("CELULA"); await db.close(); }); it("bloqueia limites, duplicidade, papel, aba e job ativo", async () => { const db = await createReadyDb(); const deps = depsOf(db); const ana = ctxOf("ana", "alpha", "owner", true); const bruno = ctxOf("bruno", "alpha", "agent", true, false); await expect(createModel(deps, bruno, { name: "X", filename: "a.csv", bytes: Buffer.from("A\n1\n") })).rejects.toMatchObject({ code: "FORBIDDEN", }); await updateSettings(deps, ana, true); const settings = await getSettings(deps, ana); expect(settings.allowNonAdminModelUpload).toBe(true); const brunoAllowed = ctxOf("bruno", "alpha", "agent", true, true); const uploaded = await createModel(deps, brunoAllowed, { name: "Operacional", filename: "a.csv", bytes: Buffer.from("A\n1\n") }); await expect(createModel(deps, ana, { name: "Operacional", filename: "a.csv", bytes: Buffer.from("B\n1\n") })).rejects.toMatchObject({ code: "DUPLICATE_MODEL_NAME", }); await expect(deleteModel(deps, brunoAllowed, uploaded.id)).rejects.toMatchObject({ code: "FORBIDDEN" }); for (let index = 0; index < 9; index += 1) { await createModel(deps, ana, { name: `Modelo ${index}`, filename: "a.csv", bytes: Buffer.from("A\n1\n") }); } await expect(createModel(deps, ana, { name: "Modelo extra", filename: "a.csv", bytes: Buffer.from("A\n1\n") })).rejects.toMatchObject({ code: "TOO_MANY_MODELS", }); const multi = await workbook({ Um: [["A"], ["1"]], Dois: [["B"], ["2"]] }); const fresh = await createReadyDb(); const deps2 = depsOf(fresh); const model = await createModel(deps2, ana, { name: "Multi", filename: "livro.xlsx", bytes: multi, sheet: "Dois" }); expect(model.sheetName).toBe("Dois"); const job = await createJob(deps2, ana, model.id); const pending = await addSource(deps2, ana, job.id, { filename: "fonte.xlsx", bytes: multi }); expect(pending.httpStatus).toBe(409); expect(pending.code).toBe("SHEET_REQUIRED"); const chosen = await updateSource(deps2, ana, job.id, pending.job.sources[0].id, { sheet: "Um", headerRow: 1 }); expect(chosen.httpStatus).toBe(200); expect(chosen.job.sources[0].selectedSheet).toBe("Um"); await saveMappings(deps2, ana, job.id, chosen.job.sources[0].id, [{ sourceColumn: "A", modelColumn: "B" }]); await fresh.withUser(ana.userId, (query) => query.query(`update public.spreadsheet_jobs set status = 'processing' where id = $1`, [job.id]), ); await expect(deleteModel(deps2, ana, model.id)).rejects.toMatchObject({ code: "MODEL_IN_USE" }); await fresh.close(); await db.close(); }); it("não processa quando o coordenador está indisponível", async () => { const db = await createReadyDb(); const deps = depsOf(db, new UnavailableCoordinator()); const ana = ctxOf("ana", "alpha", "owner", true); const model = await createModel(deps, ana, { name: "Base", filename: "a.csv", bytes: Buffer.from("A\n1\n") }); const job = await createJob(deps, ana, model.id); const source = await addSource(deps, ana, job.id, { filename: "f.csv", bytes: Buffer.from("A\n9\n") }); await saveMappings(deps, ana, job.id, source.job.sources[0].id, [{ sourceColumn: "A", modelColumn: "A" }]); await expect(startProcess(deps, ana, job.id, source.job.sources[0].id, "x")).rejects.toMatchObject({ code: "REDIS_UNAVAILABLE" }); const stored = await db.withService((query) => query.query<{ status: string }>(`select status from public.spreadsheet_jobs where id = $1`, [job.id])); expect(stored[0].status).not.toBe("completed"); await db.close(); }); it("expira temporários sem apagar o modelo", async () => { const db = await createReadyDb(); const deps = depsOf(db); deps.ttlSeconds = -1; const ana = ctxOf("ana", "alpha", "owner", true); const model = await createModel(deps, ana, { name: "Permanente", filename: "a.csv", bytes: Buffer.from("A\n1\n") }); const modelKey = `workspaces/${IDS.alpha}/models/${model.id}/original`; const job = await createJob(deps, ana, model.id); await addSource(deps, ana, job.id, { filename: "f.csv", bytes: Buffer.from("A\n1\n") }); const removed = await cleanupExpired(deps); expect(removed).toBe(1); expect((await deps.storage.get(modelKey)).length).toBeGreaterThan(0); await db.close(); }); it("rejeita workspace forjado", async () => { const db = await createReadyDb(); const deps = depsOf(db); const ana = ctxOf("ana", "alpha", "owner", true); await expect(rejectWorkspaceForgery(deps, ana, IDS.beta)).rejects.toMatchObject({ code: "WORKSPACE_FORBIDDEN" }); await db.close(); }); });