Files
dh-inspeccion-v2/api-v3/test/unit/asset-imports-d5-3-7.test.ts
T

160 lines
9.7 KiB
TypeScript

import 'reflect-metadata';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
import {
externalIdNamespace,
generatedImportCode,
isExplicitlyUnassignedOperator,
planItemHash,
planStatusForItems,
summarizePlanItems,
isPlanDependencyReview,
sourceContainerName,
sourceLocalStructureSuggestion,
technicalFamilyTypeCode,
type AssetImportPlanDraftItem,
} from '../../src/asset-imports/asset-import-plan';
import { AssetImportsController } from '../../src/asset-imports/asset-imports.controller';
function source(relative: string): string { return readFileSync(resolve(process.cwd(), relative), 'utf8'); }
function permissionFor(method: string): string[] {
const controller = AssetImportsController.prototype;
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, controller[method as keyof typeof controller]) as string[];
}
function draft(overrides: Partial<AssetImportPlanDraftItem> = {}): AssetImportPlanDraftItem {
return {
entityKey: 'technical:PSENERGY:abc', entityKind: 'TECHNICAL_ASSET', action: 'CREATE', status: 'PLANNED',
assetTypeCode: 'tanque', displayName: 'TK-1', generatedCode: 'IMP-TANQUE-ABC', parentEntityKey: 'field:a', matchedAssetId: null,
payload: { inventoryId: 'TK-1' }, sourceRowNumbers: [4], reviewCodes: [], ...overrides,
};
}
test('D5.3.7 separates planning from privileged application and rollback', () => {
assert.deepEqual(permissionFor('organizations'), ['asset_imports.read']);
assert.deepEqual(permissionFor('plan'), ['asset_imports.read']);
assert.deepEqual(permissionFor('generatePlan'), ['asset_imports.manage']);
assert.deepEqual(permissionFor('resolvePlanItem'), ['asset_imports.manage']);
assert.deepEqual(permissionFor('applyPlan'), ['asset_imports.apply']);
assert.deepEqual(permissionFor('rollbackPlan'), ['asset_imports.apply']);
});
test('D5.3.7 normalizes namespaces and technical families without losing source identity', () => {
assert.equal(externalIdNamespace(undefined, 'PSEnergy-Planilla de Inventario general PS MZA.xlsx'), 'PSENERGY');
assert.equal(externalIdNamespace('Phoenix Mendoza', 'x.xlsx'), 'PHOENIX-MENDOZA');
assert.equal(isExplicitlyUnassignedOperator('Sin Empresa Operadora'), true);
assert.equal(isExplicitlyUnassignedOperator('Petróleos Sudamericanos Energy S.A.'), false);
assert.equal(technicalFamilyTypeCode('bomba', 'centrifuga'), 'bomba');
assert.equal(technicalFamilyTypeCode('válvula', 'presión y vacío'), 'equipo');
assert.equal(sourceContainerName('PLANTA', 'PTC', 'MENDOZA / VIZCACHERAS / PTC / PTCVIZ01'), 'PTCVIZ01');
assert.equal(sourceContainerName('YACIMIENTO', 'BATERIA', 'MENDOZA / VIZCACHERAS / BATERIA / BATVIZ10'), 'BATVIZ10');
assert.equal(sourceContainerName('YACIMIENTO', 'TRANSPORTE', 'MENDOZA / VIZCACHERAS / TRANSPORTE'), null);
assert.deepEqual(sourceLocalStructureSuggestion('ATAMISQUI','YACIMIENTO','BATERIA','MENDOZA / ATAMISQUI / YACIMIENTO / BATATA01'), {
displayName:'BATATA01', sourcePath:['MENDOZA','ATAMISQUI','YACIMIENTO','BATATA01'], sourceInstallation:'YACIMIENTO', sourceSubInstallation:'BATERIA', concreteFromLocation:true, sourceGroupOnly:false,
});
assert.deepEqual(sourceLocalStructureSuggestion('VIZCACHERAS','ENERGIA','SET','MENDOZA / VIZCACHERAS / ENERGIA'), {
displayName:'ENERGIA / SET', sourcePath:['MENDOZA','VIZCACHERAS','ENERGIA'], sourceInstallation:'ENERGIA', sourceSubInstallation:'SET', concreteFromLocation:false, sourceGroupOnly:true,
});
assert.deepEqual(sourceLocalStructureSuggestion('BARRANCAS','YACIMIENTO','BATERIA','MENDOZA / BARRANCAS / BATERIA / BATBCA10 / POZO B-208'), {
displayName:'BATBCA10', sourcePath:['MENDOZA','BARRANCAS','BATERIA','BATBCA10','POZO B-208'], sourceInstallation:'YACIMIENTO', sourceSubInstallation:'BATERIA', concreteFromLocation:true, sourceGroupOnly:false,
});
assert.match(generatedImportCode('11111111-1111-4111-8111-111111111111', 'tanque', 'technical:x'), /^IMP-TANQUE-[A-F0-9]{12}$/);
});
test('D5.3.7 plan hash is stable and review items block application', () => {
const one = draft();
const two = draft({ sourceRowNumbers: [7, 4] });
assert.equal(planItemHash([one]), planItemHash([{ ...one, reviewCodes: [], sourceRowNumbers: [4] }]));
assert.equal(planStatusForItems([one]), 'READY');
assert.equal(planStatusForItems([two, draft({ entityKey: 'review', action: 'REVIEW', status: 'REVIEW', reviewCodes: ['GROUPED_QUANTITY'] })]), 'REVIEW_REQUIRED');
});
test('D5.3.9 separates direct human decisions from automatic review dependencies', () => {
const direct = draft({ entityKey: 'direct', action: 'REVIEW', status: 'REVIEW', reviewCodes: ['PLAN_TERRITORY_AMBIGUOUS'] });
const dependency = draft({ entityKey: 'dependency', action: 'REVIEW', status: 'REVIEW', reviewCodes: ['PLAN_CONTEXT_DECISION_REQUIRED'] });
assert.equal(isPlanDependencyReview(direct), false);
assert.equal(isPlanDependencyReview(dependency), true);
const summary = summarizePlanItems([draft(), direct, dependency]);
assert.equal(summary.reviewItems, 2);
assert.equal(summary.directReviewItems, 1);
assert.equal(summary.dependencyReviewItems, 1);
assert.equal(summary.blocked, true);
});
test('D5.3.7 migration creates protected plan tables and apply permission', () => {
const migration = source('src/database/migrations/1787680800000-phase-d5-3-7-import-planning-application.ts');
assert.match(migration, /CREATE TABLE asset_import_plans/);
assert.match(migration, /CREATE TABLE asset_import_plan_items/);
assert.match(migration, /asset_imports\.apply/);
assert.match(migration, /REVOKE DELETE ON TABLE asset_import_plans, asset_import_plan_items/);
assert.match(migration, /uq_asset_import_plan_active_batch/);
});
test('D5.3.7 warning rows require review and duplicate inventory IDs get row-scoped plan keys', () => {
const service = source('src/asset-imports/asset-imports.service.ts');
assert.match(service, /status === 'CONFLICT' \|\| status === 'WARNING'/);
assert.ok(service.indexOf("status === 'CONFLICT' || status === 'WARNING'") < service.indexOf("candidates.size === 1"), 'source warnings must be reviewed before exact-match auto decisions');
assert.match(service, /inventoryIdCounts/);
assert.match(service, /:row-\$\{row\.rowNumber\}/);
assert.match(service, /SOURCE_ROW_\$\{row\.status\}/);
assert.match(service, /DUPLICATE_INVENTORY_ID_IN_BATCH/);
assert.match(service, /ASSET_IMPORT_DUPLICATE_ID_UNRESOLVED/);
});
test('D5.3.7 applies a stale-safe plan transactionally and never deletes Maestro assets', () => {
const service = source('src/asset-imports/asset-imports.service.ts');
const apply = service.slice(service.indexOf('async applyPlan'), service.indexOf('async rollbackPlan'));
const rollback = service.slice(service.indexOf('async rollbackPlan'), service.indexOf('async upload'));
assert.match(apply, /this\.dataSource\.transaction/);
assert.match(apply, /plan\.planHash !== dto\.planHash/);
assert.match(apply, /ASSET_IMPORT_PLAN_STALE/);
assert.match(rollback, /information_status='INACTIVE'/);
assert.match(rollback, /imported_asset_id=NULL/);
assert.doesNotMatch(`${apply}\n${rollback}`, /DELETE FROM assets/);
});
test('D5.3.7 validates physical parent rules and complete operational context before INSERT', () => {
const service = source('src/asset-imports/asset-imports.service.ts');
assert.match(service, /asset_type_parent_rules/);
assert.match(service, /ASSET_IMPORT_PARENT_RULE_INVALID/);
assert.match(service, /ASSET_IMPORT_OPERATIONAL_CONTEXT_INCOMPLETE/);
assert.match(service, /const areaKey=String\(item\.payload\.operationalAreaEntityKey/);
});
test('D5.3.7 structural reviews cannot be silently ignored and manual CREATE gets a deterministic code', () => {
const service = source('src/asset-imports/asset-imports.service.ts');
assert.match(service, /item\.entityKind !== 'TECHNICAL_ASSET'/);
assert.match(service, /ASSET_IMPORT_STRUCTURAL_ITEM_REQUIRED/);
assert.match(service, /generatedImportCode\(plan\.id, item\.assetTypeCode, item\.entityKey\)/);
assert.match(service, /entityKind:'LOCAL_STRUCTURE'/);
assert.match(service, /classificationStatus:'SOURCE_PRESERVED'/);
});
test('D5.3.7 preserves UTE identity without inferring memberships from a display name', () => {
const service = source('src/asset-imports/asset-imports.service.ts');
assert.match(service, /organizationKind:\/\^UTE\\b\/i\.test/);
assert.doesNotMatch(service.slice(service.indexOf('buildTerritoryPlan'), service.indexOf('buildInventoryPlan')), /INSERT INTO organization_memberships/);
});
test('D5.3.7 keeps the explicit no-operator sentinel as absence, never as a fake company', () => {
const service = source('src/asset-imports/asset-imports.service.ts');
assert.match(service, /const operatorUnassigned = isExplicitlyUnassignedOperator\(operator\)/);
assert.match(service, /operatorUnassigned \? '' : organizationImportKey\(operator\)/);
assert.match(service, /operatorAssignmentStatus:explicitlyUnassigned\?'UNASSIGNED_SOURCE':'ASSIGNED'/);
assert.match(service, /operationalAreaEntityKey:explicitlyUnassigned\?null:areaItem\.entityKey/);
});
test('D5.3.7 operational context mismatches block silent MATCH and are revalidated at apply time', () => {
const service = source('src/asset-imports/asset-imports.service.ts');
assert.match(service, /PLAN_FIELD_OPERATOR_CONTEXT_MISMATCH/);
assert.match(service, /PLAN_MATCH_OPERATIONAL_CONTEXT_MISMATCH/);
assert.match(service, /ASSET_IMPORT_MATCH_CONTEXT_BLOCKED/);
assert.match(service, /revalidateMatchedOperationalContext/);
assert.match(service, /current\.operationalAreaId!==expectedArea\|\|current\.operatorCompanyId!==expectedOperator/);
});