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>
This commit is contained in:
commit
6e23f1533b
144 files changed
+19060
No files matched your search
@@ -0,0 +1,157 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user