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