metis-desk/apps/server/src/template-propagation.integration.test.ts
David ded45e1c42 feat: add AssetRelatedInformation component for displaying asset profiles and software installations
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
2026-10-19 11:45:09 +02:00

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