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:
camilafagundesandCursor committed 2026-10-02 23:02:00 -03:00
commit 6e23f1533b
144 files changed
+19060

No files matched your search

+157
View File
@@ -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();
});
});
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { createReadyDb, readDownSql } from "../helpers/world";
describe("migrations", () => {
it("sobe do zero e o rollback remove as tabelas do módulo", async () => {
const db = await createReadyDb();
const tables = await db.withService((query) =>
query.query<{ table_name: string }>(
`select table_name from information_schema.tables where table_schema = 'public' and table_name like 'spreadsheet_%'`,
),
);
expect(tables.map((row) => row.table_name).sort()).toEqual([
"spreadsheet_adapter_settings",
"spreadsheet_job_mappings",
"spreadsheet_job_sources",
"spreadsheet_jobs",
"spreadsheet_models",
]);
await db.exec(await readDownSql());
const after = await db.withService((query) =>
query.query<{ table_name: string }>(
`select table_name from information_schema.tables where table_schema = 'public' and table_name like 'spreadsheet_%'`,
),
);
expect(after).toHaveLength(0);
const profiles = await db.withService((query) => query.query(`select id from public.profiles`));
expect(profiles.length).toBe(5);
await db.close();
});
});
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { createModel } from "@/server/spreadsheet-adapter/service";
import { ctxOf, createReadyDb, depsOf, IDS } from "../helpers/world";
describe("RLS e permissões", () => {
it("impede leitura e escrita entre workspaces", async () => {
const db = await createReadyDb();
const deps = depsOf(db);
const ana = ctxOf("ana", "alpha", "owner", true);
const diego = ctxOf("diego", "beta", "admin", false);
await createModel(deps, ana, { name: "Somente Alpha", filename: "a.csv", bytes: Buffer.from("A\n1\n") });
const asDiego = await db.withUser(IDS.diego, (query) => query.query(`select name from public.spreadsheet_models`));
expect(asDiego).toHaveLength(0);
const asAna = await db.withUser(IDS.ana, (query) => query.query(`select name from public.spreadsheet_models where workspace_id = $1`, [IDS.beta]));
expect(asAna).toHaveLength(0);
await expect(
db.withUser(IDS.ana, (query) =>
query.query(
`insert into public.spreadsheet_models
(workspace_id, name, original_filename, format, storage_key, content_sha256, header_row, columns_schema, created_by)
values ($1,'Invasao','a.csv','csv','workspaces/x/models/y/original','abc',1,'[]'::jsonb,$2)`,
[IDS.beta, IDS.ana],
),
),
).rejects.toThrow(/row-level security/i);
await expect(createModel(deps, { ...diego, moduleEnabled: true }, { name: "Beta", filename: "a.csv", bytes: Buffer.from("A\n1\n") })).rejects.toMatchObject({
code: "FORBIDDEN",
});
const eva = ctxOf("eva", "beta", "agent", true, true);
await expect(createModel(deps, eva, { name: "Super", filename: "a.csv", bytes: Buffer.from("A\n1\n") })).rejects.toThrow();
await db.close();
});
it("agent não altera configuração e owner altera", async () => {
const db = await createReadyDb();
const ana = ctxOf("ana", "alpha", "owner", true);
const bruno = ctxOf("bruno", "alpha", "agent", true);
await expect(
db.withUser(bruno.userId, (query) =>
query.query(`insert into public.spreadsheet_adapter_settings (workspace_id, allow_non_admin_model_upload) values ($1, true)`, [IDS.alpha]),
),
).rejects.toThrow(/row-level security/i);
await db.withUser(ana.userId, (query) =>
query.query(`insert into public.spreadsheet_adapter_settings (workspace_id, allow_non_admin_model_upload) values ($1, true)`, [IDS.alpha]),
);
const seen = await db.withUser(bruno.userId, (query) =>
query.query<{ allow_non_admin_model_upload: boolean }>(`select allow_non_admin_model_upload from public.spreadsheet_adapter_settings`),
);
expect(seen[0].allow_non_admin_model_upload).toBe(true);
await db.close();
});
});