test: implement resource template propagation tests for resource revisions feat: create prepareResourceTemplateUpdates function for handling resource template updates feat: add resource revision specifications for managing resource revisions in the database test: add tests for entity column visibility and local storage handling feat: implement entity column visibility storage for managing column preferences feat: create synchronizeTemplateStructure utility for managing template field and section synchronization
469 lines
17 KiB
TypeScript
469 lines
17 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { test } from "node:test";
|
|
|
|
import type { Sequelize, Transaction } from "sequelize";
|
|
import { DataTypes, QueryTypes } from "sequelize";
|
|
|
|
import { AssetCatalogUseCases } from "../../../modules/assets/src/api/application/asset-catalog.use-cases";
|
|
import { Asset } from "../../../modules/assets/src/api/domain/asset.aggregate";
|
|
import {
|
|
AssetSpecification,
|
|
AssetValues,
|
|
} from "../../../modules/assets/src/api/domain/value-objects/asset-values.vo";
|
|
import {
|
|
AvailabilityModel,
|
|
BindingModel,
|
|
CatalogModel,
|
|
} from "../../../modules/assets/src/api/infrastructure/persistence/asset-catalog.model";
|
|
import { AssetCatalogPersistenceMapper } from "../../../modules/assets/src/api/infrastructure/persistence/asset-catalog-persistence.mapper";
|
|
import {
|
|
readInventory as readAssets,
|
|
writeInventory as writeAssets,
|
|
} from "../../../modules/assets/src/api/infrastructure/persistence/asset-inventory-relational";
|
|
import { AssetPersistenceMapper } from "../../../modules/assets/src/api/infrastructure/persistence/asset-persistence.mapper";
|
|
import { SequelizeAssetCatalogRepository } from "../../../modules/assets/src/api/infrastructure/persistence/sequelize-asset-catalog.repository";
|
|
import { ResourceCatalogUseCases } from "../../../modules/resources/src/api/application/resource-catalog.use-cases";
|
|
import { Resource } from "../../../modules/resources/src/api/domain/resource.aggregate";
|
|
import {
|
|
ResourceSpecification,
|
|
ResourceValues,
|
|
} from "../../../modules/resources/src/api/domain/value-objects/resource-values.vo";
|
|
import {
|
|
ResourceAvailabilityModel,
|
|
ResourceBindingModel,
|
|
ResourceCatalogModel,
|
|
} from "../../../modules/resources/src/api/infrastructure/persistence/resource-catalog.model";
|
|
import { ResourceCatalogPersistenceMapper } from "../../../modules/resources/src/api/infrastructure/persistence/resource-catalog-persistence.mapper";
|
|
import {
|
|
readInventory as readResources,
|
|
writeInventory as writeResources,
|
|
} from "../../../modules/resources/src/api/infrastructure/persistence/resource-inventory-relational";
|
|
import { ResourcePersistenceMapper } from "../../../modules/resources/src/api/infrastructure/persistence/resource-persistence.mapper";
|
|
import { SequelizeResourceCatalogRepository } from "../../../modules/resources/src/api/infrastructure/persistence/sequelize-resource-catalog.repository";
|
|
import { UniqueID } from "../../../packages/rdx-ddd/src/index";
|
|
|
|
import { createDatabase, readDatabaseEnvironment } from "./config/database";
|
|
import { revisionSpecificationTestTables } from "./template-specification-test-tables";
|
|
|
|
const id = () => UniqueID.generateNewID();
|
|
|
|
function tables(entity: "asset" | "resource") {
|
|
const owner = `${entity}Id`;
|
|
const integration =
|
|
"integrationKind VARCHAR(10),addressFieldId CHAR(36),contextFieldId CHAR(36),mailboxKind VARCHAR(10),accountId CHAR(36),referenceFieldId CHAR(36),resourceTypeId CHAR(36),systemId CHAR(36),environmentId CHAR(36)";
|
|
const values =
|
|
entity === "asset"
|
|
? "siteId CHAR(36),brandId CHAR(36),registeredOn DATE,status VARCHAR(15),brandDefined BOOLEAN"
|
|
: `active BOOLEAN,${integration}`;
|
|
const characteristic =
|
|
"fieldId CHAR(36),valueType VARCHAR(12),textValue TEXT,numberValue DOUBLE,booleanValue BOOLEAN,dateValue DATE";
|
|
|
|
return {
|
|
companies: "id CHAR(36) PRIMARY KEY",
|
|
[`${entity}_catalogs`]: "id CHAR(36) PRIMARY KEY,catalogKey VARCHAR(20)",
|
|
[`${entity}_definitions`]: `id CHAR(36) PRIMARY KEY,catalogId CHAR(36),companyId CHAR(36),stockKey VARCHAR(100),active BOOLEAN,kind VARCHAR(12),name VARCHAR(100),description TEXT,icon VARCHAR(30),fieldType VARCHAR(12),unit VARCHAR(30),sectionsDefined BOOLEAN,${entity === "asset" ? "inverseName VARCHAR(100),symmetric BOOLEAN" : "`usage` VARCHAR(15)"}`,
|
|
[`${entity}_template_fields`]:
|
|
"id CHAR(36) PRIMARY KEY,templateId CHAR(36),fieldId CHAR(36),required BOOLEAN,position INTEGER,sectionId CHAR(36)",
|
|
[`${entity}_template_sections`]:
|
|
"id CHAR(36),templateId CHAR(36),name VARCHAR(100),description TEXT,position INTEGER,PRIMARY KEY(templateId,id)",
|
|
[`${entity}_field_choices`]:
|
|
"fieldId CHAR(36),position INTEGER,value VARCHAR(100),PRIMARY KEY(fieldId,position)",
|
|
[`${entity}_availability`]:
|
|
"id CHAR(36) PRIMARY KEY,catalogId CHAR(36),companyId CHAR(36),definitionId CHAR(36),active BOOLEAN",
|
|
[`${entity}s`]: `id CHAR(36) PRIMARY KEY,companyId CHAR(36),templateId CHAR(36),name VARCHAR(100),notes TEXT,templateName VARCHAR(100),sectionsDefined BOOLEAN,${values}, CHECK (name <> 'Second' OR templateName <> 'Rechazada')`,
|
|
[`${entity}_revisions`]: `id CHAR(36) PRIMARY KEY,${owner} CHAR(36),actorId CHAR(36),recordedAt VARCHAR(30),position INTEGER,templateId CHAR(36),name VARCHAR(100),notes TEXT,${values}`,
|
|
[`${entity}_specification_fields`]: `${owner} CHAR(36),fieldId CHAR(36),name VARCHAR(100),fieldType VARCHAR(12),unit VARCHAR(30),required BOOLEAN,sectionId CHAR(36),position INTEGER,PRIMARY KEY(${owner},fieldId)`,
|
|
[`${entity}_specification_sections`]: `${owner} CHAR(36),id CHAR(36),name VARCHAR(100),description TEXT,position INTEGER,PRIMARY KEY(${owner},id)`,
|
|
[`${entity}_specification_field_choices`]: `${owner} CHAR(36),fieldId CHAR(36),position INTEGER,value VARCHAR(100),PRIMARY KEY(${owner},fieldId,position)`,
|
|
[`${entity}_characteristics`]: `${owner} CHAR(36),${characteristic},PRIMARY KEY(${owner},fieldId)`,
|
|
[`${entity}_revision_characteristics`]: `revisionId CHAR(36),${owner} CHAR(36),${characteristic},PRIMARY KEY(revisionId,fieldId)`,
|
|
...revisionSpecificationTestTables(entity),
|
|
};
|
|
}
|
|
|
|
function initializeModels(database: Sequelize) {
|
|
const identity = () => ({ type: DataTypes.UUID, allowNull: false });
|
|
|
|
const common = () => ({
|
|
id: { ...identity(), primaryKey: true },
|
|
catalogId: identity(),
|
|
companyId: identity(),
|
|
definitionId: identity(),
|
|
active: { type: DataTypes.BOOLEAN, allowNull: true },
|
|
});
|
|
|
|
const bindings = () => ({
|
|
id: { ...identity(), primaryKey: true },
|
|
templateId: identity(),
|
|
fieldId: identity(),
|
|
required: { type: DataTypes.BOOLEAN, allowNull: false },
|
|
position: { type: DataTypes.INTEGER, allowNull: false },
|
|
sectionId: { type: DataTypes.UUID, allowNull: true },
|
|
});
|
|
|
|
CatalogModel.init(
|
|
{
|
|
id: { ...identity(), primaryKey: true },
|
|
catalogKey: { type: DataTypes.STRING(20), allowNull: false },
|
|
},
|
|
{ sequelize: database, tableName: "asset_catalogs", timestamps: false }
|
|
);
|
|
AvailabilityModel.init(common(), {
|
|
sequelize: database,
|
|
tableName: "asset_availability",
|
|
timestamps: false,
|
|
});
|
|
BindingModel.init(bindings(), {
|
|
sequelize: database,
|
|
tableName: "asset_template_fields",
|
|
timestamps: false,
|
|
});
|
|
ResourceCatalogModel.init(
|
|
{
|
|
id: { ...identity(), primaryKey: true },
|
|
catalogKey: { type: DataTypes.STRING(20), allowNull: false },
|
|
},
|
|
{ sequelize: database, tableName: "resource_catalogs", timestamps: false }
|
|
);
|
|
ResourceAvailabilityModel.init(common(), {
|
|
sequelize: database,
|
|
tableName: "resource_availability",
|
|
timestamps: false,
|
|
});
|
|
ResourceBindingModel.init(bindings(), {
|
|
sequelize: database,
|
|
tableName: "resource_template_fields",
|
|
timestamps: false,
|
|
});
|
|
}
|
|
|
|
async function readEntity(
|
|
database: Sequelize,
|
|
entity: "asset" | "resource",
|
|
company: string,
|
|
transaction: Transaction
|
|
) {
|
|
if (entity === "asset") {
|
|
const data = await readAssets(database, company, transaction);
|
|
const row = data.rows[0];
|
|
|
|
assert.ok(row);
|
|
|
|
return new AssetPersistenceMapper().toDomain(row, data.revisions);
|
|
}
|
|
const data = await readResources(database, company, transaction);
|
|
const row = data.rows[0];
|
|
|
|
assert.ok(row);
|
|
|
|
return new ResourcePersistenceMapper().toDomain(row, data.revisions);
|
|
}
|
|
|
|
function isolateTableQueries(database: Sequelize, names: readonly string[]) {
|
|
const prefix = `tp_${id().toPrimitive().slice(-8)}_`;
|
|
const tableNames = new Set(names);
|
|
const originalQuery = database.query;
|
|
|
|
// Las tablas temporales de MariaDB no restauran DELETE + INSERT al volver a un savepoint.
|
|
// Se aíslan tablas InnoDB reales mediante nombres exclusivos dentro de la base de pruebas.
|
|
database.query = new Proxy(originalQuery, {
|
|
apply(target, thisArg, args) {
|
|
const sql: unknown = args[0];
|
|
|
|
const rewrite = (text: string) =>
|
|
text.replace(/\b[a-z_]+\b/g, (name) => (tableNames.has(name) ? `${prefix}${name}` : name));
|
|
|
|
if (typeof sql === "string") {
|
|
args[0] = rewrite(sql);
|
|
} else if (
|
|
sql &&
|
|
typeof sql === "object" &&
|
|
"query" in sql &&
|
|
typeof sql.query === "string"
|
|
) {
|
|
args[0] = { ...sql, query: rewrite(sql.query) };
|
|
} else {
|
|
throw new Error("Consulta SQL de prueba no reconocida.");
|
|
}
|
|
|
|
return Reflect.apply(target, thisArg, args);
|
|
},
|
|
});
|
|
|
|
return () => {
|
|
database.query = originalQuery;
|
|
};
|
|
}
|
|
|
|
function nestedTransactionDatabase(database: Sequelize, transaction: Transaction) {
|
|
// Los repositorios usan savepoints de la transacción que aísla los datos de la prueba.
|
|
const originalTransaction = database.transaction.bind(database);
|
|
|
|
return new Proxy(database, {
|
|
get(target, property, receiver) {
|
|
if (property !== "transaction") {
|
|
return Reflect.get(target, property, receiver);
|
|
}
|
|
|
|
return async (operation: (transaction: Transaction) => Promise<unknown>) => {
|
|
const savepoint = await originalTransaction({ transaction });
|
|
|
|
try {
|
|
const result = await operation(savepoint);
|
|
|
|
await savepoint.commit();
|
|
|
|
return result;
|
|
} catch (error) {
|
|
await savepoint.rollback();
|
|
throw error;
|
|
}
|
|
};
|
|
},
|
|
});
|
|
}
|
|
|
|
async function verifyPropagation(database: Sequelize, entity: "asset" | "resource") {
|
|
const schemas = tables(entity);
|
|
const restoreQueries = isolateTableQueries(database, Object.keys(schemas));
|
|
|
|
for (const [table, columns] of Object.entries(schemas)) {
|
|
await database.query(`CREATE TABLE ${table} (${columns}) ENGINE=InnoDB`);
|
|
}
|
|
const transaction = await database.transaction();
|
|
const fixedDatabase = nestedTransactionDatabase(database, transaction);
|
|
|
|
try {
|
|
const actor = id();
|
|
const companies = [id(), id()].sort((a, b) => a.toPrimitive().localeCompare(b.toPrimitive()));
|
|
const catalogId = id();
|
|
|
|
await database.query(`INSERT INTO ${entity}_catalogs (id,catalogKey) VALUES (:id,'global')`, {
|
|
replacements: { id: catalogId.toPrimitive() },
|
|
transaction,
|
|
});
|
|
for (const company of companies) {
|
|
await database.query("INSERT INTO companies (id) VALUES (:id)", {
|
|
replacements: { id: company.toPrimitive() },
|
|
transaction,
|
|
});
|
|
}
|
|
const service =
|
|
entity === "asset"
|
|
? new AssetCatalogUseCases(
|
|
new SequelizeAssetCatalogRepository(fixedDatabase, new AssetCatalogPersistenceMapper())
|
|
)
|
|
: new ResourceCatalogUseCases(
|
|
new SequelizeResourceCatalogRepository(
|
|
fixedDatabase,
|
|
new ResourceCatalogPersistenceMapper()
|
|
)
|
|
);
|
|
const saved = await service.save(
|
|
"global",
|
|
undefined,
|
|
{ kind: "field", name: "Valor", description: "", type: "text", unit: "", choices: [] },
|
|
actor.toPrimitive()
|
|
);
|
|
|
|
assert.ok(saved.isSuccess);
|
|
const field = saved.data.definitions.find((item) => item.values.data.name === "Valor");
|
|
|
|
assert.ok(field);
|
|
const section = { id: id().toPrimitive(), name: "Original", description: "" };
|
|
const templateValues = {
|
|
kind: "template" as const,
|
|
name: "Modelo",
|
|
description: "",
|
|
fields: [{ fieldId: field.id.toPrimitive(), required: false, sectionId: section.id }],
|
|
sections: [section],
|
|
};
|
|
const templateSaved = await service.save(
|
|
"global",
|
|
undefined,
|
|
templateValues,
|
|
actor.toPrimitive()
|
|
);
|
|
|
|
assert.ok(templateSaved.isSuccess);
|
|
const template = templateSaved.data.definitions.find(
|
|
(item) => item.values.data.name === "Modelo"
|
|
);
|
|
|
|
assert.ok(template);
|
|
for (const company of companies) {
|
|
const spec = {
|
|
templateId: template.id.toPrimitive(),
|
|
templateName: "Modelo",
|
|
sections: [section],
|
|
fields: [
|
|
{
|
|
fieldId: field.id.toPrimitive(),
|
|
name: "Valor",
|
|
type: "text" as const,
|
|
unit: "",
|
|
choices: [],
|
|
required: false,
|
|
sectionId: section.id,
|
|
},
|
|
],
|
|
};
|
|
const common = {
|
|
name: company.equals(companies[1]) ? "Second" : "First",
|
|
templateId: template.id.toPrimitive(),
|
|
notes: "",
|
|
characteristics: { [field.id.toPrimitive()]: "No perder" },
|
|
};
|
|
|
|
if (entity === "asset") {
|
|
const specification = AssetSpecification.create(spec);
|
|
|
|
assert.ok(specification.isSuccess);
|
|
const values = AssetValues.create(
|
|
{ ...common, siteId: null, status: "operational" },
|
|
specification.data
|
|
);
|
|
|
|
assert.ok(values.isSuccess);
|
|
const item = Asset.create(
|
|
company,
|
|
specification.data,
|
|
values.data,
|
|
actor,
|
|
"2026-10-18T10:00:00.000Z"
|
|
);
|
|
|
|
assert.ok(item.isSuccess);
|
|
await writeAssets(
|
|
database,
|
|
new AssetPersistenceMapper().toPersistence(item.data),
|
|
new Set(),
|
|
transaction
|
|
);
|
|
} else {
|
|
const specification = ResourceSpecification.create(spec);
|
|
|
|
assert.ok(specification.isSuccess);
|
|
const values = ResourceValues.create({ ...common, active: true }, specification.data);
|
|
|
|
assert.ok(values.isSuccess);
|
|
const item = Resource.create(
|
|
company,
|
|
specification.data,
|
|
values.data,
|
|
actor,
|
|
"2026-10-18T10:00:00.000Z"
|
|
);
|
|
|
|
assert.ok(item.isSuccess);
|
|
await writeResources(
|
|
database,
|
|
new ResourcePersistenceMapper().toPersistence(item.data),
|
|
new Set(),
|
|
transaction
|
|
);
|
|
}
|
|
}
|
|
const added = await service.save(
|
|
"global",
|
|
undefined,
|
|
{ kind: "field", name: "Nuevo", description: "", type: "text", unit: "", choices: [] },
|
|
actor.toPrimitive()
|
|
);
|
|
|
|
assert.ok(added.isSuccess);
|
|
const newField = added.data.definitions.find((item) => item.values.data.name === "Nuevo");
|
|
|
|
assert.ok(newField);
|
|
const next = {
|
|
...templateValues,
|
|
sections: [{ ...section, name: "Reordenada" }],
|
|
fields: [{ fieldId: newField.id.toPrimitive(), required: true, sectionId: section.id }],
|
|
};
|
|
const changed = await service.save(
|
|
"global",
|
|
template.id.toPrimitive(),
|
|
next,
|
|
actor.toPrimitive()
|
|
);
|
|
|
|
assert.ok(changed.isSuccess);
|
|
const read = entity === "asset" ? readAssets : readResources;
|
|
|
|
for (const company of companies) {
|
|
const data = await read(database, company.toPrimitive(), transaction);
|
|
|
|
assert.equal(data.rows.length, 1);
|
|
const parsed = await readEntity(database, entity, company.toPrimitive(), transaction);
|
|
|
|
assert.equal(parsed.history.length, 2);
|
|
assert.equal(
|
|
parsed.values.toPrimitive().characteristics[field.id.toPrimitive()],
|
|
"No perder"
|
|
);
|
|
assert.deepEqual(
|
|
parsed.specification.toPrimitive().fields.map((item) => item.fieldId),
|
|
[newField.id.toPrimitive(), field.id.toPrimitive()]
|
|
);
|
|
assert.equal(parsed.history[0]?.specification.toPrimitive().sections?.[0]?.name, "Original");
|
|
}
|
|
const repeated = await service.save(
|
|
"global",
|
|
template.id.toPrimitive(),
|
|
next,
|
|
actor.toPrimitive()
|
|
);
|
|
|
|
assert.ok(repeated.isSuccess);
|
|
const count = await database.query<{ count: number }>(
|
|
`SELECT COUNT(*) AS count FROM ${entity}_revisions`,
|
|
{ type: QueryTypes.SELECT, transaction }
|
|
);
|
|
|
|
assert.equal(Number(count[0]?.count), 4);
|
|
await assert.rejects(() =>
|
|
service.save(
|
|
"global",
|
|
template.id.toPrimitive(),
|
|
{ ...next, name: "Rechazada" },
|
|
actor.toPrimitive()
|
|
)
|
|
);
|
|
const after = await service.get("global");
|
|
|
|
assert.ok(after.isSuccess);
|
|
assert.equal(
|
|
after.data.definitions.find((item) => item.id.equals(template.id))?.values.data.name,
|
|
"Modelo"
|
|
);
|
|
for (const company of companies) {
|
|
const data = await read(database, company.toPrimitive(), transaction);
|
|
|
|
assert.equal(data.revisions.length, 2);
|
|
}
|
|
} finally {
|
|
await transaction.rollback();
|
|
for (const table of Object.keys(schemas).reverse()) {
|
|
await database.query(`DROP TABLE IF EXISTS ${table}`);
|
|
}
|
|
restoreQueries();
|
|
}
|
|
}
|
|
|
|
test("MariaDB: las plantillas propagan estructura en todas las compañías de forma atómica e idempotente", async () => {
|
|
const env = readDatabaseEnvironment(process.env);
|
|
|
|
assert.equal(env.DB_NAME, "metis_desk_test");
|
|
assert.equal(env.NODE_ENV, "test");
|
|
const database = createDatabase(env);
|
|
|
|
initializeModels(database);
|
|
try {
|
|
for (const entity of ["asset", "resource"] as const) {
|
|
await verifyPropagation(database, entity);
|
|
}
|
|
} finally {
|
|
await database.close();
|
|
}
|
|
});
|