160 lines
9.7 KiB
TypeScript
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/);
|
|
});
|