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

+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import { extractHeaders } from "@/domain/spreadsheet-adapter/headers";
import { validateMappings } from "@/domain/spreadsheet-adapter/mapping";
import { inferColumns } from "@/domain/spreadsheet-adapter/typing";
import { transformRows } from "@/domain/spreadsheet-adapter/transform";
import { canManageSpreadsheetModels, canUploadSpreadsheetModel, canUseSpreadsheetAdapter } from "@/domain/spreadsheet-adapter/permissions";
import { neutralizeSpreadsheetFormula } from "@/domain/spreadsheet-adapter/csv-injection";
import { buildOutputFilename, sanitizeDisplayName } from "@/domain/spreadsheet-adapter/filenames";
import { canTransition } from "@/domain/spreadsheet-adapter/job-state";
import { detectSpreadsheetFormat } from "@/domain/spreadsheet-adapter/file-signature";
import { parseCsvText } from "@/domain/spreadsheet-adapter/csv";
import { queryKeys } from "@/domain/spreadsheet-adapter/query-keys";
import { MAX_FILE_BYTES } from "@/domain/spreadsheet-adapter/limits";
import { MemoryCoordinator } from "@/infra/redis/memory";
import { scrubSentryEvent } from "@/infra/observability/scrub";
describe("domínio", () => {
it("normaliza cabeçalho e recusa vazio ou duplicado", () => {
expect(extractHeaders([" Nome ", "Valor", ""])).toEqual(["Nome", "Valor"]);
expect(() => extractHeaders(["A", "a"])).toThrow(/duplicado/i);
expect(() => extractHeaders(["A", "", "B"])).toThrow(/cabeçalho/i);
});
it("detecta tipo misto e transforma na ordem do modelo", () => {
const columns = inferColumns(["Valor", "Nome"], [["10", "Ana"], ["texto", "Bia"]]);
expect(columns[0].inferredType).toBe("mixed");
const rows = transformRows(["Nome", "Valor"], ["Valor", "Nome"], [["10", "Ana"]], [{ sourceColumn: "Nome", modelColumn: "Nome" }, { sourceColumn: "Valor", modelColumn: "Valor" }]);
expect(rows).toEqual([["Ana", "10"]]);
});
it("exige mapeamento 1:1 e obrigatórias", () => {
const model = [{ name: "Nome", required: true }, { name: "Obs", required: false }];
expect(validateMappings(model, ["A", "B"], [{ sourceColumn: "A", modelColumn: "Nome" }]).ok).toBe(true);
expect(validateMappings(model, ["A"], []).code).toBe("MAPPING_INCOMPLETE");
expect(validateMappings(model, ["A", "B"], [{ sourceColumn: "A", modelColumn: "Nome" }, { sourceColumn: "B", modelColumn: "Nome" }]).code).toBe("MAPPING_CONFLICT");
});
it("aplica permissões sem atalho de superadmin", () => {
expect(canUseSpreadsheetAdapter({ role: "agent", moduleEnabled: false })).toBe(false);
expect(canManageSpreadsheetModels({ role: "gestor", moduleEnabled: true })).toBe(false);
expect(canManageSpreadsheetModels({ role: "admin", moduleEnabled: true })).toBe(true);
expect(canUploadSpreadsheetModel({ role: "agent", moduleEnabled: true, allowNonAdminModelUpload: false })).toBe(false);
expect(canUploadSpreadsheetModel({ role: "agent", moduleEnabled: true, allowNonAdminModelUpload: true })).toBe(true);
expect(canTransition("ready", "queued")).toBe(true);
expect(canTransition("completed", "processing")).toBe(false);
});
it("neutraliza CSV injection e nomes perigosos", () => {
expect(neutralizeSpreadsheetFormula("=1+1")).toBe("'=1+1");
expect(neutralizeSpreadsheetFormula("-12")).toBe("-12");
expect(sanitizeDisplayName("../etc/passwd.csv")).toBe("passwd.csv");
expect(buildOutputFilename("Relatório Ágil", "xlsx", new Date("2026-10-02T00:00:00.000Z"))).toMatch(/^adaptado_Relatorio_Agil_/);
expect(() => detectSpreadsheetFormat("grande.csv", Buffer.alloc(MAX_FILE_BYTES + 1, 65))).toThrow(/10 MB/);
expect(parseCsvText('a;b\n"1;2";3')).toEqual([["a", "b"], ["1;2", "3"]]);
});
it("isola cache por workspace e o lock por dono", async () => {
expect(queryKeys.models("a")[1]).toBe("a");
expect(queryKeys.models("a")).not.toEqual(queryKeys.models("b"));
let now = 0;
const locks = new MemoryCoordinator(() => now);
expect(await locks.acquireLock("k", "job-1", 10)).toBe(true);
expect(await locks.acquireLock("k", "job-2", 10)).toBe(false);
now = 11_000;
expect(await locks.acquireLock("k", "job-2", 10)).toBe(true);
const event = scrubSentryEvent({
request: { data: "planilha", headers: { authorization: "secret", "x-request-id": "1" } },
extra: { cells: ["segredo"], job_id: "j" },
breadcrumbs: [{ message: "row" }],
});
expect(event.request?.data).toBeUndefined();
expect(event.request?.headers?.authorization).toBeUndefined();
expect(event.request?.headers?.["x-request-id"]).toBe("1");
expect(event.extra?.cells).toBeUndefined();
expect(event.extra?.job_id).toBe("j");
expect(event.breadcrumbs).toEqual([]);
});
});