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) => { 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(); } });