1 parent
6e23f1533b
commit
419325db12
52 files changed
+1329
-110
No files matched your search
@@ -24,7 +24,7 @@ describe("RLS e permissões", () => {
|
||||
),
|
||||
).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",
|
||||
code: "FEATURE_DISABLED",
|
||||
});
|
||||
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();
|
||||
@@ -49,4 +49,26 @@ describe("RLS e permissões", () => {
|
||||
expect(seen[0].allow_non_admin_model_upload).toBe(true);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
it("não altera auditoria pelo papel da aplicação", async () => {
|
||||
const db = await createReadyDb();
|
||||
const ana = ctxOf("ana", "alpha", "owner", true);
|
||||
await db.withUser(ana.userId, (query) =>
|
||||
query.query(
|
||||
`insert into public.audit_log (workspace_id, actor_id, action, resource_type, resource_id, result)
|
||||
values ($1, $2, 'model.uploaded', 'model', 'm1', 'success')`,
|
||||
[IDS.alpha, IDS.ana],
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
db.withUser(ana.userId, (query) =>
|
||||
query.query(`update public.audit_log set action = 'tamper' where workspace_id = $1`, [IDS.alpha]),
|
||||
),
|
||||
).rejects.toThrow(/permission denied/i);
|
||||
const rows = await db.withService((query) =>
|
||||
query.query<{ action: string }>(`select action from public.audit_log where workspace_id = $1`, [IDS.alpha]),
|
||||
);
|
||||
expect(rows.every((row) => row.action !== "tamper")).toBe(true);
|
||||
await db.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readSpreadsheet } from "@/infra/spreadsheet/parse";
|
||||
import { buildCsv, buildXlsx } from "@/infra/export/tabular";
|
||||
|
||||
function csv(rows: number): Buffer {
|
||||
const lines = ["Nome,Valor"];
|
||||
for (let index = 0; index < rows; index += 1) lines.push(`Pessoa ${index},${index}`);
|
||||
return Buffer.from(lines.join("\n"), "utf8");
|
||||
}
|
||||
|
||||
async function measure(rows: number) {
|
||||
const bytes = csv(rows);
|
||||
const started = performance.now();
|
||||
const parsed = await readSpreadsheet("amostra.csv", bytes);
|
||||
const readMs = performance.now() - started;
|
||||
const writeStarted = performance.now();
|
||||
buildCsv(parsed.grid.headers, parsed.grid.rows);
|
||||
const csvMs = performance.now() - writeStarted;
|
||||
const xlsxStarted = performance.now();
|
||||
await buildXlsx(parsed.grid.headers, parsed.grid.rows);
|
||||
const xlsxMs = performance.now() - xlsxStarted;
|
||||
return { rows, readMs, csvMs, xlsxMs, heapMb: Math.round(process.memoryUsage().heapUsed / 1024 / 1024) };
|
||||
}
|
||||
|
||||
describe("baseline de planilha", () => {
|
||||
it("mede leitura e escrita em amostra pequena e média", async () => {
|
||||
const small = await measure(200);
|
||||
const medium = await measure(5_000);
|
||||
expect(small.readMs).toBeLessThan(5_000);
|
||||
expect(medium.readMs).toBeLessThan(15_000);
|
||||
expect(medium.xlsxMs).toBeLessThan(30_000);
|
||||
expect(small.rows).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AdapterError } from "@/domain/spreadsheet-adapter/errors";
|
||||
import { readSpreadsheet } from "@/infra/spreadsheet/parse";
|
||||
import { assertZipSafe } from "@/domain/spreadsheet-adapter/file-signature";
|
||||
import {
|
||||
addSource,
|
||||
createJob,
|
||||
createModel,
|
||||
listModels,
|
||||
saveMappings,
|
||||
startProcess,
|
||||
} from "@/server/spreadsheet-adapter/service";
|
||||
import { cleanupExpired, downloadOutput, runProcess } from "@/server/spreadsheet-adapter/process-job";
|
||||
import type { ObjectStorage } from "@/infra/storage/memory";
|
||||
import { ctxOf, createReadyDb, depsOf, IDS } from "../helpers/world";
|
||||
|
||||
function zipEntry(name: string): Buffer {
|
||||
const entry = Buffer.from(name);
|
||||
const header = Buffer.alloc(30);
|
||||
header.writeUInt32LE(0x04034b50, 0);
|
||||
header.writeUInt16LE(entry.length, 26);
|
||||
return Buffer.concat([header, entry]);
|
||||
}
|
||||
|
||||
describe("isolamento e arquivos hostis", () => {
|
||||
it("impede download, listagem e flag forjada entre workspaces", async () => {
|
||||
const db = await createReadyDb();
|
||||
const deps = depsOf(db);
|
||||
const ana = ctxOf("ana", "alpha", "owner", true);
|
||||
const bruno = ctxOf("bruno", "alpha", "agent", true);
|
||||
const diego = ctxOf("diego", "beta", "admin", true);
|
||||
const model = await createModel(deps, ana, {
|
||||
name: "Isolado",
|
||||
filename: "modelo.csv",
|
||||
bytes: Buffer.from("Nome,Valor\n", "utf8"),
|
||||
requiredColumns: ["Nome", "Valor"],
|
||||
});
|
||||
const job = await createJob(deps, ana, model.id, "job-iso");
|
||||
const source = await addSource(deps, ana, job.id, {
|
||||
filename: "fonte.csv",
|
||||
bytes: Buffer.from("Nome,Valor\nAna,10\n", "utf8"),
|
||||
});
|
||||
if (!("job" in source)) throw new Error("fonte");
|
||||
const sourceId = source.job.sources[0]?.id;
|
||||
if (!sourceId) throw new Error("fonte id");
|
||||
await saveMappings(deps, ana, job.id, sourceId, [
|
||||
{ sourceColumn: "Nome", modelColumn: "Nome" },
|
||||
{ sourceColumn: "Valor", modelColumn: "Valor" },
|
||||
]);
|
||||
const done = await startProcess(deps, ana, job.id, sourceId, "proc-iso");
|
||||
expect(done.status).toBe("completed");
|
||||
await expect(downloadOutput(deps, bruno, job.id, sourceId, "csv")).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
await expect(downloadOutput(deps, diego, job.id, sourceId, "xlsx")).rejects.toMatchObject({ code: "FEATURE_DISABLED" });
|
||||
await expect(listModels(deps, diego)).rejects.toMatchObject({ code: "FEATURE_DISABLED" });
|
||||
await db.withService((query) =>
|
||||
query.query(`update public.workspace_module_flags set enabled = false where workspace_id = $1`, [IDS.alpha]),
|
||||
);
|
||||
await expect(downloadOutput(deps, ana, job.id, sourceId, "csv")).rejects.toMatchObject({ code: "FEATURE_DISABLED" });
|
||||
await db.withService((query) =>
|
||||
query.query(`update public.workspace_module_flags set enabled = true where workspace_id = $1`, [IDS.alpha]),
|
||||
);
|
||||
const outsider = { ...ana, workspaceId: "33333333-3333-4333-8333-333333333333" };
|
||||
await db.withService((query) =>
|
||||
query.query(`insert into public.workspaces (id, name) values ($1, 'Gamma')`, [outsider.workspaceId]),
|
||||
);
|
||||
await db.withService((query) =>
|
||||
query.query(
|
||||
`insert into public.workspace_module_flags (workspace_id, module_key, enabled) values ($1, 'spreadsheet_adapter', true)`,
|
||||
[outsider.workspaceId],
|
||||
),
|
||||
);
|
||||
await expect(listModels(deps, outsider)).rejects.toBeInstanceOf(AdapterError);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
it("mantém o arquivo do outro workspace na limpeza", async () => {
|
||||
const db = await createReadyDb();
|
||||
const deps = depsOf(db);
|
||||
const ana = ctxOf("ana", "alpha", "owner", true);
|
||||
const model = await createModel(deps, ana, {
|
||||
name: "TTL",
|
||||
filename: "modelo.csv",
|
||||
bytes: Buffer.from("Nome\n", "utf8"),
|
||||
requiredColumns: ["Nome"],
|
||||
});
|
||||
const job = await createJob(deps, ana, model.id, "job-ttl");
|
||||
const source = await addSource(deps, ana, job.id, { filename: "fonte.csv", bytes: Buffer.from("Nome\nAna\n", "utf8") });
|
||||
if (!("job" in source)) throw new Error("fonte");
|
||||
const sourceId = source.job.sources[0]?.id ?? "";
|
||||
await saveMappings(deps, ana, job.id, sourceId, [{ sourceColumn: "Nome", modelColumn: "Nome" }]);
|
||||
await startProcess(deps, ana, job.id, sourceId, "proc-ttl");
|
||||
const foreign = `workspaces/${IDS.beta}/tmp/outro/source`;
|
||||
await deps.storage.put(foreign, Buffer.from("nao-apagar"));
|
||||
deps.ttlSeconds = -1;
|
||||
await cleanupExpired(deps);
|
||||
await cleanupExpired(deps);
|
||||
expect(await deps.storage.get(foreign)).toEqual(Buffer.from("nao-apagar"));
|
||||
await db.close();
|
||||
});
|
||||
|
||||
it("duas transferências simultâneas do mesmo usuário terminam em um único resultado", async () => {
|
||||
const db = await createReadyDb();
|
||||
const deps = depsOf(db);
|
||||
const ana = ctxOf("ana", "alpha", "owner", true);
|
||||
const model = await createModel(deps, ana, {
|
||||
name: "Concorrencia",
|
||||
filename: "modelo.csv",
|
||||
bytes: Buffer.from("Nome\n", "utf8"),
|
||||
requiredColumns: ["Nome"],
|
||||
});
|
||||
const job = await createJob(deps, ana, model.id, "job-lock");
|
||||
const source = await addSource(deps, ana, job.id, { filename: "fonte.csv", bytes: Buffer.from("Nome\nAna\n", "utf8") });
|
||||
if (!("job" in source)) throw new Error("fonte");
|
||||
const sourceId = source.job.sources[0]?.id ?? "";
|
||||
await saveMappings(deps, ana, job.id, sourceId, [{ sourceColumn: "Nome", modelColumn: "Nome" }]);
|
||||
const settled = await Promise.allSettled([
|
||||
startProcess(deps, ana, job.id, sourceId, "mesmo"),
|
||||
startProcess(deps, ana, job.id, sourceId, "mesmo"),
|
||||
]);
|
||||
for (const item of settled) {
|
||||
if (item.status === "rejected") expect((item.reason as AdapterError).code).toMatch(/JOB_LOCKED|ALREADY_COMPLETED/);
|
||||
else expect(item.value.status === "completed" || item.value.status === "processing" || item.value.status === "queued").toBe(true);
|
||||
}
|
||||
const again = await startProcess(deps, ana, job.id, sourceId, "mesmo");
|
||||
expect(again.status).toBe("completed");
|
||||
await db.close();
|
||||
});
|
||||
|
||||
it("reenfileira falha transitória e conclui na tentativa seguinte", async () => {
|
||||
const db = await createReadyDb();
|
||||
const deps = depsOf(db);
|
||||
const inner = deps.storage;
|
||||
let failures = 1;
|
||||
const flaky: ObjectStorage = {
|
||||
put: (key, body) => inner.put(key, body),
|
||||
delete: (key) => inner.delete(key),
|
||||
list: (prefix) => inner.list(prefix),
|
||||
async get(key) {
|
||||
if (failures > 0 && key.includes("/tmp/")) {
|
||||
failures -= 1;
|
||||
throw new Error("transient");
|
||||
}
|
||||
return inner.get(key);
|
||||
},
|
||||
};
|
||||
deps.storage = flaky;
|
||||
const ana = ctxOf("ana", "alpha", "owner", true);
|
||||
const model = await createModel(deps, ana, {
|
||||
name: "Retry",
|
||||
filename: "modelo.csv",
|
||||
bytes: Buffer.from("Nome\n", "utf8"),
|
||||
requiredColumns: ["Nome"],
|
||||
});
|
||||
const job = await createJob(deps, ana, model.id, "job-retry");
|
||||
const source = await addSource(deps, ana, job.id, { filename: "fonte.csv", bytes: Buffer.from("Nome\nAna\n", "utf8") });
|
||||
if (!("job" in source)) throw new Error("fonte");
|
||||
const sourceId = source.job.sources[0]?.id ?? "";
|
||||
await saveMappings(deps, ana, job.id, sourceId, [{ sourceColumn: "Nome", modelColumn: "Nome" }]);
|
||||
await expect(startProcess(deps, ana, job.id, sourceId, "retry-1")).rejects.toMatchObject({ code: "PROCESS_FAILED" });
|
||||
const done = await runProcess(deps, ana, job.id, sourceId).then(() => startProcess(deps, ana, job.id, sourceId, "retry-1"));
|
||||
expect(done.status).toBe("completed");
|
||||
await db.close();
|
||||
});
|
||||
|
||||
it("encerra por timeout sem nova tentativa infinita", async () => {
|
||||
const previous = process.env.SPREADSHEET_JOB_TIMEOUT_MS;
|
||||
process.env.SPREADSHEET_JOB_TIMEOUT_MS = "0";
|
||||
const db = await createReadyDb();
|
||||
const deps = depsOf(db);
|
||||
const ana = ctxOf("ana", "alpha", "owner", true);
|
||||
try {
|
||||
const model = await createModel(deps, ana, {
|
||||
name: "Timeout",
|
||||
filename: "modelo.csv",
|
||||
bytes: Buffer.from("Nome\n", "utf8"),
|
||||
requiredColumns: ["Nome"],
|
||||
});
|
||||
const job = await createJob(deps, ana, model.id, "job-timeout");
|
||||
const source = await addSource(deps, ana, job.id, { filename: "fonte.csv", bytes: Buffer.from("Nome\nAna\n", "utf8") });
|
||||
if (!("job" in source)) throw new Error("fonte");
|
||||
const sourceId = source.job.sources[0]?.id ?? "";
|
||||
await saveMappings(deps, ana, job.id, sourceId, [{ sourceColumn: "Nome", modelColumn: "Nome" }]);
|
||||
await expect(startProcess(deps, ana, job.id, sourceId, "timeout-1")).rejects.toMatchObject({ code: "TIMEOUT" });
|
||||
const rows = await db.withService((query) =>
|
||||
query.query<{ status: string }>(`select status from public.spreadsheet_jobs where id = $1`, [job.id]),
|
||||
);
|
||||
expect(rows[0]?.status).toBe("failed");
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.SPREADSHEET_JOB_TIMEOUT_MS;
|
||||
else process.env.SPREADSHEET_JOB_TIMEOUT_MS = previous;
|
||||
await db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("recusa macro, CSV inválido e célula enorme", async () => {
|
||||
expect(() => assertZipSafe(zipEntry("xl/vbaProject.bin"))).toThrow(/macros/i);
|
||||
await expect(readSpreadsheet("dados.csv", Buffer.from([0xff, 0xfe, 0x41]))).rejects.toMatchObject({ code: "CSV_INVALID" });
|
||||
const huge = Buffer.from(`Nome\n${"A".repeat(8001)}\n`, "utf8");
|
||||
await expect(readSpreadsheet("grande.csv", huge)).rejects.toMatchObject({ code: "CELL_TOO_LARGE" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const real = process.env.SMOKE_REAL === "1";
|
||||
|
||||
describe.skipIf(!real)("smoke no ambiente real", () => {
|
||||
it("autentica no Supabase, processa e baixa no bucket privado", () => {
|
||||
throw new Error("SMOKE_REAL=1 foi definido, mas este teste ainda precisa do roteiro operacional.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("smoke local", () => {
|
||||
it("o caminho completo sem infraestrutura externa está em tests/integration/journey.test.ts", () => {
|
||||
expect(real).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { tempTtlSeconds } from "@/config/retention";
|
||||
import { collectProductionIssues } from "@/config/env";
|
||||
import { shouldRequeue } from "@/domain/spreadsheet-adapter/failure-policy";
|
||||
import { devAuthEnabled, verifyDevSession } from "@/server/auth/dev-session";
|
||||
import { buildCentralLoginUrl, safeReturnPath } from "@/server/auth/return-to";
|
||||
import { buildSentryPayload } from "@/infra/observability/capture";
|
||||
|
||||
describe("política de piloto", () => {
|
||||
it("recusa returnTo externo e aceita caminho interno", () => {
|
||||
expect(safeReturnPath("https://evil.example/phish")).toBe("/adaptador");
|
||||
expect(safeReturnPath("//evil.example")).toBe("/adaptador");
|
||||
expect(safeReturnPath("/adaptador/operacao")).toBe("/adaptador/operacao");
|
||||
const url = buildCentralLoginUrl("https://login.elo.example/entrar", "https://evil.example", "https://app.elo.example");
|
||||
expect(url).not.toContain("evil.example");
|
||||
expect(url).toContain("returnTo=");
|
||||
});
|
||||
|
||||
it("bloqueia autenticação de desenvolvimento em produção", () => {
|
||||
const previous = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = "production";
|
||||
process.env.ADAPTER_DEV_AUTH = "1";
|
||||
process.env.ADAPTER_DEV_AUTH_SECRET = "segredo-local";
|
||||
try {
|
||||
expect(devAuthEnabled()).toBe(false);
|
||||
expect(verifyDevSession("corpo.assinatura")).toBeNull();
|
||||
} finally {
|
||||
process.env.NODE_ENV = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it("recusa produção sem Redis, com inline, com bucket público ou TTL inválido", () => {
|
||||
const issues = collectProductionIssues({
|
||||
NODE_ENV: "production",
|
||||
ADAPTER_DEV_AUTH: "1",
|
||||
ADAPTER_JOB_MODE: "inline",
|
||||
SUPABASE_STORAGE_BUCKET: "public",
|
||||
SPREADSHEET_TEMP_TTL_SECONDS: "0",
|
||||
});
|
||||
const names = issues.map((issue) => issue.variable);
|
||||
expect(names).toContain("REDIS_URL");
|
||||
expect(names).toContain("ADAPTER_DEV_AUTH");
|
||||
expect(names).toContain("ADAPTER_JOB_MODE");
|
||||
expect(names).toContain("SUPABASE_STORAGE_BUCKET");
|
||||
expect(names).toContain("SPREADSHEET_TEMP_TTL_SECONDS");
|
||||
});
|
||||
|
||||
it("exige TTL explícito em produção", () => {
|
||||
const node = process.env.NODE_ENV;
|
||||
const ttl = process.env.SPREADSHEET_TEMP_TTL_SECONDS;
|
||||
process.env.NODE_ENV = "production";
|
||||
delete process.env.SPREADSHEET_TEMP_TTL_SECONDS;
|
||||
try {
|
||||
expect(() => tempTtlSeconds()).toThrow(/obrigatório/);
|
||||
process.env.SPREADSHEET_TEMP_TTL_SECONDS = "abc";
|
||||
expect(() => tempTtlSeconds()).toThrow(/inválido/);
|
||||
} finally {
|
||||
process.env.NODE_ENV = node;
|
||||
if (ttl === undefined) delete process.env.SPREADSHEET_TEMP_TTL_SECONDS;
|
||||
else process.env.SPREADSHEET_TEMP_TTL_SECONDS = ttl;
|
||||
}
|
||||
});
|
||||
|
||||
it("não reenfileira timeout e reenfileira falha transitória", () => {
|
||||
expect(shouldRequeue("TIMEOUT", 504, 1)).toBe(false);
|
||||
expect(shouldRequeue("PROCESS_FAILED", 500, 1)).toBe(true);
|
||||
expect(shouldRequeue("PROCESS_FAILED", 500, 3)).toBe(false);
|
||||
});
|
||||
|
||||
it("não envia célula sentinela ao payload do Sentry", () => {
|
||||
const sentinel = "Ana Silva =CMD()";
|
||||
const payload = JSON.stringify(
|
||||
buildSentryPayload(new Error(`valor da célula ${sentinel}`), {
|
||||
job_id: "11111111-1111-4111-8111-111111111111",
|
||||
error_code: "PROCESS_FAILED",
|
||||
}),
|
||||
);
|
||||
expect(payload).not.toContain(sentinel);
|
||||
expect(payload).not.toContain("Ana Silva");
|
||||
expect(payload).toContain("UNEXPECTED");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import ExcelJS from "exceljs";
|
||||
import { readSpreadsheet } from "@/infra/spreadsheet/parse";
|
||||
import { createModel, addSource, createJob, saveMappings, startProcess } from "@/server/spreadsheet-adapter/service";
|
||||
import { ctxOf, createReadyDb, depsOf, IDS } from "../helpers/world";
|
||||
|
||||
describe("fixtures XLSX", () => {
|
||||
it("lê abas, fórmula como texto, data, coluna vazia e caracteres especiais sem executar fórmula", async () => {
|
||||
const book = new ExcelJS.Workbook();
|
||||
const first = book.addWorksheet("Dados");
|
||||
first.addRow(["Nome", "Valor", ""]);
|
||||
first.getCell("A2").value = "José & Ação";
|
||||
first.getCell("B2").value = new Date("2026-01-15T00:00:00.000Z");
|
||||
first.mergeCells("A3:B3");
|
||||
first.getCell("A3").value = "=1+1";
|
||||
book.addWorksheet("Outra").addRow(["ignorar"]);
|
||||
const bytes = Buffer.from(await book.xlsx.writeBuffer());
|
||||
const parsed = await readSpreadsheet("livro.xlsx", bytes, { sheet: "Dados", headerRow: 1 });
|
||||
expect(parsed.grid.sheetNames).toContain("Outra");
|
||||
expect(parsed.grid.headers).toContain("Nome");
|
||||
const flat = parsed.grid.rows.flat().join("|");
|
||||
expect(flat).toContain("José & Ação");
|
||||
expect(flat).not.toMatch(/^2$/);
|
||||
expect(flat).toContain("=1+1");
|
||||
});
|
||||
|
||||
it("não altera os bytes do modelo ao processar", async () => {
|
||||
const db = await createReadyDb();
|
||||
const deps = depsOf(db);
|
||||
const ana = ctxOf("ana", "alpha", "owner", true);
|
||||
const model = await createModel(deps, ana, {
|
||||
name: "Livro",
|
||||
filename: "modelo.csv",
|
||||
bytes: Buffer.from("Nome\n", "utf8"),
|
||||
requiredColumns: ["Nome"],
|
||||
});
|
||||
const before = await deps.storage.get(`workspaces/${IDS.alpha}/models/${model.id}/original`);
|
||||
const job = await createJob(deps, ana, model.id, "xlsx-model");
|
||||
const source = await addSource(deps, ana, job.id, { filename: "fonte.csv", bytes: Buffer.from("Nome\nJosé\n", "utf8") });
|
||||
if (!("job" in source)) throw new Error("fonte");
|
||||
const sourceId = source.job.sources[0]?.id ?? "";
|
||||
await saveMappings(deps, ana, job.id, sourceId, [{ sourceColumn: "Nome", modelColumn: "Nome" }]);
|
||||
await startProcess(deps, ana, job.id, sourceId, "xlsx-proc");
|
||||
const after = await deps.storage.get(`workspaces/${IDS.alpha}/models/${model.id}/original`);
|
||||
expect(after.equals(before)).toBe(true);
|
||||
await db.close();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user