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,39 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Dialog, JourneyTrail, Select, Switch } from "@/components/ui";
|
||||
import { EmptyState, ErrorState, LoadingBlock } from "@/components/states";
|
||||
|
||||
describe("estados e controles", () => {
|
||||
it("mostra loading, vazio, erro e switch acessível", async () => {
|
||||
const user = userEvent.setup();
|
||||
let checked = false;
|
||||
const view = render(
|
||||
<div>
|
||||
<LoadingBlock />
|
||||
<EmptyState title="Nada aqui" description="Cadastre um modelo." />
|
||||
<ErrorState message="Falha recuperável" onRetry={() => undefined} />
|
||||
<Switch label="Permitir upload" checked={checked} onCheckedChange={(value) => { checked = value; }} />
|
||||
<JourneyTrail steps={["Modelo", "Fontes"]} current={0} />
|
||||
<Select aria-label="Coluna fonte">
|
||||
<option value="">Não mapear</option>
|
||||
<option value="Nome">Nome</option>
|
||||
</Select>
|
||||
<Dialog open title="Critérios" onClose={() => undefined}>Formatos XLSX e CSV.</Dialog>
|
||||
</div>,
|
||||
);
|
||||
expect(screen.getByRole("status")).toBeTruthy();
|
||||
expect(screen.getByText("Nada aqui")).toBeTruthy();
|
||||
expect(screen.getByRole("alert")).toBeTruthy();
|
||||
expect(screen.getByRole("switch", { name: "Permitir upload" }).getAttribute("aria-checked")).toBe("false");
|
||||
await user.click(screen.getByRole("switch", { name: "Permitir upload" }));
|
||||
expect(checked).toBe(true);
|
||||
expect(screen.getByRole("dialog", { name: "Critérios" })).toBeTruthy();
|
||||
await user.selectOptions(screen.getByRole("combobox", { name: "Coluna fonte" }), "Nome");
|
||||
expect((screen.getByRole("combobox", { name: "Coluna fonte" }) as HTMLSelectElement).value).toBe("Nome");
|
||||
view.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { createMemoryDb } from "@/infra/db/pglite";
|
||||
import { migrate } from "@/infra/db/migrate";
|
||||
import type { Db } from "@/infra/db/types";
|
||||
import { MemoryCoordinator } from "@/infra/redis/memory";
|
||||
import { MemoryStorage } from "@/infra/storage/memory";
|
||||
import type { AppDeps } from "@/server/spreadsheet-adapter/deps";
|
||||
import { FIXTURES } from "@/dev/fixtures";
|
||||
import type { RequestContext, Role } from "@/domain/spreadsheet-adapter/types";
|
||||
|
||||
export const IDS = FIXTURES;
|
||||
|
||||
export async function createReadyDb(): Promise<Db> {
|
||||
const db = await createMemoryDb();
|
||||
await migrate(db);
|
||||
await db.withService(async (query) => {
|
||||
await query.query(`insert into public.profiles (id, display_name, is_superadmin) values
|
||||
($1,'Ana Owner', false), ($2,'Bruno Agent', false), ($3,'Carla Gestor', false), ($4,'Diego Admin', false), ($5,'Eva Agent', true)`, [
|
||||
IDS.ana, IDS.bruno, IDS.carla, IDS.diego, IDS.eva,
|
||||
]);
|
||||
await query.query(`insert into public.workspaces (id, name) values ($1,'Alpha'), ($2,'Beta')`, [IDS.alpha, IDS.beta]);
|
||||
await query.query(
|
||||
`insert into public.workspace_members (workspace_id, user_id, role) values
|
||||
($1,$2,'owner'), ($1,$3,'agent'), ($1,$4,'gestor'), ($5,$6,'admin'), ($5,$7,'agent')`,
|
||||
[IDS.alpha, IDS.ana, IDS.bruno, IDS.carla, IDS.beta, IDS.diego, IDS.eva],
|
||||
);
|
||||
await query.query(
|
||||
`insert into public.workspace_module_flags (workspace_id, module_key, enabled) values
|
||||
($1,'spreadsheet_adapter', true), ($2,'spreadsheet_adapter', false)`,
|
||||
[IDS.alpha, IDS.beta],
|
||||
);
|
||||
});
|
||||
return db;
|
||||
}
|
||||
|
||||
export function depsOf(db: Db, coordinator = new MemoryCoordinator()): AppDeps & { storage: MemoryStorage } {
|
||||
return { db, storage: new MemoryStorage(), coordinator, ttlSeconds: 3600, now: () => new Date("2026-10-02T12:00:00.000Z") };
|
||||
}
|
||||
|
||||
export function ctxOf(user: keyof typeof IDS, workspace: "alpha" | "beta", role: Role, moduleEnabled: boolean, allow = false): RequestContext {
|
||||
return {
|
||||
userId: IDS[user],
|
||||
displayName: user,
|
||||
isSuperadmin: user === "eva",
|
||||
workspaceId: IDS[workspace],
|
||||
workspaceName: workspace,
|
||||
role,
|
||||
moduleEnabled,
|
||||
allowNonAdminModelUpload: allow,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readDownSql(): Promise<string> {
|
||||
return readFile(path.join(process.cwd(), "migrations", "down", "0002_spreadsheet_adapter_down.sql"), "utf8");
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseCsvText } from "@/domain/spreadsheet-adapter/csv";
|
||||
import { gridFromRows } from "@/domain/spreadsheet-adapter/csv";
|
||||
import { transformRows } from "@/domain/spreadsheet-adapter/transform";
|
||||
import { buildCsv } from "@/infra/export/tabular";
|
||||
|
||||
describe("capacidade", () => {
|
||||
it("transforma 50.000 linhas úteis sem perder registros", () => {
|
||||
const lines = ["Origem"];
|
||||
for (let index = 0; index < 50_000; index += 1) lines.push(String(index));
|
||||
const grid = gridFromRows(parseCsvText(lines.join("\n")), 1, ["CSV"], "CSV");
|
||||
expect(grid.rows).toHaveLength(50_000);
|
||||
const output = transformRows(["Destino"], grid.headers, grid.rows, [{ sourceColumn: "Origem", modelColumn: "Destino" }]);
|
||||
expect(output).toHaveLength(50_000);
|
||||
expect(output[49_999][0]).toBe("49999");
|
||||
const csv = buildCsv(["Destino"], output.slice(0, 2));
|
||||
expect(csv.toString("utf8")).toContain("0");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MemoryStorage } from "@/infra/storage/memory";
|
||||
import { detectSpreadsheetFormat } from "@/domain/spreadsheet-adapter/file-signature";
|
||||
import { assertZipSafe } from "@/domain/spreadsheet-adapter/file-signature";
|
||||
|
||||
describe("hardening de arquivo", () => {
|
||||
it("rejeita path traversal, extensão enganosa e zip anômalo", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
await expect(storage.put("../segredo", Buffer.from("x"))).rejects.toMatchObject({ code: "PATH_TRAVERSAL" });
|
||||
const zip = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
|
||||
expect(() => detectSpreadsheetFormat("dados.csv", zip)).toThrow(/CSV/);
|
||||
expect(() => detectSpreadsheetFormat("legado.xls", Buffer.from("a,b"))).toThrow(/XLS/);
|
||||
const bomb = Buffer.alloc(64);
|
||||
bomb.writeUInt32LE(0x04034b50, 0);
|
||||
bomb.writeUInt16LE(0, 8);
|
||||
bomb.writeUInt32LE(10, 18);
|
||||
bomb.writeUInt32LE(400_000_000, 22);
|
||||
bomb.writeUInt16LE(1, 26);
|
||||
bomb.writeUInt16LE(0, 28);
|
||||
bomb.write("a", 30);
|
||||
expect(() => assertZipSafe(bomb)).toThrow(/limite seguro|compressão/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.ADAPTER_JOB_MODE = "inline";
|
||||
process.env.SPREADSHEET_TEMP_TTL_SECONDS = "3600";
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user