Files
camilafagundesandCursor 6e23f1533b Implementa o MVP do Adaptador de Planilhas no Elo Pro.
O módulo usa a identidade Supabase, isola cada workspace e registra storage, TTL, runner e biblioteca XLSX como decisões ainda abertas.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 23:02:00 -03:00

158 lines
7.8 KiB
TypeScript

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<string, string[][]>): Promise<Buffer> {
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();
});
});