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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user