chore: import DH V2 D5.6.4 production baseline

This commit is contained in:
DH V2
2026-09-05 10:12:35 -03:00
commit 82213e72f5
757 changed files with 84218 additions and 0 deletions
@@ -0,0 +1,39 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
import { RolesController } from '../../src/administration/roles/roles.controller';
import { UsersController } from '../../src/administration/users/users.controller';
function permissionFor(controller: object, method: string): string[] {
const handler = controller[method as keyof typeof controller];
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
}
test('UsersController declares a permission for every administrative action', () => {
const controller = UsersController.prototype;
assert.deepEqual(permissionFor(controller, 'list'), ['users.read']);
assert.deepEqual(permissionFor(controller, 'getById'), ['users.read']);
assert.deepEqual(permissionFor(controller, 'create'), ['users.create']);
assert.deepEqual(permissionFor(controller, 'update'), ['users.update']);
assert.deepEqual(permissionFor(controller, 'changeStatus'), [
'users.change_status',
]);
assert.deepEqual(permissionFor(controller, 'replaceRoles'), [
'users.assign_roles',
]);
});
test('RolesController separates reading from matrix administration', () => {
const controller = RolesController.prototype;
assert.deepEqual(permissionFor(controller, 'list'), ['roles.read']);
assert.deepEqual(permissionFor(controller, 'listPermissions'), [
'roles.read',
]);
assert.deepEqual(permissionFor(controller, 'getById'), ['roles.read']);
assert.deepEqual(permissionFor(controller, 'create'), ['roles.manage']);
assert.deepEqual(permissionFor(controller, 'update'), ['roles.manage']);
assert.deepEqual(permissionFor(controller, 'replacePermissions'), [
'roles.manage',
]);
});
@@ -0,0 +1,49 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { validate } from 'class-validator';
import { CreateRoleDto } from '../../src/administration/roles/dto/create-role.dto';
import { CreateUserDto } from '../../src/administration/users/dto/create-user.dto';
const ROLE_ID = '9c3e6a45-8de3-4acf-a078-7994168231f7';
const PERMISSION_ID = '35f62a40-5fd0-4ee7-89fc-381c3c0eceaf';
test('CreateUserDto accepts a controlled temporary credential and role ids', async () => {
const dto = Object.assign(new CreateUserDto(), {
username: 'nuevo.inspector',
email: 'inspector@example.com',
password: 'Temporal-segura-2026',
firstName: 'Nuevo',
lastName: 'Inspector',
mustChangePassword: true,
roleIds: [ROLE_ID],
});
assert.deepEqual(await validate(dto), []);
});
test('CreateUserDto rejects weak passwords and malformed role ids', async () => {
const dto = Object.assign(new CreateUserDto(), {
username: 'nuevo inspector',
password: 'corta',
firstName: 'Nuevo',
lastName: 'Inspector',
roleIds: ['no-es-uuid'],
});
const properties = new Set((await validate(dto)).map((error) => error.property));
assert.equal(properties.has('username'), true);
assert.equal(properties.has('password'), true);
assert.equal(properties.has('roleIds'), true);
});
test('CreateRoleDto validates dynamic permission identifiers', async () => {
const dto = Object.assign(new CreateRoleDto(), {
code: 'coordinador_campo',
name: 'Coordinador de campo',
description: 'Coordina equipos operativos',
permissionIds: [PERMISSION_ID],
});
assert.deepEqual(await validate(dto), []);
});
@@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { ConflictException } from '@nestjs/common';
import type { EntityManager } from 'typeorm';
import { assertAdministrativeRecoveryRemains } from '../../src/administration/common/administration-audit';
function managerWithCount(count: number): EntityManager {
return {
query: async () => [{ count }],
} as unknown as EntityManager;
}
test('administrative integrity accepts at least one recovery administrator', async () => {
await assert.doesNotReject(
assertAdministrativeRecoveryRemains(managerWithCount(1)),
);
});
test('administrative integrity prevents removing the last recovery administrator', async () => {
await assert.rejects(
assertAdministrativeRecoveryRemains(managerWithCount(0)),
(error: unknown) =>
error instanceof ConflictException && error.getStatus() === 409,
);
});
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { validateAssetAttributeValues } from '../../src/asset-master/asset-attribute-validator';
import {
AssetAttributeDataType,
type AssetAttributeDefinition,
} from '../../src/database/entities';
function definition(
id: string,
name: string,
dataType: AssetAttributeDataType,
overrides: Partial<AssetAttributeDefinition> = {},
): AssetAttributeDefinition {
return {
id,
name,
code: name.toLowerCase(),
dataType,
assetTypeId: '16e54e65-60cf-4739-b0d1-ccdd904fbfd5',
isRequired: false,
isActive: true,
unit: null,
options: null,
sortOrder: 0,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
test('normalizes configured values without accepting arbitrary attributes', () => {
const definitions = [
definition('7450ea58-6348-4cfc-88f1-e0f3bcd38913', 'Presión', AssetAttributeDataType.NUMBER),
definition('f82088f8-6fc0-46c9-98b2-d694f80449c3', 'Crítico', AssetAttributeDataType.BOOLEAN),
];
assert.deepEqual(validateAssetAttributeValues(definitions, {
[definitions[0]!.id]: 12.5,
[definitions[1]!.id]: false,
}), [
{ definitionId: definitions[0]!.id, value: 12.5 },
{ definitionId: definitions[1]!.id, value: false },
]);
assert.throws(() => validateAssetAttributeValues(definitions, { unknown: 'value' }));
});
test('enforces required, date and selection semantics', () => {
const requiredDate = definition(
'880d0cdf-2400-4892-9a78-bcd8e7a3e270',
'Instalación',
AssetAttributeDataType.DATE,
{ isRequired: true },
);
const selector = definition(
'26430113-1116-45f0-a1d5-f96858e97b2e',
'Clase',
AssetAttributeDataType.SELECT,
{ options: ['A', 'B'] },
);
assert.throws(() => validateAssetAttributeValues([requiredDate], {}));
assert.throws(() => validateAssetAttributeValues([requiredDate], { [requiredDate.id]: '2026-02-30' }));
assert.throws(() => validateAssetAttributeValues([selector], { [selector.id]: 'C' }));
assert.deepEqual(validateAssetAttributeValues([requiredDate, selector], {
[requiredDate.id]: '2026-08-13',
[selector.id]: 'B',
}), [
{ definitionId: requiredDate.id, value: '2026-08-13' },
{ definitionId: selector.id, value: 'B' },
]);
});
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import test from 'node:test';
async function source(relativePath: string): Promise<string> {
return readFile(resolve(process.cwd(), 'src', relativePath), 'utf8');
}
test('D5.3.4 adds a bounded hierarchy query without changing physical parent semantics', async () => {
const controller = await source('asset-master/assets.controller.ts');
const service = await source('asset-master/assets.service.ts');
const dto = await source('asset-master/dto/list-asset-tree-query.dto.ts');
assert.match(controller, /@Get\('tree'\)/);
assert.match(controller, /@RequirePermissions\('assets\.read'\)/);
assert.match(service, /WITH RECURSIVE matched AS/);
assert.match(service, /JOIN visible current/);
assert.match(service, /rows\.length > 5000/);
assert.match(service, /asset_external_identifiers external_id/);
assert.match(dto, /needsValidation\?: boolean/);
assert.match(dto, /hasGeometry\?: boolean/);
});
test('D5.3.4 exposes operational dashboard counts before technical administration metrics', async () => {
const dashboard = await source('dashboard/dashboard.service.ts');
assert.match(dashboard, /"plannedInspections"/);
assert.match(dashboard, /"assetsNeedValidation"/);
assert.match(dashboard, /"assetsWithoutGeometry"/);
assert.match(dashboard, /information_status NOT IN \('VALIDATED','INACTIVE'\)/);
});
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import test from 'node:test';
async function source(relativePath: string): Promise<string> {
return readFile(resolve(process.cwd(), 'src', relativePath), 'utf8');
}
test('D5.3.14 protects the inventory dossier with all related read permissions', async () => {
const controller = await source('asset-master/assets.controller.ts');
assert.match(controller, /@Get\(':id\/dossier'\)/);
assert.match(controller, /'assets\.read'/);
assert.match(controller, /'inspections\.read'/);
assert.match(controller, /'inspection_acts\.read'/);
assert.match(controller, /'inspection_findings\.read'/);
assert.match(controller, /'inspection_evidence\.read'/);
assert.match(controller, /'inspection_communications\.read'/);
});
test('D5.3.14 assembles a chronological dossier without duplicating business data', async () => {
const service = await source('asset-master/assets.service.ts');
assert.match(service, /inspection_visit_assets/);
assert.match(service, /inspection_act_assets/);
assert.match(service, /inspection_findings/);
assert.match(service, /inspection_finding_evidence/);
assert.match(service, /inspection_finding_communications/);
assert.match(service, /asset_source_documents/);
assert.match(service, /asset_versions/);
assert.match(service, /timeline\.sort/);
assert.doesNotMatch(service, /INSERT INTO inspection_/);
});
@@ -0,0 +1,24 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
import {
AssetGeometriesController,
MapAssetsController,
} from '../../src/asset-master/asset-geometries.controller';
function permissionFor(controller: object, method: string): string[] {
const handler = controller[method as keyof typeof controller];
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
}
test('geometry reads use assets.read and writes require the specific permission', () => {
const controller = AssetGeometriesController.prototype;
assert.deepEqual(permissionFor(controller, 'get'), ['assets.read']);
assert.deepEqual(permissionFor(controller, 'upsert'), ['assets.update_geometry']);
assert.deepEqual(permissionFor(controller, 'remove'), ['assets.update_geometry']);
});
test('the operational map is protected by assets.read', () => {
assert.deepEqual(permissionFor(MapAssetsController.prototype, 'map'), ['assets.read']);
});
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
parseBoundingBox,
validateGeoJsonGeometry,
} from '../../src/asset-master/asset-geometry-validator';
import { AssetGeometryType } from '../../src/database/entities';
test('accepts points and strips unsupported altitude values', () => {
assert.deepEqual(validateGeoJsonGeometry({
type: AssetGeometryType.POINT,
coordinates: [-68.8458, -32.8895, 900],
}), {
type: AssetGeometryType.POINT,
coordinates: [-68.8458, -32.8895],
});
});
test('rejects invalid lines and out-of-range coordinates', () => {
assert.throws(() => validateGeoJsonGeometry({
type: AssetGeometryType.LINESTRING,
coordinates: [[-68, -32], [-68, -32]],
}));
assert.throws(() => validateGeoJsonGeometry({
type: AssetGeometryType.POINT,
coordinates: [-181, -32],
}));
});
test('requires closed, non-degenerate polygon rings', () => {
assert.throws(() => validateGeoJsonGeometry({
type: AssetGeometryType.POLYGON,
coordinates: [[[-68, -32], [-67, -32], [-67, -33]]],
}));
assert.deepEqual(validateGeoJsonGeometry({
type: AssetGeometryType.POLYGON,
coordinates: [[
[-68, -32], [-67, -32], [-67, -33], [-68, -32],
]],
}).type, AssetGeometryType.POLYGON);
});
test('parses only ordered WGS84 bounding boxes', () => {
assert.deepEqual(parseBoundingBox('-70,-35,-66,-31'), [-70, -35, -66, -31]);
assert.throws(() => parseBoundingBox('-66,-31,-70,-35'));
assert.throws(() => parseBoundingBox('-200,-35,-66,-31'));
});
@@ -0,0 +1,17 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { AssetHistoryController } from '../../src/asset-master/asset-history.controller';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
function permissionFor(method: string): string[] {
const controller = AssetHistoryController.prototype;
const handler = controller[method as keyof typeof controller];
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
}
test('all asset history endpoints require the dedicated permission', () => {
assert.deepEqual(permissionFor('list'), ['assets.read_history']);
assert.deepEqual(permissionFor('listForAsset'), ['assets.read_history']);
assert.deepEqual(permissionFor('getVersion'), ['assets.read_history']);
});
@@ -0,0 +1,54 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import type { DataSource, EntityManager } from 'typeorm';
import { AssetHistoryService } from '../../src/asset-master/asset-history.service';
import { AssetVersionChangeType } from '../../src/database/entities/asset-version.entity';
import type { AuthPrincipal, RequestWithContext } from '../../src/common/http/request-context';
test('asset history capture increments then reads current_version before inserting the revision', async () => {
const calls: Array<{ sql: string; parameters: unknown[] }> = [];
const manager = {
query: async (sql: string, parameters: unknown[] = []) => {
calls.push({ sql, parameters });
if (/^\s*UPDATE assets/i.test(sql)) return [];
if (/^\s*SELECT current_version/i.test(sql)) return [{ current_version: 1 }];
if (/FROM asset_versions/i.test(sql)) return [];
if (/^\s*INSERT INTO asset_versions/i.test(sql)) return [];
throw new Error(`Consulta no esperada en test: ${sql}`);
},
} as unknown as EntityManager;
const service = new AssetHistoryService({} as DataSource);
(service as unknown as { loadCurrentSnapshot: () => Promise<Record<string, unknown>> })
.loadCurrentSnapshot = async () => ({ id: 'asset-1', currentVersion: 1 });
const principal = {
userId: '00000000-0000-4000-8000-000000000001',
username: 'admin',
sessionId: 'test',
firstName: 'Admin',
lastName: 'Test',
email: null,
mustChangePassword: false,
roles: ['admin'],
permissions: [],
transport: 'cookie',
} as AuthPrincipal;
const request = { requestId: 'test-request' } as RequestWithContext;
const version = await service.capture(
manager,
'00000000-0000-4000-8000-000000000002',
AssetVersionChangeType.CREATED,
principal,
request,
);
assert.equal(version, 1);
assert.match(calls[0].sql, /COALESCE\(current_version, 0\) \+ 1/);
assert.match(calls[1].sql, /SELECT current_version/);
const insert = calls.find((call) => /INSERT INTO asset_versions/i.test(call.sql));
assert.ok(insert);
assert.equal(insert.parameters[1], 1);
});
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { ListAssetVersionsQueryDto } from '../../src/asset-master/dto/list-asset-versions-query.dto';
import { AssetVersionChangeType } from '../../src/database/entities';
test('asset history filters accept pagination, dates and known change types', async () => {
const dto = plainToInstance(ListAssetVersionsQueryDto, {
page: '2',
pageSize: '50',
search: ' ACT-001 ',
changeType: AssetVersionChangeType.GEOMETRY_UPDATED,
from: '2026-08-01T00:00:00.000Z',
});
assert.deepEqual(await validate(dto), []);
assert.equal(dto.page, 2);
assert.equal(dto.pageSize, 50);
assert.equal(dto.search, 'ACT-001');
});
test('asset history filters reject invalid pages, dates and change types', async () => {
const dto = plainToInstance(ListAssetVersionsQueryDto, {
page: '0',
pageSize: '500',
changeType: 'DELETED',
to: 'fecha-invalida',
});
const properties = new Set((await validate(dto)).map((error) => error.property));
assert.equal(properties.has('page'), true);
assert.equal(properties.has('pageSize'), true);
assert.equal(properties.has('changeType'), true);
assert.equal(properties.has('to'), true);
});
@@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { analyzeImportWorkbook, detectImportProfile } from '../../src/asset-imports/asset-import-profiles';
import type { ParsedWorkbook } from '../../src/asset-imports/asset-import-parser';
function workbook(rows: string[][], name = 'Inventario'): ParsedWorkbook {
return { kind: 'XLSX', sheets: [{ name, rows }] };
}
test('D5.3.5 detects the Mendoza inventory layout and preserves source semantics', () => {
const input = workbook([
['Item','Area/ Yacimiento','Instalacion','Sub instalacion','Equipo','Denominación del equipo','Ubicación del equipo o instalación','N° ID/ Inventario','Cantidad','Especificaciones técnicas','Estado (en servicio/ fuera de servicio)'],
['1','REFUGIO TUPUNGATO','TUP-PIAS','ZONA BOMBAS PIAS','Bomba Centrífuga','Clase: BBA; Tipo: CEN','N/A','BBA-050','1','Fabricante; GOULD; Modelo: 3196','NO'],
]);
const detection = detectImportProfile(input);
assert.equal(detection.profileCode, 'MENDOZA_INVENTORY_V1');
const analysis = analyzeImportWorkbook(input);
assert.equal(analysis.summary.totalRows, 1);
assert.equal(analysis.rows[0]?.normalized.areaOrField, 'REFUGIO TUPUNGATO');
assert.equal(analysis.rows[0]?.normalized.inventoryId, 'BBA-050');
assert.equal(analysis.rows[0]?.normalized.sourceClass, 'BBA');
assert.equal(analysis.rows[0]?.normalized.sourceSubtype, 'CEN');
assert.equal(analysis.rows[0]?.normalized.manufacturer, 'GOULD');
assert.equal(analysis.rows[0]?.normalized.model, '3196');
assert.equal(analysis.rows[0]?.normalized.sourceStatus, 'NO');
assert.ok(analysis.rows[0]?.issues.includes('SOURCE_STATUS_REQUIRES_MAPPING'));
});
test('D5.3.5 treats grouped quantity and repeated inventory IDs as review signals', () => {
const input = workbook([
['Item','Area/ Yacimiento','Instalacion','Sub instalacion','Equipo','Denominación del equipo','Ubicación del equipo o instalación','N° ID/ Inventario','Cantidad','Especificaciones técnicas','Estado'],
['1','Loma de La Mina','PTC','Playa Tanques','Bomba','','Pozo 1','MEX000062','2','','en servicio'],
['2','Loma de La Mina','PTC','Playa Tanques','Bomba','','Pozo 2','MEX000062','1','','fuera de servicio'],
]);
const analysis = analyzeImportWorkbook(input);
assert.equal(analysis.summary.totalRows, 2);
assert.ok(analysis.rows[0]?.issues.includes('GROUPED_QUANTITY'));
assert.ok(analysis.rows.every((row) => row.issues.includes('INVENTORY_ID_MULTIPLE_LOCATIONS')));
assert.equal(analysis.summary.conflictRows, 2);
});
test('D5.3.5 detects the Mendoza Yacimiento-Area reference table separately', () => {
const input = workbook([
['Yacimiento','Área','Departamento','Tipo Concesión','Operadora'],
['Cañada Dura','Vizcacheras','San Carlos','Explotación','Petróleos Sudamericanos Energy S.A.'],
], 'Tabla yacimientos');
const analysis = analyzeImportWorkbook(input);
assert.equal(analysis.detection.profileCode, 'MENDOZA_YACIMIENTOS_V1');
assert.equal(analysis.rows[0]?.normalized.field, 'Cañada Dura');
assert.equal(analysis.rows[0]?.normalized.area, 'Vizcacheras');
assert.equal(analysis.rows[0]?.normalized.rightType, 'Explotación');
assert.equal(analysis.rows[0]?.normalized.operator, 'Petróleos Sudamericanos Energy S.A.');
});
@@ -0,0 +1,20 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
import { AssetImportsController } from '../../src/asset-imports/asset-imports.controller';
function permissionFor(controller: object, method: string): string[] {
const handler = controller[method as keyof typeof controller];
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
}
test('D5.3.5 separates import analysis reading from batch administration', () => {
const controller = AssetImportsController.prototype;
assert.deepEqual(permissionFor(controller, 'list'), ['asset_imports.read']);
assert.deepEqual(permissionFor(controller, 'get'), ['asset_imports.read']);
assert.deepEqual(permissionFor(controller, 'rows'), ['asset_imports.read']);
assert.deepEqual(permissionFor(controller, 'upload'), ['asset_imports.manage']);
assert.deepEqual(permissionFor(controller, 'reconcile'), ['asset_imports.manage']);
assert.deepEqual(permissionFor(controller, 'cancel'), ['asset_imports.manage']);
});
@@ -0,0 +1,71 @@
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 { safePlanItems, summarizePlanItems, 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 item(overrides: Partial<AssetImportPlanDraftItem> = {}): AssetImportPlanDraftItem {
return {
entityKey: 'technical:safe', entityKind: 'TECHNICAL_ASSET', action: 'CREATE', status: 'PLANNED', assetTypeCode: 'tanque',
displayName: 'TK-1', generatedCode: 'IMP-TANQUE-1', parentEntityKey: null, matchedAssetId: null, payload: {}, sourceRowNumbers: [2], reviewCodes: [],
...overrides,
};
}
test('D5.3.11 exposes the review inbox and privileged safe application', () => {
assert.deepEqual(permissionFor('reviews'), ['asset_imports.read']);
assert.deepEqual(permissionFor('applySafePlan'), ['asset_imports.apply']);
});
test('D5.3.11 imports only branches independent from pending reviews', () => {
const decision = item({ entityKey: 'field:ambiguous', entityKind: 'FIELD', action: 'REVIEW', status: 'REVIEW', assetTypeCode: 'yacimiento', reviewCodes: ['PLAN_TERRITORY_AMBIGUOUS'] });
const blockedParent = item({ entityKey: 'structure:blocked', entityKind: 'LOCAL_STRUCTURE', assetTypeCode: 'estructura_local', parentEntityKey: decision.entityKey });
const blockedChild = item({ entityKey: 'technical:blocked', parentEntityKey: blockedParent.entityKey });
const safeField = item({ entityKey: 'field:safe', entityKind: 'FIELD', assetTypeCode: 'yacimiento' });
const safeChild = item({ entityKey: 'technical:safe', parentEntityKey: safeField.entityKey });
const planItems = [decision, blockedParent, blockedChild, safeField, safeChild];
const safe = safePlanItems(planItems);
assert.deepEqual(safe.map((candidate) => candidate.entityKey), ['field:safe', 'technical:safe']);
const summary = summarizePlanItems(planItems);
assert.equal(summary.safeCreateItems, 2);
assert.equal(summary.blockedCreateItems, 2);
});
test('D5.3.11 treats already-applied parents as valid support for later safe branches', () => {
const parent = item({ entityKey: 'field:applied', entityKind: 'FIELD', assetTypeCode: 'yacimiento', status: 'APPLIED' });
const child = item({ entityKey: 'technical:next', parentEntityKey: parent.entityKey });
assert.deepEqual(safePlanItems([parent, child]).map((candidate) => candidate.entityKey), ['field:applied', 'technical:next']);
const summary = summarizePlanItems([parent, child]);
assert.equal(summary.appliedCreateItems, 1);
assert.equal(summary.pendingCreateItems, 1);
assert.equal(summary.safeCreateItems, 1);
assert.equal(summary.blockedCreateItems, 0);
});
test('D5.3.11 keeps partial application transactional and rollback cumulative across plan revisions', () => {
const service = source('src/asset-imports/asset-imports.service.ts');
const partial = service.slice(service.indexOf('async applySafePlan'), service.indexOf('async applyPlan'));
const rollback = service.slice(service.indexOf('async rollbackPlan'), service.indexOf('async upload'));
assert.match(partial, /this\.dataSource\.transaction/);
assert.match(partial, /safePlanItems\(allItems\)/);
assert.match(partial, /status: 'PARTIAL'/);
assert.match(partial, /masterStateHash: nextMasterStateHash/);
assert.match(rollback, /JOIN asset_import_plans history ON history\.id=item\.plan_id/);
assert.match(rollback, /history\.batch_id=\$1 AND item\.action='CREATE' AND item\.status='APPLIED'/);
assert.match(rollback, /UPDATE asset_import_rows SET imported_asset_id=NULL/);
});
test('D5.3.11 prevents normal reconciliation from bypassing an existing partial import', () => {
const service = source('src/asset-imports/asset-imports.service.ts');
assert.match(service, /ASSET_IMPORT_PARTIAL_RECONCILE_BLOCKED/);
assert.match(service, /batchHasAppliedItems\(manager, id\)/);
assert.match(service, /item\.status !== 'APPLIED'/);
});
@@ -0,0 +1,49 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import test from 'node:test';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
import { AssetImportsController } from '../../src/asset-imports/asset-imports.controller';
import { analyzeImportWorkbook } from '../../src/asset-imports/asset-import-profiles';
import type { ParsedWorkbook } from '../../src/asset-imports/asset-import-parser';
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 workbook(rows: string[][]): ParsedWorkbook { return { kind: 'XLSX', sheets: [{ name: 'Inventario', rows }] }; }
test('D5.3.6 protects reconciliation as an import-management operation', () => {
assert.deepEqual(permissionFor('reconcile'), ['asset_imports.manage']);
});
test('D5.3.6 normalizes technical family and subtype while preserving source classification', () => {
const analysis = analyzeImportWorkbook(workbook([
['Item','Area/ Yacimiento','Instalacion','Sub instalacion','Equipo','Denominación del equipo','Ubicación del equipo o instalación','N° ID/ Inventario','Cantidad','Especificaciones técnicas','Estado'],
['1','REFUGIO TUPUNGATO','TUP-PIAS','ZONA BOMBAS','Bomba Centrífuga','Clase: BBA; Tipo: CEN','N/A','BBA-050','1','Fabricante; GOULD; Modelo: 3196','NO'],
]));
assert.equal(analysis.rows[0]?.normalized.sourceClass, 'BBA');
assert.equal(analysis.rows[0]?.normalized.sourceSubtype, 'CEN');
assert.equal(analysis.rows[0]?.normalized.normalizedFamily, 'bomba');
assert.equal(analysis.rows[0]?.normalized.normalizedSubtype, 'centrifuga');
assert.equal(analysis.rows[0]?.normalized.sourceStatus, 'NO');
});
test('D5.3.6 reconciliation is conservative and does not write Maestro assets', () => {
const service = source('src/asset-imports/asset-imports.service.ts');
const reconcile = service.slice(service.indexOf('async reconcile'), service.indexOf('async cancel'));
assert.match(reconcile, /INVENTORY_ID_OR_CODE_EXACT/);
assert.match(reconcile, /AREA_FIELD_EXACT/);
assert.match(reconcile, /MATCH_MULTIPLE_ASSETS/);
assert.match(reconcile, /masterModified: false/);
assert.doesNotMatch(reconcile, /INSERT INTO assets\s*\(/);
assert.doesNotMatch(reconcile, /UPDATE assets\s+SET/);
});
test('D5.3.6.3 audit summary is exposed as a plain record', () => {
const source = readFileSync(join(process.cwd(), 'src/asset-imports/asset-imports.service.ts'), 'utf8');
assert.match(source, /afterData:\s*\{\s*\.\.\.summary\s*\}/);
});
@@ -0,0 +1,133 @@
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 { AssetImportsController } from '../../src/asset-imports/asset-imports.controller';
import { departmentCode, normalizedLegalRightType, organizationImportKey } from '../../src/asset-imports/asset-import-plan';
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[];
}
test('D5.3.7.2 normalizes Departments and legal-right source values', () => {
assert.equal(departmentCode('Luján de Cuyo'), 'LUJAN-DE-CUYO');
assert.equal(departmentCode('San Rafael'), 'SAN-RAFAEL');
assert.equal(normalizedLegalRightType('Explotación'), 'EXPLOITATION_CONCESSION');
assert.equal(normalizedLegalRightType('Exploración'), 'EXPLORATION_PERMIT');
assert.equal(normalizedLegalRightType('Concesión de Transporte'), 'TRANSPORT_CONCESSION');
});
test('D5.3.7.2 exposes paginated plan details as read-only import information', () => {
assert.deepEqual(permissionFor('planItems'), ['asset_imports.read']);
const controller = source('src/asset-imports/asset-imports.controller.ts');
assert.match(controller, /@Get\(':id\/plan\/items'\)/);
const service = source('src/asset-imports/asset-imports.service.ts');
assert.match(service, /async planItems\(/);
assert.match(service, /entity_kind=\$\$\{params\.length\}/);
});
test('D5.3.7.2 migration creates a normalized Department catalog and Area relation', () => {
const migration = source('src/database/migrations/1787767200000-phase-d5-3-7-2-relational-territory-plan.ts');
assert.match(migration, /CREATE TABLE administrative_departments/);
assert.match(migration, /CREATE TABLE area_department_relations/);
assert.match(migration, /uq_administrative_department_name/);
assert.match(migration, /uq_area_department_relation_active_pair/);
assert.match(migration, /'DEPARTMENT'.*'AREA_DEPARTMENT_RELATION'.*'LEGAL_RIGHT'.*'LEGAL_RIGHT_ORGANIZATION'/s);
assert.match(migration, /REVOKE DELETE ON TABLE administrative_departments, area_department_relations/);
});
test('D5.3.7.2 territory planning deduplicates catalogs before relations and fields', () => {
const service = source('src/asset-imports/asset-imports.service.ts');
const territory = service.slice(service.indexOf('private async buildTerritoryPlan'), service.indexOf('private async buildInventoryPlan'));
assert.match(territory, /const departments = new Map/);
assert.match(territory, /entityKind: 'DEPARTMENT'|entityKind:'DEPARTMENT'/);
assert.match(territory, /entityKind:'AREA_DEPARTMENT_RELATION'|entityKind: 'AREA_DEPARTMENT_RELATION'/);
assert.match(territory, /entityKind:'LEGAL_RIGHT'|entityKind: 'LEGAL_RIGHT'/);
assert.match(territory, /entityKind:'LEGAL_RIGHT_ORGANIZATION'|entityKind: 'LEGAL_RIGHT_ORGANIZATION'/);
assert.match(territory, /matchedDepartmentId/);
assert.match(territory, /matchedLegalRightId/);
assert.match(territory, /departmentEntityKeys/);
assert.match(territory, /legalRightEntityKeys/);
});
test('D5.3.7.2 applies relational objects transactionally without duplicating Department attributes', () => {
const service = source('src/asset-imports/asset-imports.service.ts');
const apply = service.slice(service.indexOf('private async applyPlanItems'), service.indexOf('private async insertAssetItems'));
assert.match(apply, /INSERT INTO administrative_departments/);
assert.match(apply, /INSERT INTO area_department_relations/);
assert.match(apply, /INSERT INTO area_legal_rights/);
assert.match(apply, /INSERT INTO area_legal_right_organizations/);
assert.match(apply, /'PENDING'/);
assert.match(apply, /no se infiere titularidad/i);
const attributes = service.slice(service.indexOf('private async insertKnownAttributes'), service.indexOf('private async insertImportVersions'));
assert.doesNotMatch(attributes, /add\('departamento'/);
assert.match(service, /return `\(\$\$\{base\+1\}::integer,\$\$\{base\+2\}::uuid\)`/);
assert.match(service, /row\.row_number=v\.row_number/);
});
test('D5.3.7.2 rollback is logical for relational territory objects', () => {
const service = source('src/asset-imports/asset-imports.service.ts');
const rollback = service.slice(service.indexOf('async rollbackPlan'), service.indexOf('async upload'));
assert.match(rollback, /area_department_relations SET valid_until=CURRENT_DATE/);
assert.match(rollback, /area_legal_rights SET status='REVOKED'/);
assert.match(rollback, /administrative_departments SET is_active=false/);
assert.doesNotMatch(rollback, /DELETE FROM administrative_departments|DELETE FROM area_department_relations|DELETE FROM area_legal_rights/);
});
test('D5.3.7.2.3 stores an applied asset id without PostgreSQL parameter type ambiguity', () => {
const service = source('src/asset-imports/asset-imports.service.ts');
assert.match(service, /applied_asset_id=\$2::uuid,applied_object_id=\(\$2::uuid\)::text/);
assert.doesNotMatch(service, /applied_asset_id=\$2,applied_object_id=\$2/);
});
test('D5.3.7.2.4 snapshots the Maestro and blocks stale READY plans before any write', () => {
const service = source('src/asset-imports/asset-imports.service.ts');
const generate = service.slice(service.indexOf('async generatePlan'), service.indexOf('async resolvePlanItem'));
const apply = service.slice(service.indexOf('async applyPlan'), service.indexOf('async rollbackPlan'));
assert.match(service, /private async masterStateFingerprint/);
assert.match(generate, /masterStateBefore = await this\.masterStateFingerprint/);
assert.match(generate, /masterStateHash = await this\.masterStateFingerprint/);
assert.match(generate, /ASSET_IMPORT_MASTER_CHANGED_DURING_PLAN/);
assert.match(generate, /masterStateHash,/);
assert.match(apply, /plannedMasterStateHash/);
assert.match(apply, /currentMasterStateHash/);
assert.match(apply, /El Maestro cambió desde que se generó este plan/);
});
test('D5.3.8 preserves operator-local hierarchy instead of inventing universal installation names', () => {
const service = source('src/asset-imports/asset-imports.service.ts');
const planner = service.slice(service.indexOf('private async buildInventoryPlan'), service.indexOf('private async assetNameMatches'));
assert.match(planner, /sourceLocalStructureSuggestion/);
assert.match(planner, /entityKind:'LOCAL_STRUCTURE'/);
assert.match(planner, /assetTypeCode:'estructura_local'/);
assert.match(planner, /localNomenclature:true/);
assert.match(planner, /classificationStatus:'SOURCE_PRESERVED'/);
assert.doesNotMatch(planner, /\[A VALIDAR\]/);
});
test('D5.3.8 collapses territory ambiguity into one explicit context decision', () => {
const service = source('src/asset-imports/asset-imports.service.ts');
assert.match(service, /contextDecisionItems=new Map/);
assert.match(service, /inventoryContextDecision:true/);
assert.match(service, /candidateOptions/);
assert.match(service, /PLAN_CONTEXT_DECISION_REQUIRED/);
assert.match(service, /inventoryContextOverrides/);
assert.match(service, /ASSET_IMPORT_CONTEXT_MATCH_INVALID/);
});
test('D5.3.8 migration adds the provisional local structure type and hierarchy rules', () => {
const migration = source('src/database/migrations/1787853600000-phase-d5-3-8-local-inventory-structure.ts');
assert.match(migration, /'LOCAL_STRUCTURE'/);
assert.match(migration, /'estructura_local'/);
assert.match(migration, /Estructura local \(fuente\)/);
assert.match(migration, /nivel_fuente/);
assert.match(migration, /clasificacion_fuente/);
assert.match(migration, /ruta_fuente/);
assert.match(migration, /asset_type_parent_rules/);
});
@@ -0,0 +1,159 @@
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/);
});
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function source(relative: string): string {
return readFileSync(resolve(process.cwd(), relative), 'utf8');
}
test('D5.3.5 migration creates append-preserving import analysis tables and search indexes', () => {
const migration = source('src/database/migrations/1787594400000-phase-d5-3-5-import-center-scalability.ts');
assert.match(migration, /CREATE EXTENSION IF NOT EXISTS pg_trgm/);
assert.match(migration, /CREATE TABLE asset_import_batches/);
assert.match(migration, /CREATE TABLE asset_import_rows/);
assert.match(migration, /asset_imports\.read/);
assert.match(migration, /asset_imports\.manage/);
assert.match(migration, /GRANT SELECT, INSERT, UPDATE ON TABLE asset_import_batches, asset_import_rows/);
assert.match(migration, /REVOKE DELETE ON TABLE asset_import_batches, asset_import_rows/);
assert.match(migration, /idx_assets_name_trgm/);
assert.match(migration, /idx_asset_external_identifiers_value_trgm/);
});
test('D5.3.5 import analysis itself does not create Maestro assets', () => {
const service = source('src/asset-imports/asset-imports.service.ts');
const upload = service.slice(service.indexOf('async upload'), service.indexOf('async reconcile'));
assert.match(upload, /INSERT INTO asset_import_batches/);
assert.match(upload, /INSERT INTO source_documents/);
assert.doesNotMatch(upload, /INSERT INTO assets\s*\(/);
assert.doesNotMatch(upload, /UPDATE assets\s+SET/);
assert.match(service, /INSERT INTO asset_import_rows/);
assert.match(service, /FOR UPDATE OF batch/);
});
test('D5.3.5 hierarchy loads children on demand instead of returning the full tree', () => {
const controller = source('src/asset-master/assets.controller.ts');
const service = source('src/asset-master/assets.service.ts');
assert.match(controller, /@Get\('tree-children'\)/);
assert.match(service, /async treeChildren/);
assert.match(service, /query\.limit \+ 1/);
});
@@ -0,0 +1,36 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
import { AssetTypesController } from '../../src/asset-master/asset-types.controller';
import { AssetsController } from '../../src/asset-master/assets.controller';
function permissionFor(controller: object, method: string): string[] {
const handler = controller[method as keyof typeof controller];
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
}
test('AssetsController separates reading, editing and status permissions', () => {
const controller = AssetsController.prototype;
assert.deepEqual(permissionFor(controller, 'list'), ['assets.read']);
assert.deepEqual(permissionFor(controller, 'tree'), ['assets.read']);
assert.deepEqual(permissionFor(controller, 'treeChildren'), ['assets.read']);
assert.deepEqual(permissionFor(controller, 'parentOptions'), ['assets.read']);
assert.deepEqual(permissionFor(controller, 'getById'), ['assets.read']);
assert.deepEqual(permissionFor(controller, 'create'), ['assets.create']);
assert.deepEqual(permissionFor(controller, 'update'), ['assets.update']);
assert.deepEqual(permissionFor(controller, 'changeStatus'), ['assets.change_status']);
assert.deepEqual(permissionFor(controller, 'changeOperationalStatus'), ['assets.change_operational_status']);
});
test('AssetTypesController reserves configuration for asset_types.manage', () => {
const controller = AssetTypesController.prototype;
assert.deepEqual(permissionFor(controller, 'list'), ['asset_types.read']);
assert.deepEqual(permissionFor(controller, 'bootstrapStatus'), ['asset_types.read']);
assert.deepEqual(permissionFor(controller, 'bootstrapDefaults'), ['asset_types.manage']);
assert.deepEqual(permissionFor(controller, 'getById'), ['asset_types.read']);
assert.deepEqual(permissionFor(controller, 'create'), ['asset_types.manage']);
assert.deepEqual(permissionFor(controller, 'update'), ['asset_types.manage']);
assert.deepEqual(permissionFor(controller, 'createAttribute'), ['asset_types.manage']);
assert.deepEqual(permissionFor(controller, 'updateAttribute'), ['asset_types.manage']);
});
@@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
MASTER_BOOTSTRAP_CORE_CODES,
MASTER_BOOTSTRAP_PRESET_CODE,
MASTER_BOOTSTRAP_TYPES,
} from '../../src/asset-master/asset-master-bootstrap';
import { AssetAttributeDataType, AssetTypeOperationalRole } from '../../src/database/entities';
test('D5.3.2 preset preserves the D5.3.1 anchors and operational roles', () => {
assert.equal(MASTER_BOOTSTRAP_PRESET_CODE, 'mendoza-hidrocarburos-v2');
assert.deepEqual(MASTER_BOOTSTRAP_CORE_CODES, [
'area', 'empresa', 'yacimiento', 'instalacion', 'estacion', 'subestacion', 'pozo', 'equipo', 'ducto',
]);
const byCode = new Map(MASTER_BOOTSTRAP_TYPES.map((type) => [type.code, type]));
assert.equal(byCode.get('area')?.operationalRole, AssetTypeOperationalRole.AREA);
assert.equal(byCode.get('empresa')?.operationalRole, AssetTypeOperationalRole.COMPANY);
assert.equal(byCode.get('equipo')?.operationalRole, AssetTypeOperationalRole.GENERIC);
for (const code of MASTER_BOOTSTRAP_CORE_CODES) assert.ok(byCode.has(code), `missing core type ${code}`);
});
test('D5.3.2 preset models technical inspection families instead of only generic Equipo', () => {
const codes = new Set(MASTER_BOOTSTRAP_TYPES.map((type) => type.code));
for (const code of [
'locacion', 'planta', 'bateria', 'zona_bombas', 'sistema_drenaje',
'sistema_electrico_iluminacion', 'sistema_defensa_incendios',
'cargadero_descargadero', 'pileta_api', 'tanque', 'separador', 'bomba',
'caldera', 'antorcha', 'colector', 'filtro', 'equipo_flotacion',
'fwko', 'tratador', 'calentador',
]) {
assert.ok(codes.has(code), `missing technical type ${code}`);
}
assert.ok(codes.has('equipo'), 'generic equipment fallback must remain available');
});
test('D5.3.2 keeps one Pozo type and selects its operational family by attribute', () => {
const wells = MASTER_BOOTSTRAP_TYPES.filter((type) => type.code === 'pozo');
assert.equal(wells.length, 1);
const method = wells[0].attributes.find((attribute) => attribute.code === 'metodo_extraccion');
assert.equal(method?.dataType, AssetAttributeDataType.SELECT);
assert.deepEqual(method?.options, [
'Bombeo mecánico',
'Bombeo electrosumergible',
'Bombeo de cavidad progresiva (PCP)',
'Surgente / productor de gas',
'Inyector de agua',
'Otro / a validar',
]);
});
test('D5.3.2 preset adds technical attributes without embedding real operators or areas', () => {
const byCode = new Map(MASTER_BOOTSTRAP_TYPES.map((type) => [type.code, type]));
assert.ok(byCode.get('empresa')?.attributes.some((attribute) => attribute.code === 'cuit'));
assert.ok(byCode.get('tanque')?.attributes.some((attribute) => attribute.code === 'capacidad_nominal'));
assert.ok(byCode.get('equipo')?.attributes.some((attribute) => attribute.code === 'numero_serie'));
assert.equal(MASTER_BOOTSTRAP_TYPES.every((type) => !('assets' in type)), true);
});
+35
View File
@@ -0,0 +1,35 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { validate } from 'class-validator';
import { CreateAssetDto } from '../../src/asset-master/dto/create-asset.dto';
import { CreateAssetTypeDto } from '../../src/asset-master/dto/create-asset-type.dto';
const TYPE_ID = '16e54e65-60cf-4739-b0d1-ccdd904fbfd5';
test('CreateAssetTypeDto accepts a root type without fixed hierarchy levels', async () => {
const dto = Object.assign(new CreateAssetTypeDto(), {
code: 'empresa_operadora',
name: 'Empresa operadora',
description: 'Entidad administrable raíz',
canBeRoot: true,
allowedParentTypeIds: [],
});
assert.deepEqual(await validate(dto), []);
});
test('CreateAssetDto validates identity, type and the attributes object', async () => {
const dto = Object.assign(new CreateAssetDto(), {
code: 'ACT-001',
name: 'Activo de prueba',
typeId: TYPE_ID,
parentId: null,
description: null,
attributes: {},
});
assert.deepEqual(await validate(dto), []);
dto.code = 'código con espacios';
const properties = new Set((await validate(dto)).map((error) => error.property));
assert.equal(properties.has('code'), true);
});
@@ -0,0 +1,22 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { AssetMediaController } from '../../src/asset-master/asset-media.controller';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
function permissionFor(method: string): string[] {
const controller = AssetMediaController.prototype;
const handler = controller[method as keyof typeof controller];
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
}
test('media reads and content use assets.read_media', () => {
assert.deepEqual(permissionFor('list'), ['assets.read_media']);
assert.deepEqual(permissionFor('content'), ['assets.read_media']);
});
test('media mutations use assets.manage_media', () => {
assert.deepEqual(permissionFor('upload'), ['assets.manage_media']);
assert.deepEqual(permissionFor('update'), ['assets.manage_media']);
assert.deepEqual(permissionFor('remove'), ['assets.manage_media']);
});
+34
View File
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { CreateAssetMediaDto } from '../../src/asset-master/dto/create-asset-media.dto';
import { AssetMediaKind } from '../../src/database/entities';
test('multipart media metadata transforms valid numeric coordinates', async () => {
const dto = plainToInstance(CreateAssetMediaDto, {
kind: AssetMediaKind.PHOTO,
title: 'Cabezal del pozo',
capturedAt: '2026-08-14T12:00:00.000Z',
latitude: '-32.889500',
longitude: '-68.845800',
accuracyM: '3.250',
});
assert.deepEqual(await validate(dto), []);
assert.equal(dto.latitude, -32.8895);
assert.equal(dto.longitude, -68.8458);
assert.equal(dto.accuracyM, 3.25);
});
test('media metadata rejects unknown kinds and coordinate ranges', async () => {
const dto = plainToInstance(CreateAssetMediaDto, {
kind: 'VIDEO',
latitude: '91',
longitude: '-181',
});
const properties = new Set((await validate(dto)).map((error) => error.property));
assert.equal(properties.has('kind'), true);
assert.equal(properties.has('latitude'), true);
assert.equal(properties.has('longitude'), true);
});
+40
View File
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
inspectAssetFile,
MAX_ASSET_MEDIA_BYTES,
} from '../../src/asset-master/asset-media-file';
import { AssetMediaKind } from '../../src/database/entities';
function file(buffer: Buffer, originalname: string) {
return { buffer, originalname, size: buffer.length };
}
test('file inspection trusts magic bytes instead of the declared extension', () => {
const result = inspectAssetFile(
file(Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00]), 'registro.pdf'),
AssetMediaKind.PHOTO,
);
assert.equal(result.mimeType, 'image/jpeg');
assert.equal(result.extension, '.jpg');
});
test('file inspection rejects kind mismatches and executable text', () => {
assert.throws(() => inspectAssetFile(
file(Buffer.from('%PDF-1.7'), 'documento.pdf'),
AssetMediaKind.PHOTO,
));
assert.throws(() => inspectAssetFile(
file(Buffer.from('<html><script>alert(1)</script></html>'), 'foto.jpg'),
AssetMediaKind.PHOTO,
));
});
test('file inspection enforces the 15 MB boundary', () => {
const oversized = {
buffer: Buffer.from([0xff, 0xd8, 0xff]),
originalname: 'foto.jpg',
size: MAX_ASSET_MEDIA_BYTES + 1,
};
assert.throws(() => inspectAssetFile(oversized, AssetMediaKind.PHOTO));
});
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import test from 'node:test';
async function source(relativePath: string): Promise<string> {
return readFile(resolve(process.cwd(), 'src', relativePath), 'utf8');
}
test('D5.3.10.2 exposes a read-only lineage endpoint for Maestro breadcrumbs', async () => {
const controller = await source('asset-master/assets.controller.ts');
const service = await source('asset-master/assets.service.ts');
assert.match(controller, /@Get\(':id\/lineage'\)/);
assert.match(controller, /@RequirePermissions\('assets\.read'\)/);
assert.match(service, /async lineage\(id: string\)/);
assert.match(service, /WITH RECURSIVE lineage AS/);
assert.match(service, /ORDER BY lineage\.depth DESC/);
});
test('production API source keeps external-review hygiene', async () => {
const files = [
'main.ts',
'asset-imports/asset-import-plan.ts',
'inspection-operations/mobile-inspector-policy.ts',
'asset-master/asset-master-bootstrap.ts',
];
const content = (await Promise.all(files.map(source))).join('\n');
assert.doesNotMatch(content, /^\s*(?:\/\/|\/\*|\*)/m);
});
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import test from 'node:test';
async function source(relativePath: string): Promise<string> {
return readFile(resolve(process.cwd(), 'src', relativePath), 'utf8');
}
test('D5.3.11.1 exposes imported operational assignments in Maestro navigation without creating legal relations', async () => {
const service = await source('asset-master/asset-operational-relations.service.ts');
assert.match(service, /SELECT asset\.operator_company_id AS company_id/);
assert.match(service, /WHERE asset\.operational_area_id = \$1/);
assert.match(service, /SELECT asset\.operational_area_id AS area_id/);
assert.match(service, /WHERE asset\.operator_company_id = \$1/);
assert.match(service, /UNION/);
assert.doesNotMatch(service, /INSERT INTO area_company_relations[\s\S]*listAreasForCompany/);
});
@@ -0,0 +1,21 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { AssetOperationalRelationsController } from '../../src/asset-master/asset-operational-relations.controller';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
function permissionFor(method: string): string[] {
const controller = AssetOperationalRelationsController.prototype;
const handler = controller[method as keyof typeof controller];
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
}
test('D5.3 separates relation reading from relation management', () => {
assert.deepEqual(permissionFor('areas'), ['asset_relations.read']);
assert.deepEqual(permissionFor('companies'), ['asset_relations.read']);
assert.deepEqual(permissionFor('companiesForArea'), ['asset_relations.read']);
assert.deepEqual(permissionFor('areasForCompany'), ['asset_relations.read']);
assert.deepEqual(permissionFor('list'), ['asset_relations.read']);
assert.deepEqual(permissionFor('create'), ['asset_relations.manage']);
assert.deepEqual(permissionFor('end'), ['asset_relations.manage']);
});
@@ -0,0 +1,77 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { CreateAreaCompanyRelationDto } from '../../src/asset-master/dto/create-area-company-relation.dto';
import { EndAreaCompanyRelationDto } from '../../src/asset-master/dto/end-area-company-relation.dto';
import { ListAreaCompanyRelationsQueryDto } from '../../src/asset-master/dto/list-area-company-relations-query.dto';
import { ListOperationalAreasQueryDto } from '../../src/asset-master/dto/list-operational-areas-query.dto';
import { CreateAssetTypeDto } from '../../src/asset-master/dto/create-asset-type.dto';
import { AssetTypeOperationalRole } from '../../src/database/entities';
const AREA_ID = '11111111-1111-4111-8111-111111111111';
const COMPANY_ID = '22222222-2222-4222-8222-222222222222';
test('D5.3 relation DTO trims reasons and accepts UUID v4 anchors', async () => {
const dto = plainToInstance(CreateAreaCompanyRelationDto, {
areaId: AREA_ID,
companyId: COMPANY_ID,
reason: ' Contrato de explotación vigente ',
});
assert.deepEqual(await validate(dto), []);
assert.equal(dto.reason, 'Contrato de explotación vigente');
const end = plainToInstance(EndAreaCompanyRelationDto, { reason: ' Fin de contrato ' });
assert.deepEqual(await validate(end), []);
assert.equal(end.reason, 'Fin de contrato');
});
test('D5.3 relation DTO rejects incomplete identifiers and short reasons', async () => {
const dto = plainToInstance(CreateAreaCompanyRelationDto, {
areaId: 'area',
companyId: COMPANY_ID,
reason: 'x',
});
const properties = new Set((await validate(dto)).map((error) => error.property));
assert.equal(properties.has('areaId'), true);
assert.equal(properties.has('reason'), true);
});
test('D5.3 history filter accepts explicit booleans', async () => {
const query = plainToInstance(ListAreaCompanyRelationsQueryDto, {
areaId: AREA_ID,
includeHistory: 'true',
});
assert.deepEqual(await validate(query), []);
assert.equal(query.includeHistory, true);
});
test('D5.3 operational area filter validates the physical parent', async () => {
const valid = plainToInstance(ListOperationalAreasQueryDto, { parentId: AREA_ID });
assert.deepEqual(await validate(valid), []);
const invalid = plainToInstance(ListOperationalAreasQueryDto, { parentId: 'parent' });
assert.equal((await validate(invalid)).some((error) => error.property === 'parentId'), true);
});
test('D5.3 asset type operational role is constrained to the enum', async () => {
const valid = plainToInstance(CreateAssetTypeDto, {
code: 'area',
name: 'Área',
description: '',
canBeRoot: true,
operationalRole: AssetTypeOperationalRole.AREA,
allowedParentTypeIds: [],
});
assert.deepEqual(await validate(valid), []);
const invalid = plainToInstance(CreateAssetTypeDto, {
code: 'company',
name: 'Empresa',
description: '',
canBeRoot: true,
operationalRole: 'MULTI',
allowedParentTypeIds: [],
});
assert.equal((await validate(invalid)).some((error) => error.property === 'operationalRole'), true);
});
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import test from 'node:test';
async function source(relativePath: string): Promise<string> {
return readFile(resolve(process.cwd(), 'src', relativePath), 'utf8');
}
test('D5.3 migration preserves physical hierarchy and adds operational context', async () => {
const migration = await source(
'database/migrations/1787421600000-phase-d5-3-operational-context.ts',
);
assert.match(migration, /CREATE TYPE asset_type_operational_role AS ENUM \('GENERIC', 'AREA', 'COMPANY'\)/);
assert.match(migration, /CREATE TABLE area_company_relations/);
assert.match(migration, /CREATE UNIQUE INDEX uq_area_company_relations_active_pair/);
assert.match(migration, /ADD COLUMN operational_area_id uuid/);
assert.match(migration, /ADD COLUMN operator_company_id uuid/);
assert.match(migration, /chk_assets_operational_assignment_pair/);
assert.match(migration, /operational area must be an ancestor in the physical hierarchy/);
assert.match(migration, /physical hierarchy change would invalidate descendant operational assignments/);
assert.match(migration, /area cannot be inactivated while operational relations or assignments are active/);
assert.match(migration, /FOR KEY SHARE/);
assert.match(migration, /REVOKE DELETE ON TABLE area_company_relations/);
});
test('D5.3 API enforces active relation, exclusive pair and physical ancestry', async () => {
const assets = await source('asset-master/assets.service.ts');
assert.match(assets, /ASSET_OPERATIONAL_CONTEXT_INCOMPLETE/);
assert.match(assets, /ASSET_OPERATIONAL_RELATION_REQUIRED/);
assert.match(assets, /ASSET_OPERATIONAL_AREA_OUTSIDE_HIERARCHY/);
assert.match(assets, /ASSET_HIERARCHY_MOVE_BREAKS_OPERATIONAL_CONTEXT/);
assert.match(assets, /OPERATIONAL_ANCHOR_IN_USE/);
});
test('D5.3 relation lifecycle is historical and audited', async () => {
const service = await source('asset-master/asset-operational-relations.service.ts');
assert.match(service, /INSERT INTO area_company_relations/);
assert.match(service, /valid_until = CURRENT_TIMESTAMP/);
assert.match(service, /AREA_COMPANY_RELATION_IN_USE/);
assert.match(service, /ASSET_AREA_COMPANY_RELATION_CREATED/);
assert.match(service, /ASSET_AREA_COMPANY_RELATION_ENDED/);
});
@@ -0,0 +1,24 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { AssetProvenanceController } from '../../src/asset-master/asset-provenance.controller';
import { AssetTemporalController } from '../../src/asset-master/asset-temporal.controller';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
function permissionFor(controller: object, method: string): string[] {
const handler = controller[method as keyof typeof controller];
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
}
test('provenance reads, updates and verification use separate permissions', () => {
const controller = AssetProvenanceController.prototype;
assert.deepEqual(permissionFor(controller, 'get'), ['assets.read_provenance']);
assert.deepEqual(permissionFor(controller, 'update'), ['assets.manage_provenance']);
assert.deepEqual(permissionFor(controller, 'verify'), ['assets.verify_provenance']);
});
test('all temporal reconstruction endpoints require temporal reading', () => {
const controller = AssetTemporalController.prototype;
assert.deepEqual(permissionFor(controller, 'list'), ['assets.read_temporal']);
assert.deepEqual(permissionFor(controller, 'get'), ['assets.read_temporal']);
});
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { UpdateAssetProvenanceDto } from '../../src/asset-master/dto/update-asset-provenance.dto';
import { ListTemporalAssetsQueryDto } from '../../src/asset-master/dto/list-temporal-assets-query.dto';
import { AssetDataOrigin } from '../../src/database/entities';
test('provenance metadata accepts controlled origins and trims sources', async () => {
const dto = plainToInstance(UpdateAssetProvenanceDto, {
origin: AssetDataOrigin.PROVIDED_DOCUMENT,
sourceName: ' Expediente 2026-14 ',
observedAt: '2026-08-14T15:00:00.000Z',
});
assert.deepEqual(await validate(dto), []);
assert.equal(dto.sourceName, 'Expediente 2026-14');
});
test('provenance metadata rejects unknown origins and invalid dates', async () => {
const dto = plainToInstance(UpdateAssetProvenanceDto, {
origin: 'INTERNET',
observedAt: 'ayer',
});
const properties = new Set((await validate(dto)).map((error) => error.property));
assert.equal(properties.has('origin'), true);
assert.equal(properties.has('observedAt'), true);
});
test('temporal filters require a valid instant and bounded pagination', async () => {
const valid = plainToInstance(ListTemporalAssetsQueryDto, {
at: '2026-08-14T15:00:00.000Z',
page: '2',
pageSize: '50',
});
assert.deepEqual(await validate(valid), []);
assert.equal(valid.page, 2);
const invalid = plainToInstance(ListTemporalAssetsQueryDto, {
at: 'fecha',
pageSize: '101',
});
const properties = new Set((await validate(invalid)).map((error) => error.property));
assert.equal(properties.has('at'), true);
assert.equal(properties.has('pageSize'), true);
});
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { BadRequestException } from '@nestjs/common';
import type { DataSource } from 'typeorm';
import { AssetProvenanceService } from '../../src/asset-master/asset-provenance.service';
import { UpdateAssetProvenanceDto } from '../../src/asset-master/dto/update-asset-provenance.dto';
import { AssetDataOrigin } from '../../src/database/entities';
test('document and import provenance require an identified source', async () => {
const service = new AssetProvenanceService(
{} as DataSource,
{} as never,
{} as never,
);
const dto = Object.assign(new UpdateAssetProvenanceDto(), {
origin: AssetDataOrigin.IMPORT,
sourceName: null,
});
await assert.rejects(
service.update('asset-id', dto, {} as never, {} as never),
(error: unknown) => error instanceof BadRequestException
&& error.getResponse() instanceof Object
&& (error.getResponse() as { code?: string }).code === 'PROVENANCE_SOURCE_REQUIRED',
);
});
@@ -0,0 +1,18 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { AssetRegistryController } from '../../src/asset-master/asset-registry.controller';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
function permissionFor(method: string): string[] {
const handler = AssetRegistryController.prototype[method as keyof AssetRegistryController];
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
}
test('D5.3.3 registry separates consultation and administration', () => {
assert.deepEqual(permissionFor('get'), ['asset_registry.read']);
assert.deepEqual(permissionFor('documents'), ['asset_registry.read']);
for (const method of ['createDocument','profile','addMembership','endMembership','link','identifier','endIdentifier','legalRight','updateRight','rightOrg','endRightOrg']) {
assert.deepEqual(permissionFor(method), ['asset_registry.manage']);
}
});
@@ -0,0 +1,28 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { AddOrganizationMembershipDto } from '../../src/asset-master/dto/add-organization-membership.dto';
import { CreateExternalIdentifierDto } from '../../src/asset-master/dto/create-external-identifier.dto';
import { UpsertOrganizationProfileDto } from '../../src/asset-master/dto/upsert-organization-profile.dto';
const uuid = '550e8400-e29b-41d4-a716-446655440000';
test('D5.3.3 organization profile accepts explicit UTE kind', async () => {
const dto = plainToInstance(UpsertOrganizationProfileDto, { organizationKind:'UTE', legalName:'UTE Prueba' });
assert.equal((await validate(dto)).length, 0);
});
test('D5.3.3 membership validates participant percentage', async () => {
const valid = plainToInstance(AddOrganizationMembershipDto, { memberOrganizationId:uuid, role:'MEMBER', participationPercent:50 });
assert.equal((await validate(valid)).length, 0);
const invalid = plainToInstance(AddOrganizationMembershipDto, { memberOrganizationId:uuid, role:'MEMBER', participationPercent:101 });
assert.ok((await validate(invalid)).length > 0);
});
test('D5.3.3 external identifiers normalize namespace', async () => {
const dto = plainToInstance(CreateExternalIdentifierDto, { namespace:'sen', value:'12345' });
assert.equal(dto.namespace, 'SEN');
assert.equal((await validate(dto)).length, 0);
});
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { DataSource } from 'typeorm';
import { AssetTemporalService } from '../../src/asset-master/asset-temporal.service';
import { ListTemporalAssetsQueryDto } from '../../src/asset-master/dto/list-temporal-assets-query.dto';
test('temporal reconstruction parameterizes dates and user searches', async () => {
const calls: Array<{ sql: string; parameters: unknown[] }> = [];
const dataSource = {
query: async (sql: string, parameters: unknown[]) => {
calls.push({ sql, parameters });
return calls.length === 1 ? [{ total: 0 }] : [];
},
} as unknown as DataSource;
const service = new AssetTemporalService(dataSource);
const query = Object.assign(new ListTemporalAssetsQueryDto(), {
at: '2026-08-14T15:00:00.000Z',
search: "ACT' OR 1=1 --",
});
const result = await service.list(query);
assert.equal(result.meta.total, 0);
assert.equal(calls[0].sql.includes("ACT' OR 1=1"), false);
assert.match(calls[0].sql, /occurred_at <= \$1/);
assert.match(calls[0].sql, /ILIKE \$2/);
assert.equal(calls[0].parameters[1], "%ACT' OR 1=1 --%");
});
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { changedSnapshotFields } from '../../src/asset-master/asset-version-diff';
const base = {
code: 'ACT-001',
name: 'Pozo 1',
description: null,
type: { id: 'type-1', code: 'WELL', name: 'Pozo' },
parent: null,
informationStatus: 'DRAFT',
attributes: [{ code: 'depth', value: 1200 }],
geometry: null,
updatedAt: '2026-08-14T10:00:00.000Z',
};
test('version diff ignores technical timestamps and detects functional fields', () => {
const current = {
...base,
informationStatus: 'SURVEYED',
updatedAt: '2026-08-14T11:00:00.000Z',
};
assert.deepEqual(changedSnapshotFields(base, current), ['informationStatus']);
});
test('version diff detects attribute and geometry changes', () => {
const current = {
...base,
attributes: [{ code: 'depth', value: 1300 }],
geometry: { geometryType: 'POINT', geometry: { type: 'POINT', coordinates: [-68, -32] } },
};
assert.deepEqual(changedSnapshotFields(base, current), ['attributes', 'geometry']);
});
test('version diff detects media changes independently', () => {
const previous = { ...base, media: [] };
const current = { ...base, media: [{ id: 'media-1', sha256: 'abc' }] };
assert.deepEqual(changedSnapshotFields(previous, current), ['media']);
});
test('version diff detects provenance changes independently', () => {
const previous = { ...base, provenance: { origin: 'MANUAL', verifiedAt: null } };
const current = { ...base, provenance: { origin: 'FIELD_SURVEY', verifiedAt: null } };
assert.deepEqual(changedSnapshotFields(previous, current), ['provenance']);
});
+16
View File
@@ -0,0 +1,16 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { AuditController } from '../../src/audit/audit.controller';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
function permissionFor(method: string): string[] {
const prototype = AuditController.prototype;
const handler = prototype[method as keyof typeof prototype];
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
}
test('AuditController protects list and detail with audit.read', () => {
assert.deepEqual(permissionFor('list'), ['audit.read']);
assert.deepEqual(permissionFor('getById'), ['audit.read']);
});
+36
View File
@@ -0,0 +1,36 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { ListAuditQueryDto } from '../../src/audit/dto/list-audit-query.dto';
import { AuditSource } from '../../src/database/entities';
test('ListAuditQueryDto transforms pagination and normalized enums', async () => {
const dto = plainToInstance(ListAuditQueryDto, {
page: '2',
pageSize: '50',
action: ' user_created ',
source: 'web',
from: '2026-08-01T00:00:00.000Z',
});
assert.deepEqual(await validate(dto), []);
assert.equal(dto.page, 2);
assert.equal(dto.pageSize, 50);
assert.equal(dto.action, 'USER_CREATED');
assert.equal(dto.source, AuditSource.WEB);
});
test('ListAuditQueryDto rejects oversized pages and invalid dates', async () => {
const dto = plainToInstance(ListAuditQueryDto, {
page: '0',
pageSize: '500',
from: 'fecha-invalida',
});
const properties = new Set((await validate(dto)).map((error) => error.property));
assert.equal(properties.has('page'), true);
assert.equal(properties.has('pageSize'), true);
assert.equal(properties.has('from'), true);
});
+84
View File
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { BadRequestException } from '@nestjs/common';
import type { DataSource, EntityManager } from 'typeorm';
import { AuditQueryService } from '../../src/audit/audit-query.service';
import { buildAuditFilters } from '../../src/audit/audit-query';
import { ListAuditQueryDto } from '../../src/audit/dto/list-audit-query.dto';
import { AuditSource } from '../../src/database/entities';
test('buildAuditFilters parameterizes every audit filter', () => {
const query = Object.assign(new ListAuditQueryDto(), {
actorUsername: 'admin',
action: 'USER_CREATED',
source: AuditSource.WEB,
entityType: 'user',
requestId: 'request-12345678',
from: '2026-08-01T00:00:00.000Z',
to: '2026-08-31T23:59:59.999Z',
search: "anything' OR 1=1 --",
});
const filters = buildAuditFilters(query);
assert.match(filters.clause, /event\.actor_username ILIKE \$1/);
assert.match(filters.clause, /event\.action = \$2/);
assert.match(filters.clause, /event\.source = \$3/);
assert.match(filters.clause, /event\.occurred_at >= \$6/);
assert.match(filters.clause, /event\.occurred_at <= \$7/);
assert.match(filters.clause, /event\.request_id ILIKE \$8/);
assert.equal(filters.clause.includes("anything' OR 1=1"), false);
assert.equal(filters.parameters[7], "%anything' OR 1=1 --%");
});
test('AuditQueryService rejects an inverted date range before querying', async () => {
const dataSource = {
transaction: () => {
throw new Error('transaction should not run');
},
} as unknown as DataSource;
const service = new AuditQueryService(dataSource);
const query = Object.assign(new ListAuditQueryDto(), {
from: '2026-08-31T00:00:00.000Z',
to: '2026-08-01T00:00:00.000Z',
});
await assert.rejects(
service.list(query),
(error: unknown) =>
error instanceof BadRequestException && error.getStatus() === 400,
);
});
test('AuditQueryService returns stable pagination metadata', async () => {
let calls = 0;
const manager = {
query: async () => {
calls += 1;
return calls === 1
? [{ total: 26 }]
: [
{
id: '0f45472b-541b-4d6c-969f-0821463d0ea7',
action: 'USER_CREATED',
},
];
},
} as unknown as EntityManager;
const dataSource = {
transaction: async (
_isolation: string,
operation: (entityManager: EntityManager) => Promise<unknown>,
) => operation(manager),
} as unknown as DataSource;
const service = new AuditQueryService(dataSource);
const query = Object.assign(new ListAuditQueryDto(), {
page: 2,
pageSize: 25,
});
const result = await service.list(query);
assert.equal(result.meta.total, 26);
assert.equal(result.meta.totalPages, 2);
assert.equal(result.meta.page, 2);
assert.equal(result.data.length, 1);
});
+36
View File
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { sanitizeAuditData } from '../../src/audit/audit-sanitizer';
test('sanitizeAuditData redacts sensitive keys at every level', () => {
const input = {
username: 'inspector',
password: 'hidden',
nested: {
password_hash: 'hidden',
refresh_token: 'hidden',
safe: 'visible',
},
entries: [{ Authorization: 'Bearer hidden' }],
};
assert.deepEqual(sanitizeAuditData(input), {
username: 'inspector',
password: '[REDACTED]',
nested: {
password_hash: '[REDACTED]',
refresh_token: '[REDACTED]',
safe: 'visible',
},
entries: [{ Authorization: '[REDACTED]' }],
});
});
test('sanitizeAuditData handles circular data without throwing', () => {
const input: Record<string, unknown> = { value: 1 };
input.self = input;
assert.deepEqual(sanitizeAuditData(input), {
value: 1,
self: '[CIRCULAR]',
});
});
@@ -0,0 +1,21 @@
import { strict as assert } from 'node:assert';
import test from 'node:test';
import 'reflect-metadata';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
import { DashboardController } from '../../src/dashboard/dashboard.controller';
test('dashboard summary requires dashboard.read', () => {
const permissions = Reflect.getMetadata(
REQUIRED_PERMISSIONS_KEY,
DashboardController.prototype.summary,
);
assert.deepEqual(permissions, ['dashboard.read']);
});
test('dashboard controller delegates summary', async () => {
const expected = { counts: {}, recentAudit: [] };
const controller = new DashboardController({
summary: async () => expected,
} as never);
assert.equal(await controller.summary(), expected);
});
@@ -0,0 +1,53 @@
import { strict as assert } from 'node:assert';
import test from 'node:test';
import { DashboardService } from '../../src/dashboard/dashboard.service';
test('DashboardService returns numeric counts and recent activity consistently', async () => {
const recent = [{
id: 'event-id',
occurredAt: new Date('2026-08-13T12:00:00.000Z'),
actorUsername: 'admin',
action: 'AUTH_LOGIN_SUCCESS',
entityType: 'auth_session',
entityId: 'session-id',
}];
const manager = {
query: async (sql: string) => {
if (sql.includes('COUNT(*) FROM users')) {
return [{
activeUsers: '4', inactiveUsers: '2', activeSessions: '3',
totalAssets: '12', assetsNeedValidation: '4', assetsWithoutGeometry: '6', plannedInspections: '2',
openFindings: '8', awaitingCompanyResponse: '5', overdueCompanyResponses: '2',
companyResponsesDueNext7Days: '1', awaitingVerificationSchedule: '4',
overdueControls: '2', controlsNext30Days: '3',
}];
}
if (sql.includes('FROM inspection_findings finding')) return [];
return recent;
},
};
const dataSource = {
transaction: async (_isolation: string, operation: (value: typeof manager) => unknown) => operation(manager),
};
const result = await new DashboardService(dataSource as never).summary();
assert.deepEqual(result.counts, {
activeUsers: 4,
inactiveUsers: 2,
activeSessions: 3,
totalAssets: 12,
assetsNeedValidation: 4,
assetsWithoutGeometry: 6,
plannedInspections: 2,
openFindings: 8,
awaitingCompanyResponse: 5,
overdueCompanyResponses: 2,
companyResponsesDueNext7Days: 1,
awaitingVerificationSchedule: 4,
overdueControls: 2,
controlsNext30Days: 3,
});
assert.equal(result.recentAudit, recent);
assert.deepEqual(result.upcomingControls, []);
assert.equal(typeof result.generatedAt, 'string');
});
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import test from 'node:test';
async function source(relativePath: string): Promise<string> {
return readFile(resolve(process.cwd(), 'src', relativePath), 'utf8');
}
test('D5.2 versions every catalog item and keeps versions append-only', async () => {
const migration = await source(
'database/migrations/1787335200000-phase-d5-2-finding-catalog-administration.ts',
);
assert.match(migration, /CREATE TABLE finding_catalog_item_versions/);
assert.match(migration, /UNIQUE \(item_id, revision\)/);
assert.match(migration, /jsonb_typeof\(snapshot\) = 'object'/);
assert.match(migration, /INSERT INTO finding_catalog_item_versions/);
assert.match(migration, /FROM finding_catalog_items item/);
assert.match(migration, /GRANT SELECT, INSERT ON TABLE finding_catalog_item_versions/);
assert.match(migration, /REVOKE UPDATE, DELETE ON TABLE finding_catalog_item_versions/);
});
test('D5.2 catalog administration captures audit and immutable revisions', async () => {
const service = await source('inspection-findings/finding-catalog.service.ts');
assert.match(service, /item\.revision \+= 1/);
assert.match(service, /captureVersion\(manager, created, principal\)/);
assert.match(service, /captureVersion\(manager, updated, principal\)/);
assert.match(service, /FINDING_CATEGORY_CREATED/);
assert.match(service, /FINDING_CATALOG_ITEM_UPDATED/);
});
@@ -0,0 +1,21 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
PHASE_D3_FINDING_CATALOG_ITEMS,
PHASE_D3_FINDING_CATEGORIES,
} from '../../src/database/seeds/phase-d3-finding-catalog';
test('D3 catalog contains 19 categories and 170 unique finding templates', () => {
assert.equal(PHASE_D3_FINDING_CATEGORIES.length, 19);
assert.equal(PHASE_D3_FINDING_CATALOG_ITEMS.length, 170);
assert.equal(
new Set(PHASE_D3_FINDING_CATALOG_ITEMS.map((item) => item.code)).size,
170,
);
});
test('D3 catalog records the repaired shifted pump row', () => {
const repaired = PHASE_D3_FINDING_CATALOG_ITEMS.find((item) => item.code === 'BOMBAS-003');
assert.equal(repaired?.title, 'PUESTA A TIERRA');
assert.match(repaired?.importNote ?? '', /celda de título contenía el número 4/i);
});
@@ -0,0 +1,34 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
import {
InspectionActsController,
InspectionVisitActsController,
} from '../../src/inspection-acts/inspection-acts.controller';
function permissionFor(controller: object, method: string): string[] {
const handler = controller[method as keyof typeof controller];
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
}
test('inspection act endpoints separate reading, creation, editing and cancellation', () => {
assert.deepEqual(permissionFor(InspectionVisitActsController.prototype, 'list'), [
'inspection_acts.read',
]);
assert.deepEqual(permissionFor(InspectionVisitActsController.prototype, 'create'), [
'inspection_acts.create',
]);
assert.deepEqual(permissionFor(InspectionActsController.prototype, 'list'), [
'inspection_acts.read',
]);
assert.deepEqual(permissionFor(InspectionActsController.prototype, 'get'), [
'inspection_acts.read',
]);
assert.deepEqual(permissionFor(InspectionActsController.prototype, 'update'), [
'inspection_acts.update',
]);
assert.deepEqual(permissionFor(InspectionActsController.prototype, 'cancel'), [
'inspection_acts.cancel',
]);
});
@@ -0,0 +1,41 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { CancelInspectionActDto } from '../../src/inspection-acts/dto/cancel-inspection-act.dto';
import { CreateInspectionActDto } from '../../src/inspection-acts/dto/create-inspection-act.dto';
const ASSET_ID = '16e54e65-60cf-4739-b0d1-ccdd904fbfd5';
test('inspection act DTO accepts content and selected visit assets', async () => {
const dto = plainToInstance(CreateInspectionActDto, {
occurredAt: '2026-08-14T15:30:00.000Z',
title: ' Verificación de instalación ',
summary: ' Se inspeccionó el activo seleccionado. ',
observations: ' Sin novedades. ',
assetIds: [ASSET_ID],
});
assert.deepEqual(await validate(dto), []);
assert.equal(dto.title, 'Verificación de instalación');
assert.equal(dto.summary, 'Se inspeccionó el activo seleccionado.');
assert.equal(dto.observations, 'Sin novedades.');
});
test('inspection act DTO rejects duplicate assets', async () => {
const dto = plainToInstance(CreateInspectionActDto, {
occurredAt: '2026-08-14T15:30:00.000Z',
title: 'Acta',
summary: 'Contenido',
assetIds: [ASSET_ID, ASSET_ID],
});
assert.equal((await validate(dto)).some((error) => error.property === 'assetIds'), true);
});
test('cancellation requires a meaningful reason', async () => {
const invalid = plainToInstance(CancelInspectionActDto, { reason: ' breve ' });
const valid = plainToInstance(CancelInspectionActDto, { reason: ' Error de carga confirmado ' });
assert.equal((await validate(invalid)).some((error) => error.property === 'reason'), true);
assert.deepEqual(await validate(valid), []);
assert.equal(valid.reason, 'Error de carga confirmado');
});
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { ConflictException } from '@nestjs/common';
import type { DataSource, EntityManager } from 'typeorm';
import { InspectionActsService } from '../../src/inspection-acts/inspection-acts.service';
type SingleActGuard = {
assertVisitHasNoAct(manager: EntityManager, visitId: string): Promise<void>;
};
function serviceGuard(): SingleActGuard {
return new InspectionActsService(
{} as DataSource,
{} as never,
) as unknown as SingleActGuard;
}
test('single-act guard allows a visit without an act', async () => {
const manager = {
query: async () => [],
} as unknown as EntityManager;
await assert.doesNotReject(
serviceGuard().assertVisitHasNoAct(manager, 'visit-id'),
);
});
test('single-act guard rejects a second act for the same visit', async () => {
const manager = {
query: async () => [{ code: 'ACTA-2026-000001' }],
} as unknown as EntityManager;
await assert.rejects(
serviceGuard().assertVisitHasNoAct(manager, 'visit-id'),
(error: unknown) => error instanceof ConflictException
&& error.getResponse() instanceof Object
&& (error.getResponse() as { code?: string }).code
=== 'INSPECTION_VISIT_ACT_ALREADY_EXISTS',
);
});
@@ -0,0 +1,25 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
import {
InspectionClosingController,
InspectionSignatureContentController,
} from '../../src/inspection-closing/inspection-closing.controller';
function permissionFor(controller: object, method: string): string[] {
const handler = controller[method as keyof typeof controller];
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
}
test('D5 closing endpoints separate read, prepare, sign and close permissions', () => {
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'get'), ['inspection_closure.read']);
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'responsible'), ['inspection_closure.prepare']);
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'ready'), ['inspection_closure.prepare']);
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'reopen'), ['inspection_closure.prepare']);
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'inspectorSignature'), ['inspection_closure.sign']);
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'companySignature'), ['inspection_closure.sign']);
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'companyOutcome'), ['inspection_closure.sign']);
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'close'), ['inspection_closure.close']);
assert.deepEqual(permissionFor(InspectionSignatureContentController.prototype, 'content'), ['inspection_closure.read']);
});
@@ -0,0 +1,18 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { canonicalJson, sha256CanonicalJson } from '../../src/inspection-closing/canonical-json';
test('D5 canonical JSON sorts object keys recursively and preserves array order', () => {
const left = { z: 3, nested: { y: 2, x: 1 }, list: [{ b: 2, a: 1 }, 4] };
const right = { list: [{ a: 1, b: 2 }, 4], nested: { x: 1, y: 2 }, z: 3 };
assert.equal(canonicalJson(left), canonicalJson(right));
assert.equal(sha256CanonicalJson(left), sha256CanonicalJson(right));
assert.notEqual(sha256CanonicalJson(left), sha256CanonicalJson({ ...right, z: 4 }));
});
test('D5 canonical JSON serializes dates as ISO-8601', () => {
assert.equal(
canonicalJson({ at: new Date('2026-08-15T01:02:03.000Z') }),
'{"at":"2026-08-15T01:02:03.000Z"}',
);
});
@@ -0,0 +1,56 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { CreateCompanyOutcomeDto } from '../../src/inspection-closing/dto/create-company-outcome.dto';
import { CreateInspectionSignatureDto } from '../../src/inspection-closing/dto/create-inspection-signature.dto';
import { UpsertInspectionResponsibleDto } from '../../src/inspection-closing/dto/upsert-inspection-responsible.dto';
test('D5 present responsible requires identity and position', async () => {
const valid = plainToInstance(UpsertInspectionResponsibleDto, {
attendanceStatus: 'PRESENT',
fullName: ' Responsable Empresa ',
documentType: 'DNI',
documentNumber: '12345678',
position: 'Encargado',
email: 'responsable@empresa.test',
});
assert.deepEqual(await validate(valid), []);
assert.equal(valid.fullName, 'Responsable Empresa');
const invalid = plainToInstance(UpsertInspectionResponsibleDto, {
attendanceStatus: 'PRESENT',
});
assert.equal((await validate(invalid)).length > 0, true);
});
test('D5 absent responsible and company outcome require a detailed reason', async () => {
const responsible = plainToInstance(UpsertInspectionResponsibleDto, {
attendanceStatus: 'ABSENT',
absenceReason: 'No había personal responsable en el establecimiento.',
});
assert.deepEqual(await validate(responsible), []);
const outcome = plainToInstance(CreateCompanyOutcomeDto, {
status: 'ABSENT',
reason: 'Se dejó constancia ante el personal presente.',
});
assert.deepEqual(await validate(outcome), []);
const invalid = plainToInstance(CreateCompanyOutcomeDto, {
status: 'SIGNED',
reason: 'Motivo suficientemente extenso',
});
assert.equal((await validate(invalid)).some((error) => error.property === 'status'), true);
});
test('D5 signature DTO transforms multipart consent and coordinates', async () => {
const dto = plainToInstance(CreateInspectionSignatureDto, {
consentAccepted: 'true',
clientSignedAt: '2026-08-15T01:02:03.000Z',
latitude: '-32.889458',
longitude: '-68.845839',
accuracyM: '5.5',
});
assert.deepEqual(await validate(dto), []);
assert.equal(dto.consentAccepted, true);
assert.equal(dto.latitude, -32.889458);
});
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
inspectInspectionSignatureFile,
MAX_INSPECTION_SIGNATURE_BYTES,
} from '../../src/inspection-closing/inspection-signature-file';
const png = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 0]);
test('D5 signature accepts only PNG content and MIME', () => {
assert.deepEqual(inspectInspectionSignatureFile({
originalname: 'firma.png',
mimetype: 'image/png',
size: png.length,
buffer: png,
}), {
originalName: 'firma.png',
mimeType: 'image/png',
extension: '.png',
});
assert.throws(() => inspectInspectionSignatureFile({
originalname: 'firma.png',
mimetype: 'image/png',
size: 4,
buffer: Buffer.from('test'),
}));
assert.throws(() => inspectInspectionSignatureFile({
originalname: 'firma.png',
mimetype: 'application/octet-stream',
size: png.length,
buffer: png,
}));
});
test('D5 signature rejects empty and oversized files', () => {
assert.throws(() => inspectInspectionSignatureFile(undefined));
assert.throws(() => inspectInspectionSignatureFile({
originalname: 'firma.png',
mimetype: 'image/png',
size: MAX_INSPECTION_SIGNATURE_BYTES + 1,
buffer: Buffer.concat([png, Buffer.alloc(MAX_INSPECTION_SIGNATURE_BYTES)]),
}));
});
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import test from 'node:test';
test('D5 migration protects signatures, frozen findings and sealed acts', async () => {
const source = await readFile(
resolve(
process.cwd(),
'src/database/migrations/1787328000000-phase-d5-act-closing-signatures.ts',
),
'utf8',
);
assert.match(source, /REVOKE UPDATE ON TABLE inspection_act_signatures/);
assert.match(source, /trg_inspection_acts_closed_immutable/);
assert.match(source, /trg_inspection_findings_frozen_fields/);
assert.match(source, /trg_inspection_evidence_observation_draft/);
assert.match(source, /uq_inspection_act_company_outcome/);
assert.match(source, /closure_sha256 ~ '\^\[0-9a-f\]\{64\}\$'/);
});
@@ -0,0 +1,40 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
import {
InspectionEvidenceContentController,
InspectionFindingCommunicationsController,
InspectionFindingEvidenceController,
} from '../../src/inspection-findings/inspection-evidence.controller';
function permissionFor(controller: object, method: string): string[] {
const handler = controller[method as keyof typeof controller];
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
}
test('D4 evidence endpoints separate reading and creation', () => {
assert.deepEqual(
permissionFor(InspectionFindingEvidenceController.prototype, 'list'),
['inspection_evidence.read'],
);
assert.deepEqual(
permissionFor(InspectionFindingEvidenceController.prototype, 'upload'),
['inspection_evidence.create'],
);
assert.deepEqual(
permissionFor(InspectionEvidenceContentController.prototype, 'content'),
['inspection_evidence.read'],
);
});
test('D4 communications endpoints separate reading and creation', () => {
assert.deepEqual(
permissionFor(InspectionFindingCommunicationsController.prototype, 'list'),
['inspection_communications.read'],
);
assert.deepEqual(
permissionFor(InspectionFindingCommunicationsController.prototype, 'create'),
['inspection_communications.create'],
);
});
@@ -0,0 +1,59 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import {
InspectionCommunicationChannel,
InspectionCommunicationDirection,
InspectionCommunicationType,
InspectionEvidenceKind,
InspectionEvidencePurpose,
} from '../../src/database/entities';
import { CreateInspectionCommunicationDto } from '../../src/inspection-findings/dto/create-inspection-communication.dto';
import { CreateInspectionEvidenceDto } from '../../src/inspection-findings/dto/create-inspection-evidence.dto';
test('D4 evidence DTO transforms multipart coordinates and accepts a communication link', async () => {
const dto = plainToInstance(CreateInspectionEvidenceDto, {
kind: InspectionEvidenceKind.DOCUMENT,
purpose: InspectionEvidencePurpose.COMPANY_RESPONSE,
communicationId: 'd567722e-888f-45e8-8ac6-a8bfa6855518',
capturedAt: '2026-08-14T20:30:00.000Z',
latitude: '-32.889458',
longitude: '-68.845839',
accuracyM: '4.250',
deviceLabel: 'Panel web',
});
assert.deepEqual(await validate(dto), []);
assert.equal(dto.latitude, -32.889458);
assert.equal(dto.accuracyM, 4.25);
});
test('D4 communication DTO accepts a company response and normalizes text', async () => {
const dto = plainToInstance(CreateInspectionCommunicationDto, {
direction: InspectionCommunicationDirection.INBOUND,
channel: InspectionCommunicationChannel.EMAIL,
type: InspectionCommunicationType.COMPANY_RESPONSE,
occurredAt: '2026-08-14T20:30:00.000Z',
subject: ' Respuesta al hallazgo H001 ',
details: ' Se comprometen a corregir. ',
contactEmail: 'responsable@empresa.test',
});
assert.deepEqual(await validate(dto), []);
assert.equal(dto.subject, 'Respuesta al hallazgo H001');
assert.equal(dto.details, 'Se comprometen a corregir.');
});
test('D4 communication DTO rejects invalid email and date', async () => {
const dto = plainToInstance(CreateInspectionCommunicationDto, {
direction: InspectionCommunicationDirection.OUTBOUND,
channel: InspectionCommunicationChannel.EMAIL,
type: InspectionCommunicationType.AUTHORITY_NOTICE,
occurredAt: '14/08/2026',
subject: 'Notificación',
contactEmail: 'correo-invalido',
});
const errors = await validate(dto);
assert.equal(errors.some((error) => error.property === 'occurredAt'), true);
assert.equal(errors.some((error) => error.property === 'contactEmail'), true);
});
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { InspectionEvidenceKind } from '../../src/database/entities';
import {
inspectInspectionEvidenceFile,
MAX_INSPECTION_EVIDENCE_BYTES,
} from '../../src/inspection-findings/inspection-evidence-file';
function file(buffer: Buffer, originalname: string) {
return { buffer, originalname, size: buffer.length };
}
test('D4 evidence detects content by magic bytes', () => {
const photo = inspectInspectionEvidenceFile(
file(Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00]), 'evidencia.pdf'),
InspectionEvidenceKind.PHOTO,
);
assert.equal(photo.mimeType, 'image/jpeg');
const document = inspectInspectionEvidenceFile(
file(Buffer.from('%PDF-1.7'), 'respuesta.bin'),
InspectionEvidenceKind.DOCUMENT,
);
assert.equal(document.mimeType, 'application/pdf');
});
test('D4 evidence rejects executable text, mismatches and oversize files', () => {
assert.throws(() => inspectInspectionEvidenceFile(
file(Buffer.from('<script>alert(1)</script>'), 'foto.jpg'),
InspectionEvidenceKind.PHOTO,
));
assert.throws(() => inspectInspectionEvidenceFile(
file(Buffer.from('%PDF-1.7'), 'foto.pdf'),
InspectionEvidenceKind.PHOTO,
));
assert.throws(() => inspectInspectionEvidenceFile({
buffer: Buffer.from([0xff, 0xd8, 0xff]),
originalname: 'grande.jpg',
size: MAX_INSPECTION_EVIDENCE_BYTES + 1,
}, InspectionEvidenceKind.PHOTO));
});
@@ -0,0 +1,18 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import test from 'node:test';
test('D4 migration makes evidence and communications append-only for runtime', async () => {
const source = await readFile(
resolve(
process.cwd(),
'src/database/migrations/1787241600000-phase-d4-finding-evidence-communications.ts',
),
'utf8',
);
assert.match(source, /GRANT SELECT, INSERT ON TABLE/);
assert.match(source, /REVOKE UPDATE, DELETE ON TABLE/);
assert.match(source, /chk_inspection_finding_company_response_pdf/);
assert.match(source, /FOREIGN KEY \(communication_id, finding_id\)/);
});
@@ -0,0 +1,52 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
import { FindingCatalogController } from '../../src/inspection-findings/finding-catalog.controller';
import {
InspectionActFindingsController,
InspectionFindingsController,
} from '../../src/inspection-findings/inspection-findings.controller';
function permissionFor(controller: object, method: string): string[] {
const handler = controller[method as keyof typeof controller];
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
}
test('finding endpoints separate catalog, content, follow-up and closure permissions', () => {
assert.deepEqual(permissionFor(FindingCatalogController.prototype, 'list'), [
'finding_catalog.read',
]);
for (const method of [
'adminList',
'createCategory',
'updateCategory',
'createItem',
'updateItem',
]) {
assert.deepEqual(permissionFor(FindingCatalogController.prototype, method), [
'finding_catalog.manage',
]);
}
assert.deepEqual(permissionFor(InspectionActFindingsController.prototype, 'list'), [
'inspection_findings.read',
]);
assert.deepEqual(permissionFor(InspectionActFindingsController.prototype, 'create'), [
'inspection_findings.create',
]);
assert.deepEqual(permissionFor(InspectionFindingsController.prototype, 'list'), [
'inspection_findings.read',
]);
assert.deepEqual(permissionFor(InspectionFindingsController.prototype, 'get'), [
'inspection_findings.read',
]);
assert.deepEqual(permissionFor(InspectionFindingsController.prototype, 'update'), [
'inspection_findings.update',
]);
assert.deepEqual(permissionFor(InspectionFindingsController.prototype, 'updateFollowUp'), [
'inspection_findings.follow_up',
]);
assert.deepEqual(permissionFor(InspectionFindingsController.prototype, 'close'), [
'inspection_findings.close',
]);
});
@@ -0,0 +1,107 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { CloseInspectionFindingDto } from '../../src/inspection-findings/dto/close-inspection-finding.dto';
import { CreateInspectionFindingDto } from '../../src/inspection-findings/dto/create-inspection-finding.dto';
import { ListInspectionFindingsQueryDto } from '../../src/inspection-findings/dto/list-inspection-findings-query.dto';
import { CreateFindingCatalogItemDto } from '../../src/inspection-findings/dto/create-finding-catalog-item.dto';
import { CreateFindingCategoryDto } from '../../src/inspection-findings/dto/create-finding-category.dto';
import { UpdateFindingCatalogItemDto } from '../../src/inspection-findings/dto/update-finding-catalog-item.dto';
import { UpdateInspectionFindingFollowUpDto } from '../../src/inspection-findings/dto/update-inspection-finding-follow-up.dto';
const ASSET_ID = '16e54e65-60cf-4739-b0d1-ccdd904fbfd5';
const CATALOG_ID = '9e38a67b-d6b1-49e0-8ec3-69a68366bd88';
test('finding DTO accepts catalog findings with a correction deadline', async () => {
const dto = plainToInstance(CreateInspectionFindingDto, {
assetId: ASSET_ID,
catalogItemId: CATALOG_ID,
description: ' Pérdida visible en la unión bridada. ',
correctionDueOn: '2026-09-15',
});
assert.deepEqual(await validate(dto), []);
assert.equal(dto.description, 'Pérdida visible en la unión bridada.');
});
test('custom findings require a title and reject malformed dates', async () => {
const dto = plainToInstance(CreateInspectionFindingDto, {
assetId: ASSET_ID,
catalogItemId: null,
description: 'Observación específica',
correctionDueOn: '15/09/2026',
});
const errors = await validate(dto);
assert.equal(errors.some((error) => error.property === 'customTitle'), true);
assert.equal(errors.some((error) => error.property === 'correctionDueOn'), true);
});
test('follow-up DTO keeps company response and verification planning as separate dates', async () => {
const dto = plainToInstance(UpdateInspectionFindingFollowUpDto, {
companyResponse: ' Se corregirá durante la parada programada. ',
companyResponseReceivedOn: '2026-08-20',
companyCommittedCorrectionOn: '2026-09-10',
nextControlOn: '2026-09-15',
});
assert.deepEqual(await validate(dto), []);
assert.equal(dto.companyResponse, 'Se corregirá durante la parada programada.');
});
test('global finding filters accept the two operational work queues', async () => {
const administrative = plainToInstance(ListInspectionFindingsQueryDto, {
page: '1', pageSize: '25', workflow: 'COMPANY_OVERDUE', search: ' PSEnergy ',
});
const operational = plainToInstance(ListInspectionFindingsQueryDto, {
page: '1', pageSize: '25', workflow: 'VERIFICATION_OVERDUE',
});
assert.deepEqual(await validate(administrative), []);
assert.deepEqual(await validate(operational), []);
assert.equal(administrative.search, 'PSEnergy');
});
test('finding close DTO requires a meaningful conclusion', async () => {
const valid = plainToInstance(CloseInspectionFindingDto, { closureNotes: ' Solución verificada en campo. ' });
const invalid = plainToInstance(CloseInspectionFindingDto, { closureNotes: 'x' });
assert.deepEqual(await validate(valid), []);
assert.equal(valid.closureNotes, 'Solución verificada en campo.');
assert.equal((await validate(invalid)).length > 0, true);
});
test('catalog category DTO normalizes the immutable code', async () => {
const dto = plainToInstance(CreateFindingCategoryDto, {
code: ' seguridad_operativa ',
name: ' Seguridad operativa ',
sortOrder: '25',
});
assert.deepEqual(await validate(dto), []);
assert.equal(dto.code, 'SEGURIDAD_OPERATIVA');
assert.equal(dto.name, 'Seguridad operativa');
assert.equal(dto.sortOrder, 25);
});
test('catalog item DTO accepts nullable reference fields and normalizes its code', async () => {
const dto = plainToInstance(CreateFindingCatalogItemDto, {
categoryId: CATALOG_ID,
code: ' perdida_brida ',
sourceNumber: '171',
title: ' Pérdida visible en unión bridada ',
legalBasis: ' ',
glossary: null,
importNote: ' Alta manual ',
});
assert.deepEqual(await validate(dto), []);
assert.equal(dto.code, 'PERDIDA_BRIDA');
assert.equal(dto.legalBasis, null);
assert.equal(dto.importNote, 'Alta manual');
});
test('catalog item update rejects an empty title and invalid source number', async () => {
const dto = plainToInstance(UpdateFindingCatalogItemDto, {
title: ' ',
sourceNumber: 0,
});
const errors = await validate(dto);
assert.equal(errors.some((error) => error.property === 'title'), true);
assert.equal(errors.some((error) => error.property === 'sourceNumber'), true);
});
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const source = readFileSync(resolve(process.cwd(), 'src/inspection-findings/inspection-findings.service.ts'), 'utf8');
const controller = readFileSync(resolve(process.cwd(), 'src/inspection-findings/inspection-findings.controller.ts'), 'utf8');
test('D5.3.13 separates company response due dates from verification due dates', () => {
assert.match(source, /correction_due_on IS NOT NULL AND correction_due_on/);
assert.match(source, /company_response_received_on IS NOT NULL AND next_control_on/);
assert.match(source, /companyOverdue/);
assert.match(source, /verificationOverdue/);
});
test('D5.3.13 exposes a global office queue and controlled finding closure', () => {
assert.match(controller, /@Get\(\)/);
assert.match(controller, /this\.findings\.listGlobal\(query\)/);
assert.match(controller, /@Patch\(':id\/close'\)/);
assert.match(source, /finding\.status = InspectionFindingStatus\.CLOSED/);
assert.match(source, /finding\.closureNotes = dto\.closureNotes/);
});
@@ -0,0 +1,17 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
import { InspectionActReportController, InspectionReportsController } from '../../src/inspection-reports/inspection-reports.controller';
function permissionFor(controller: object, method: string): string[] {
const handler = controller[method as keyof typeof controller];
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
}
test('D5.3.15 separates report consultation from report generation', () => {
assert.deepEqual(permissionFor(InspectionReportsController.prototype, 'list'), ['inspection_reports.read']);
assert.deepEqual(permissionFor(InspectionReportsController.prototype, 'pending'), ['inspection_reports.read']);
assert.deepEqual(permissionFor(InspectionReportsController.prototype, 'get'), ['inspection_reports.read']);
assert.deepEqual(permissionFor(InspectionActReportController.prototype, 'generate'), ['inspection_reports.generate']);
});
@@ -0,0 +1,20 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
import { InspectionVerificationsController } from '../../src/inspection-verifications/inspection-verifications.controller';
function permissionFor(controller: object, method: string): string[] {
const handler = controller[method as keyof typeof controller];
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
}
test('D5.3.16 separates queue consultation from office verification planning', () => {
assert.deepEqual(permissionFor(InspectionVerificationsController.prototype, 'list'), ['inspection_findings.read']);
assert.deepEqual(permissionFor(InspectionVerificationsController.prototype, 'plan'), ['inspection_findings.read', 'inspection_verifications.plan']);
});
test('D5.3.17 reserves verification result capture for field execution', () => {
assert.deepEqual(permissionFor(InspectionVerificationsController.prototype, 'recordResult'), ['inspections.execute']);
});
@@ -0,0 +1,23 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
import { InspectionVisitsController } from '../../src/inspection-visits/inspection-visits.controller';
function permissionFor(method: string): string[] {
const controller = InspectionVisitsController.prototype;
const handler = controller[method as keyof typeof controller];
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
}
test('inspection visit endpoints separate reading, planning, assignment and execution', () => {
assert.deepEqual(permissionFor('list'), ['inspections.read']);
assert.deepEqual(permissionFor('get'), ['inspections.read']);
assert.deepEqual(permissionFor('assignees'), ['inspections.assign']);
assert.deepEqual(permissionFor('create'), ['inspections.manage', 'inspections.assign']);
assert.deepEqual(permissionFor('update'), ['inspections.manage']);
assert.deepEqual(permissionFor('replaceAssets'), ['inspections.manage']);
assert.deepEqual(permissionFor('replaceTeam'), ['inspections.assign']);
assert.deepEqual(permissionFor('changeStatus'), ['inspections.manage']);
assert.deepEqual(permissionFor('start'), ['inspections.execute']);
});
@@ -0,0 +1,48 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { ChangeInspectionVisitStatusDto } from '../../src/inspection-visits/dto/change-inspection-visit-status.dto';
import { CreateInspectionVisitDto } from '../../src/inspection-visits/dto/create-inspection-visit.dto';
import { ReplaceInspectionVisitAssetsDto } from '../../src/inspection-visits/dto/replace-inspection-visit-assets.dto';
import { ReplaceInspectionVisitTeamDto } from '../../src/inspection-visits/dto/replace-inspection-visit-team.dto';
const AREA_ID = '16e54e65-60cf-4739-b0d1-ccdd904fbfd5';
const COMPANY_ID = 'ce96211d-ac15-4421-8868-4185323aff61';
const USER_ID = '9e38a67b-d6b1-49e0-8ec3-69a68366bd88';
test('visit DTO accepts only the minimum planning data required for quick creation', async () => {
const dto = plainToInstance(CreateInspectionVisitDto, {
operationalAreaId: AREA_ID,
operatorCompanyId: COMPANY_ID,
plannedStartAt: '2026-08-17T12:00:00.000Z',
leadInspectorUserId: USER_ID,
});
assert.deepEqual(await validate(dto), []);
assert.equal(dto.operationalAreaId, AREA_ID);
assert.equal(dto.operatorCompanyId, COMPANY_ID);
assert.equal(dto.leadInspectorUserId, USER_ID);
});
test('asset and team DTOs reject duplicate references', async () => {
const assets = Object.assign(new ReplaceInspectionVisitAssetsDto(), {
assetIds: [AREA_ID, AREA_ID],
});
const team = Object.assign(new ReplaceInspectionVisitTeamDto(), {
leadInspectorUserId: USER_ID,
memberUserIds: [USER_ID, USER_ID],
});
assert.equal((await validate(assets)).some((error) => error.property === 'assetIds'), true);
assert.equal((await validate(team)).some((error) => error.property === 'memberUserIds'), true);
});
test('status DTO accepts controlled states and rejects unknown transitions at input', async () => {
const dto = Object.assign(new ChangeInspectionVisitStatusDto(), {
status: 'PLANNED',
reason: null,
});
assert.deepEqual(await validate(dto), []);
dto.status = 'OPEN' as typeof dto.status;
assert.equal((await validate(dto)).some((error) => error.property === 'status'), true);
});
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import test from 'node:test';
async function source(relativePath: string): Promise<string> {
return readFile(resolve(process.cwd(), 'src', relativePath), 'utf8');
}
function policyCalls(value: string): number {
return value.match(/assertMobileInspector\(principal\);/g)?.length ?? 0;
}
test('D5.1 applies the mobile-inspector policy to every field operation', async () => {
const [visits, acts, findings, evidence, closing] = await Promise.all([
source('inspection-visits/inspection-visits.service.ts'),
source('inspection-acts/inspection-acts.service.ts'),
source('inspection-findings/inspection-findings.service.ts'),
source('inspection-findings/inspection-evidence.service.ts'),
source('inspection-closing/inspection-closing.service.ts'),
]);
assert.equal(policyCalls(visits), 1);
assert.equal(policyCalls(acts), 3);
assert.equal(policyCalls(findings), 2);
assert.equal(policyCalls(evidence), 1);
assert.equal(policyCalls(closing), 7);
assert.match(evidence, /purpose === InspectionEvidencePurpose\.OBSERVATION/);
});
test('D5.1 leaves company follow-up outside the field-operation gate', async () => {
const findings = await source('inspection-findings/inspection-findings.service.ts');
const followUp = findings.slice(findings.indexOf('async updateFollowUp('));
assert.doesNotMatch(followUp, /assertMobileInspector/);
});
test('D5.1 migration grants operational permissions only to inspector', async () => {
const migration = await source(
'database/migrations/1787331600000-phase-d5-1-mobile-inspection-policy.ts',
);
assert.match(migration, /role\.code <> 'inspector'/);
assert.match(migration, /role\.code = 'inspector'/);
assert.doesNotMatch(migration, /inspection_findings\.follow_up/);
});
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { ForbiddenException } from '@nestjs/common';
import type { AuthPrincipal, AuthTransport } from '../../src/common/http/request-context';
import { assertMobileInspector } from '../../src/inspection-operations/mobile-inspector-policy';
function principal(transport: AuthTransport, roles: string[]): AuthPrincipal {
return {
userId: 'b6f6b10e-434a-4d72-9ec9-3520212dd290',
username: 'inspector',
sessionId: '1bab61ac-65d1-40e6-816b-63d90131a795',
firstName: 'Inspector',
lastName: 'DH',
email: null,
mustChangePassword: false,
roles,
permissions: [],
transport,
};
}
function forbiddenCode(error: unknown): string | undefined {
return error instanceof ForbiddenException
? (error.getResponse() as { code?: string }).code
: undefined;
}
test('operational inspection writes reject web cookie sessions', () => {
assert.throws(
() => assertMobileInspector(principal('cookie', ['inspector'])),
(error: unknown) => forbiddenCode(error) === 'INSPECTION_MOBILE_APP_REQUIRED',
);
});
test('operational inspection writes reject non-inspector bearer users', () => {
assert.throws(
() => assertMobileInspector(principal('bearer', ['admin'])),
(error: unknown) => forbiddenCode(error) === 'INSPECTION_INSPECTOR_ROLE_REQUIRED',
);
});
test('operational inspection writes accept inspector bearer users', () => {
assert.doesNotThrow(() => assertMobileInspector(principal('bearer', ['inspector'])));
});
+18
View File
@@ -0,0 +1,18 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { PasswordService } from '../../src/auth/services/password.service';
test('PasswordService hashes with Argon2id and verifies safely', async () => {
const service = new PasswordService();
const hash = await service.hash('Una-clave-segura-2026');
assert.match(hash, /^\$argon2id\$/);
assert.equal(await service.verify(hash, 'Una-clave-segura-2026'), true);
assert.equal(await service.verify(hash, 'clave-incorrecta'), false);
assert.equal(service.needsRehash(hash), false);
});
test('PasswordService performs dummy verification for unknown users', async () => {
const service = new PasswordService();
await assert.doesNotReject(service.verifyUnknown('cualquier-clave'));
});
+140
View File
@@ -0,0 +1,140 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
ExecutionContext,
ForbiddenException,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { RequirePermissions } from '../../src/authorization/decorators/require-permissions.decorator';
import { PermissionsGuard } from '../../src/authorization/guards/permissions.guard';
import { Public } from '../../src/auth/decorators/public.decorator';
import type { AuthPrincipal } from '../../src/common/http/request-context';
@RequirePermissions('dashboard.read')
class ProtectedController {
@RequirePermissions('users.create')
createUser() {}
openToAuthenticatedUsers() {}
}
class UndecoratedController {
handle() {}
}
@RequirePermissions('dashboard.read')
class PublicController {
@Public()
handle() {}
}
function principal(permissions: string[]): AuthPrincipal {
return {
userId: 'b6f6b10e-434a-4d72-9ec9-3520212dd290',
username: 'admin',
sessionId: '1bab61ac-65d1-40e6-816b-63d90131a795',
firstName: 'Admin',
lastName: 'DH',
email: null,
mustChangePassword: false,
roles: ['admin'],
permissions,
transport: 'cookie',
};
}
function context(
controller: new () => object,
handlerName: string,
auth?: AuthPrincipal,
): ExecutionContext {
const handler = controller.prototype[
handlerName as keyof typeof controller.prototype
];
return {
getHandler: () => handler,
getClass: () => controller,
switchToHttp: () => ({
getRequest: () => ({ auth }),
}),
} as unknown as ExecutionContext;
}
const guard = new PermissionsGuard(new Reflector());
test('PermissionsGuard leaves handlers without requirements available', () => {
assert.equal(
guard.canActivate(context(UndecoratedController, 'handle')),
true,
);
});
test('PermissionsGuard gives explicit public metadata priority', () => {
assert.equal(guard.canActivate(context(PublicController, 'handle')), true);
});
test('PermissionsGuard returns 401 when required permissions have no principal', () => {
assert.throws(
() => guard.canActivate(context(ProtectedController, 'createUser')),
(error: unknown) =>
error instanceof UnauthorizedException && error.getStatus() === 401,
);
});
test('PermissionsGuard returns 403 when any required permission is missing', () => {
assert.throws(
() =>
guard.canActivate(
context(
ProtectedController,
'createUser',
principal(['dashboard.read']),
),
),
(error: unknown) =>
error instanceof ForbiddenException && error.getStatus() === 403,
);
});
test('PermissionsGuard requires changing a temporary password first', () => {
const auth = principal(['dashboard.read', 'users.create']);
auth.mustChangePassword = true;
assert.throws(
() =>
guard.canActivate(context(ProtectedController, 'createUser', auth)),
(error: unknown) =>
error instanceof ForbiddenException &&
error.getStatus() === 403 &&
(error.getResponse() as { code?: string }).code ===
'PASSWORD_CHANGE_REQUIRED',
);
});
test('PermissionsGuard authorizes when class and handler permissions are granted', () => {
assert.equal(
guard.canActivate(
context(
ProtectedController,
'createUser',
principal(['dashboard.read', 'users.create']),
),
),
true,
);
});
test('PermissionsGuard applies class requirements to every handler', () => {
assert.equal(
guard.canActivate(
context(
ProtectedController,
'openToAuthenticatedUsers',
principal(['dashboard.read']),
),
),
true,
);
});
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const reportService = readFileSync(resolve(process.cwd(), 'src/inspection-reports/inspection-reports.service.ts'), 'utf8');
const actService = readFileSync(resolve(process.cwd(), 'src/inspection-acts/inspection-acts.service.ts'), 'utf8');
const migration = readFileSync(resolve(process.cwd(), 'src/database/migrations/1788026400000-phase-d5-3-15-document-center.ts'), 'utf8');
test('D5.3.15 exposes global acts and pending report emission', () => {
assert.match(actService, /async listGlobal\(query: ListInspectionActsQueryDto\)/);
assert.match(reportService, /async listPending\(query: ListInspectionReportsQueryDto\)/);
assert.match(reportService, /act\.status = 'CLOSED'/);
assert.match(reportService, /report\.id IS NULL/);
});
test('D5.3.15 freezes a report from the final closed act snapshot', () => {
assert.match(reportService, /assertMobileInspector\(principal\)/);
assert.match(reportService, /final_snapshot AS "finalSnapshot"/);
assert.match(reportService, /DocumentSequenceType\.REPORT/);
assert.match(reportService, /sha256CanonicalJson\(frozenSnapshot\)/);
assert.match(reportService, /INSPECTION_REPORT_GENERATED/);
});
test('D5.3.15 migration creates independent report numbering and protected frozen content', () => {
assert.match(migration, /CREATE TABLE inspection_reports/);
assert.match(migration, /UNIQUE \(report_year, report_number\)/);
assert.match(migration, /dhv2_guard_inspection_report_frozen/);
assert.match(migration, /inspection_reports\.read/);
assert.match(migration, /inspection_reports\.generate/);
});
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const service = readFileSync(resolve(process.cwd(), 'src/inspection-verifications/inspection-verifications.service.ts'), 'utf8');
const migration = readFileSync(resolve(process.cwd(), 'src/database/migrations/1788112800000-phase-d5-3-16-verification-planning.ts'), 'utf8');
const visitService = readFileSync(resolve(process.cwd(), 'src/inspection-visits/inspection-visits.service.ts'), 'utf8');
test('D5.3.16 builds the verification queue from the second finding deadline', () => {
assert.match(service, /company_response_received_on IS NOT NULL/);
assert.match(service, /finding\.next_control_on IS NOT NULL/);
assert.match(service, /verification_visit\.id IS NULL/);
assert.match(service, /overdueUnplanned/);
});
test('D5.3.16 creates one draft verification visit for one company and area', () => {
assert.match(service, /VERIFICATION_CONTEXT_MIXED/);
assert.match(service, /status: InspectionVisitStatus\.DRAFT/);
assert.match(service, /scopeAssetId: areaId/);
assert.match(service, /inspection_visit_assets/);
assert.match(service, /inspection_finding_verification_visits/);
assert.match(service, /INSPECTION_VERIFICATION_PLANNED/);
});
test('D5.3.16 prevents an open finding from being scheduled into two active verification visits', () => {
assert.match(service, /visit\.status IN \('DRAFT', 'PLANNED', 'IN_PROGRESS'\)/);
assert.match(service, /VERIFICATION_ALREADY_PLANNED/);
});
test('D5.3.16 exposes linked findings in the created visit', () => {
assert.match(visitService, /verificationFindings/);
assert.match(visitService, /inspection_finding_verification_visits/);
});
test('D5.3.16 migration adds traceable verification links and an office planning permission', () => {
assert.match(migration, /CREATE TABLE inspection_finding_verification_visits/);
assert.match(migration, /inspection_verifications\.plan/);
assert.match(migration, /REFERENCES inspection_findings\(id\) ON DELETE RESTRICT/);
assert.match(migration, /REFERENCES inspection_visits\(id\) ON DELETE RESTRICT/);
});
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const verificationService = readFileSync(resolve(process.cwd(), 'src/inspection-verifications/inspection-verifications.service.ts'), 'utf8');
const evidenceService = readFileSync(resolve(process.cwd(), 'src/inspection-findings/inspection-evidence.service.ts'), 'utf8');
const closingService = readFileSync(resolve(process.cwd(), 'src/inspection-closing/inspection-closing.service.ts'), 'utf8');
const findingsService = readFileSync(resolve(process.cwd(), 'src/inspection-findings/inspection-findings.service.ts'), 'utf8');
const migration = readFileSync(resolve(process.cwd(), 'src/database/migrations/1788199200000-phase-d5-3-17-verification-execution.ts'), 'utf8');
test('D5.3.17 records one controlled field result for a linked verification finding', () => {
assert.match(verificationService, /assertMobileInspector\(principal\)/);
assert.match(verificationService, /VERIFICATION_VISIT_NOT_IN_PROGRESS/);
assert.match(verificationService, /InspectionVerificationOutcome\.RESOLVED/);
assert.match(verificationService, /InspectionVerificationOutcome\.NOT_RESOLVED/);
assert.match(verificationService, /InspectionVerificationOutcome\.REQUIRES_NEW_DATE/);
assert.match(verificationService, /VERIFICATION_RECORDED/);
assert.match(verificationService, /INSPECTION_VERIFICATION_RESULT_RECORDED/);
});
test('D5.3.17 preserves the original target and only reschedules when explicitly requested', () => {
assert.match(verificationService, /target_control_on/);
assert.match(verificationService, /rescheduled_control_on/);
assert.match(verificationService, /VERIFICATION_NEW_DATE_REQUIRED/);
assert.match(verificationService, /VERIFICATION_NEW_DATE_NOT_FUTURE/);
});
test('D5.3.17 links verification photos to the verification visit', () => {
assert.match(evidenceService, /InspectionEvidencePurpose\.VERIFICATION/);
assert.match(evidenceService, /verification_visit_id/);
assert.match(evidenceService, /INSPECTION_VERIFICATION_VISIT_REQUIRED/);
assert.match(evidenceService, /INSPECTION_VERIFICATION_PHOTO_REQUIRED/);
});
test('D5.3.17 closes a verification act only after every linked finding has a result', () => {
assert.match(closingService, /INSPECTION_VERIFICATION_RESULTS_REQUIRED/);
assert.match(closingService, /verificationResults/);
assert.match(closingService, /DH-ACT-CLOSURE-V2/);
});
test('D5.3.17 moves verified conforming findings into an office ready-to-close queue', () => {
assert.match(findingsService, /READY_TO_CLOSE/);
assert.match(findingsService, /readyToClose/);
assert.match(findingsService, /latestVerificationOutcome/);
});
test('D5.3.17 migration adds verification outcome and verification evidence linkage', () => {
assert.match(migration, /target_control_on date/);
assert.match(migration, /outcome varchar\(24\)/);
assert.match(migration, /verified_at timestamptz/);
assert.match(migration, /rescheduled_control_on date/);
assert.match(migration, /verification_visit_id uuid/);
assert.match(migration, /'VERIFICATION'/);
});
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const controller = readFileSync(resolve(process.cwd(), 'src/inspection-findings/inspection-findings.controller.ts'), 'utf8');
const service = readFileSync(resolve(process.cwd(), 'src/inspection-findings/inspection-findings.service.ts'), 'utf8');
const migration = readFileSync(resolve(process.cwd(), 'src/database/migrations/1788285600000-phase-d5-3-18-3-office-follow-up-hardening.ts'), 'utf8');
test('D5.3.18.3 separates finding closure from general follow-up permission', () => {
assert.match(controller, /@Patch\(':id\/close'\)[\s\S]*@RequirePermissions\('inspection_findings\.close'\)/);
assert.match(migration, /\('inspector', 'inspection_findings\.close'\)/);
assert.match(migration, /\('supervisor', 'inspection_findings\.close'\)/);
assert.match(migration, /\('director', 'inspection_findings\.close'\)/);
assert.doesNotMatch(migration, /\('admin', 'inspection_findings\.close'\)/);
});
test('D5.3.18.3 keeps the first company response immutable and requires later presentations in history', () => {
assert.match(service, /INSPECTION_FINDING_RESPONSE_IMMUTABLE/);
assert.match(service, /La primera respuesta registrada no se modifica/);
assert.match(service, /finding\.companyResponseReceivedOn && \(dto\.companyResponse !== undefined \|\| dto\.companyResponseReceivedOn !== undefined\)/);
});
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const findingsService = readFileSync(resolve(process.cwd(), 'src/inspection-findings/inspection-findings.service.ts'), 'utf8');
test('D5.3.18 exposes one server-side office workflow for the finding dossier', () => {
assert.match(findingsService, /officeWorkflow/);
assert.match(findingsService, /WAITING_COMPANY/);
assert.match(findingsService, /COMPANY_OVERDUE/);
assert.match(findingsService, /DEFINE_VERIFICATION/);
assert.match(findingsService, /PLAN_VERIFICATION/);
assert.match(findingsService, /VERIFICATION_PLANNED/);
assert.match(findingsService, /RESCHEDULE_VERIFICATION/);
assert.match(findingsService, /READY_TO_CLOSE/);
assert.match(findingsService, /CLOSED/);
});
test('D5.3.18 preserves administrative and operational dates as independent fields', () => {
assert.match(findingsService, /correctionDueOn/);
assert.match(findingsService, /companyResponseReceivedOn/);
assert.match(findingsService, /companyCommittedCorrectionOn/);
assert.match(findingsService, /nextControlOn/);
assert.doesNotMatch(findingsService, /finding\.correctionDueOn = dto\.nextControlOn/);
});
@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const read = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8');
const migration = read('src/database/migrations/1788372000000-phase-d5-3-19-inventory-aliases-operational-filters.ts');
const assetsService = read('src/asset-master/assets.service.ts');
const createAssetDto = read('src/asset-master/dto/create-asset.dto.ts');
const findingService = read('src/inspection-findings/inspection-findings.service.ts');
const followUpDto = read('src/inspection-findings/dto/update-inspection-finding-follow-up.dto.ts');
const visitsDto = read('src/inspection-visits/dto/list-inspection-visits-query.dto.ts');
const actsDto = read('src/inspection-acts/dto/list-inspection-acts-query.dto.ts');
const reportsDto = read('src/inspection-reports/dto/list-inspection-reports-query.dto.ts');
const findingsDto = read('src/inspection-findings/dto/list-inspection-findings-query.dto.ts');
const actsService = read('src/inspection-acts/inspection-acts.service.ts');
test('D5.3.19 adds one searchable common name without replacing technical identity', () => {
assert.match(migration, /ADD COLUMN common_name varchar\(200\)/);
assert.match(migration, /idx_assets_common_name_lower/);
assert.match(createAssetDto, /commonName\?: string \| null/);
assert.match(assetsService, /asset\.common_name ILIKE/);
assert.match(assetsService, /common_name AS "commonName"/);
});
test('D5.3.19 stores the administrative response rule and its immutable base', () => {
assert.match(migration, /response_due_basis varchar\(32\)/);
assert.match(migration, /response_due_days integer/);
assert.match(migration, /response_due_base_on date/);
assert.match(migration, /report_notified_on date/);
assert.match(followUpDto, /InspectionFindingResponseDueBasis/);
assert.match(findingService, /INSPECTION_FINDING_RESPONSE_DEADLINE_LOCKED/);
assert.match(findingService, /InspectionFindingResponseDueBasis\.FINDING_DATE/);
assert.match(findingService, /INSPECTION_FINDING_REPORT_NOTIFICATION_REQUIRED/);
assert.match(findingService, /finding\.correctionDueOn = addCalendarDays\(baseOn, days\)/);
});
test('D5.3.19 exposes company area inspector and date filters across operational lists', () => {
for (const dto of [visitsDto, actsDto, reportsDto, findingsDto]) {
assert.match(dto, /companyId\?: string|companyId/);
assert.match(dto, /areaId\?: string|areaId/);
assert.match(dto, /inspectorId\?: string/);
assert.match(dto, /dateFrom\?: string/);
assert.match(dto, /dateTo\?: string/);
}
});
test('D5.3.19 keeps one visit equal to one act', () => {
assert.match(actsService, /await this\.assertVisitHasNoAct\(manager, visitId\)/);
assert.match(actsService, /INSPECTION_VISIT_ACT_ALREADY_EXISTS/);
});
test('D5.3.19 does not introduce the contextual finding catalog before its office definition', () => {
assert.doesNotMatch(migration, /finding_catalog.*asset_type|asset_type.*finding_catalog/i);
});
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const read = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8');
const migration = read('src/database/migrations/1788458400000-phase-d5-3-20-automatic-word-report.ts');
const closing = read('src/inspection-closing/inspection-closing.service.ts');
const reports = read('src/inspection-reports/inspection-reports.service.ts');
const controller = read('src/inspection-reports/inspection-reports.controller.ts');
const builder = read('src/inspection-reports/inspection-report-word-builder.ts');
const wordService = read('src/inspection-reports/inspection-report-word.service.ts');
test('D5.3.20 freezes one report automatically in the same act-closing transaction', () => {
assert.match(closing, /await this\.reports\.ensureFrozenReport\(manager, actId, principal, request\)/);
assert.match(closing, /await this\.reports\.ensureWordForAct\(actId\)/);
assert.match(reports, /async ensureFrozenReport\(/);
assert.match(reports, /SELECT id FROM inspection_reports WHERE act_id/);
});
test('D5.3.20 stores an integrity-checked Word artifact without changing the frozen report', () => {
assert.match(migration, /ADD COLUMN word_status varchar\(24\)/);
assert.match(migration, /word_sha256 char\(64\)/);
assert.match(migration, /word_generated_at timestamptz/);
assert.match(wordService, /createHash\('sha256'\)/);
assert.match(wordService, /word_status = 'READY'/);
assert.match(controller, /@Get\(':id\/word'\)/);
});
test('D5.3.20 builds OOXML Word from the frozen act snapshot without external document dependencies', () => {
assert.match(builder, /\[Content_Types\]\.xml/);
assert.match(builder, /word\/document\.xml/);
assert.match(builder, /0x04034b50/);
assert.match(builder, /buildInspectionReportWord/);
assert.doesNotMatch(builder, /nodemailer|libreoffice|pandoc/i);
});
test('D5.3.20 preserves one visit one act and leaves email delivery for a configured phase', () => {
assert.match(closing, /findingsRemainOpen: true/);
assert.doesNotMatch(migration, /smtp|email_outbox|mail_delivery/i);
});
@@ -0,0 +1,7 @@
import assert from 'node:assert/strict'; import test from 'node:test'; import {readFileSync} from 'node:fs'; import {resolve} from 'node:path';
const read=(path:string)=>readFileSync(resolve(process.cwd(),path),'utf8'); const migration=read('src/database/migrations/1788544800000-phase-d5-3-21-document-delivery.ts'); const delivery=read('src/inspection-reports/inspection-document-delivery.service.ts'); const smtp=read('src/inspection-reports/smtp-delivery.service.ts'); const pdf=read('src/inspection-reports/inspection-act-pdf-builder.ts'); const controller=read('src/inspection-reports/document-delivery.controller.ts'); const reports=read('src/inspection-reports/inspection-reports.service.ts');
test('D5.3.21 stores institutional and company recipients without hardcoding addresses',()=>{assert.match(migration,/institutional_delivery_settings/);assert.match(migration,/notification_email varchar\(320\)/);assert.match(delivery,/office_email/);assert.match(delivery,/director_email/);assert.doesNotMatch(delivery,/@gmail\.com|@hotmail\.com|@mendoza\.gov/i);});
test('D5.3.21 creates an immutable act PDF and separates its delivery from the report Word',()=>{assert.match(migration,/inspection_act_pdf_artifacts/);assert.match(migration,/ACT_PDF/);assert.match(migration,/REPORT_WORD/);assert.match(pdf,/%PDF-1\.4/);assert.match(delivery,/this\.pdf\.ensure/);assert.match(delivery,/this\.word\.ensure/);});
test('D5.3.21 keeps an auditable retryable outbox with explicit waiting states',()=>{assert.match(migration,/WAITING_RECIPIENT/);assert.match(migration,/WAITING_TRANSPORT/);assert.match(migration,/WAITING_ARTIFACT/);assert.match(delivery,/export interface DeliveryRow/);assert.match(controller,/Promise<DeliveryRow>/);assert.match(controller,/outbox\/:id\/retry/);assert.match(controller,/retry-pending/);});
test('D5.3.21 only attempts SMTP when transport and recipients are configured',()=>{assert.match(smtp,/SMTP_HOST/);assert.match(smtp,/MAIL_FROM/);assert.match(smtp,/STARTTLS/);assert.match(delivery,/this\.smtp\.configured\(\)/);assert.match(delivery,/WAITING_TRANSPORT/);});
test('D5.3.21 triggers document delivery after the frozen report Word step without changing one visit one act',()=>{assert.match(reports,/await this\.delivery\.dispatchForAct\(actId\)/); const acts=read('src/inspection-acts/inspection-acts.service.ts');assert.match(acts,/assertVisitHasNoAct/);assert.match(acts,/INSPECTION_VISIT_ACT_ALREADY_EXISTS/);});
@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const read = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8');
const migration = read('src/database/migrations/1788631200000-phase-d5-3-22-director-report-review.ts');
const review = read('src/inspection-reports/inspection-report-review.service.ts');
const controller = read('src/inspection-reports/inspection-report-review.controller.ts');
const word = read('src/inspection-reports/inspection-report-word.service.ts');
const acts = read('src/inspection-acts/inspection-acts.service.ts');
const findings = read('src/inspection-findings/inspection-findings.service.ts');
test('D5.3.22 keeps report corrections as immutable numbered Word revisions', () => {
assert.match(migration, /inspection_report_revisions/);
assert.match(migration, /UNIQUE \(report_id, revision_number\)/);
assert.match(migration, /source IN \('AUTO','DIRECTOR_UPLOAD'\)/);
assert.match(word, /'AUTO'/);
assert.match(review, /'DIRECTOR_UPLOAD'/);
assert.match(review, /current_revision_number = \$2/);
});
test('D5.3.22 reserves revision approval and final signature to the Director role', () => {
assert.match(migration, /inspection_reports\.revise/);
assert.match(migration, /inspection_reports\.review/);
assert.match(migration, /inspection_reports\.sign_final/);
assert.match(migration, /\('director', 'inspection_reports\.sign_final'\)/);
assert.doesNotMatch(migration, /\('admin', 'inspection_reports\.sign_final'\)/);
assert.match(review, /role\.code = 'director'/);
assert.match(controller, /@RequirePermissions\('inspection_reports\.sign_final'\)/);
});
test('D5.3.22 approves exactly the latest revision before allowing a final signature', () => {
assert.match(review, /revision_number = \$2/);
assert.match(review, /approved_revision_id = \$2/);
assert.match(review, /review_status = 'APPROVED'/);
assert.match(review, /INSPECTION_REPORT_NOT_APPROVED/);
assert.match(review, /review_status = 'SIGNED'/);
});
test('D5.3.22 binds the final Director signature to report and revision hashes', () => {
assert.match(migration, /inspection_report_signatures/);
assert.match(review, /DH-INSPECTION-REPORT-SIGNATURE-V1/);
assert.match(review, /reportFrozenSha256/);
assert.match(review, /revisionSha256/);
assert.match(review, /sha256CanonicalJson\(payload\)/);
assert.match(review, /INSPECTION_REPORT_ALREADY_SIGNED/);
});
test('D5.3.22 changes only the report review layer and preserves inspection immutability', () => {
assert.match(acts, /assertVisitHasNoAct/);
assert.match(acts, /INSPECTION_VISIT_ACT_ALREADY_EXISTS/);
assert.match(findings, /INSPECTION_FINDING_RESPONSE_IMMUTABLE/);
assert.doesNotMatch(migration, /ALTER TABLE inspection_acts/);
assert.doesNotMatch(migration, /ALTER TABLE inspection_findings/);
});
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const read = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8');
const migration = read('src/database/migrations/1788717600000-phase-d5-3-23-field-discoveries.ts');
const assets = read('src/asset-master/assets.service.ts');
const inspectionLinks = read('src/inspection-operations/field-discovery-inspection-link.service.ts');
const controller = read('src/asset-master/assets.controller.ts');
const mobilePolicy = read('src/inspection-operations/mobile-inspector-policy.ts');
const findings = read('src/inspection-findings/inspection-findings.service.ts');
test('D5.3.23 records field discoveries as auditable provisional inventory records', () => {
assert.match(migration, /asset_field_discoveries/);
assert.match(migration, /status IN \('PENDING','APPROVED','MATCHED','REJECTED'\)/);
assert.match(assets, /AssetDataOrigin\.FIELD_SURVEY/);
assert.match(assets, /AssetInformationStatus\.DRAFT/);
assert.match(assets, /inspection-visit:/);
});
test('D5.3.23 only lets a mobile assigned inspector create during an active visit', () => {
assert.match(assets, /assertMobileInspector\(principal\)/);
assert.match(mobilePolicy, /principal\.transport !== 'bearer'/);
assert.match(assets, /visit\.status !== 'IN_PROGRESS'/);
assert.match(assets, /FIELD_DISCOVERY_INSPECTOR_NOT_ASSIGNED/);
});
test('D5.3.23 immediately links the provisional object to the visit and open act', () => {
assert.match(assets, /fieldDiscoveryInspectionLinks\.attach/);
assert.doesNotMatch(assets, /INSERT INTO inspection_/);
assert.match(inspectionLinks, /INSERT INTO inspection_visit_assets/);
assert.match(inspectionLinks, /INSERT INTO inspection_act_assets/);
assert.match(inspectionLinks, /FIELD_DISCOVERY_ACT_NOT_EDITABLE/);
assert.match(findings, /assertActAsset/);
});
test('D5.3.23 office review can approve match or reject without deleting historical objects', () => {
assert.match(controller, /field-discoveries\/:id\/approve/);
assert.match(controller, /field-discoveries\/:id\/match/);
assert.match(controller, /field-discoveries\/:id\/reject/);
assert.match(controller, /@RequirePermissions\('assets\.change_status'\)/);
assert.match(assets, /AssetInformationStatus\.VALIDATED/);
assert.match(assets, /ASSET_TYPE_CHANGE_NOT_ALLOWED/);
assert.match(assets, /AssetInformationStatus\.INACTIVE/);
assert.doesNotMatch(assets, /DELETE FROM assets/);
});
test('D5.3.23 preserves immutable inspection history when matching an existing inventory object', () => {
assert.match(migration, /matched_asset_id uuid/);
assert.match(migration, /matched_asset_id <> asset_id/);
assert.match(assets, /FIELD_DISCOVERY_MATCH_CONTEXT_MISMATCH/);
assert.doesNotMatch(assets, /UPDATE inspection_findings SET asset_id/);
assert.doesNotMatch(assets, /UPDATE inspection_act_assets SET asset_id/);
});
@@ -0,0 +1,51 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const read = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8');
const migration = read('src/database/migrations/1788804000000-phase-d5-3-24-asset-context-history.ts');
const assets = read('src/asset-master/assets.service.ts');
const controller = read('src/asset-master/assets.controller.ts');
const temporal = read('src/asset-master/asset-temporal.service.ts');
const versionEntity = read('src/database/entities/asset-version.entity.ts');
test('D5.3.24 stores parent area and operator as explicit non-overlapping temporal context', () => {
assert.match(migration, /CREATE TABLE asset_context_history/);
assert.match(migration, /valid_from timestamptz NOT NULL/);
assert.match(migration, /valid_until timestamptz/);
assert.match(migration, /uq_asset_context_history_active/);
assert.match(migration, /operational_area_id IS NULL AND operator_company_id IS NULL/);
assert.match(migration, /REVOKE DELETE ON TABLE asset_context_history/);
});
test('D5.3.24 reconstructs previous context from immutable asset versions during migration', () => {
assert.match(migration, /FROM asset_versions version/);
assert.match(migration, /LAG\(NULLIF\(version\.snapshot #>> '\{parent,id\}'/);
assert.match(migration, /LEAD\(changes\.occurred_at\)/);
assert.match(migration, /Cambio de contexto reconstruido desde el historial versionado/);
});
test('D5.3.24 separates ordinary editing from an audited context transfer', () => {
assert.match(controller, /@Get\(':id\/context-history'\)/);
assert.match(controller, /@Post\(':id\/context'\)/);
assert.match(controller, /@RequirePermissions\('assets\.manage_context'\)/);
assert.match(assets, /ASSET_CONTEXT_CHANGE_REQUIRES_TRANSFER/);
assert.match(assets, /AssetVersionChangeType\.CONTEXT_CHANGED/);
assert.match(versionEntity, /CONTEXT_CHANGED = 'CONTEXT_CHANGED'/);
assert.match(assets, /AuditAction\.ASSET_CONTEXT_CHANGED/);
});
test('D5.3.24 closes the previous interval and appends the replacement without deleting history', () => {
assert.match(assets, /UPDATE asset_context_history[\s\S]*SET valid_until=\$2/);
assert.match(assets, /INSERT INTO asset_context_history/);
assert.match(assets, /change_reason/);
assert.doesNotMatch(assets, /DELETE FROM asset_context_history/);
});
test('D5.3.24 temporal reconstruction resolves context by effective validity at the requested instant', () => {
assert.match(temporal, /FROM asset_context_history history/);
assert.match(temporal, /history\.valid_from <= \$2/);
assert.match(temporal, /history\.valid_until IS NULL OR history\.valid_until > \$2/);
assert.match(temporal, /operatorCompany: context\.operatorCompany/);
});
@@ -0,0 +1,51 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const read = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8');
const migration = read('src/database/migrations/1788890400000-phase-d5-3-25-verification-event-history.ts');
const findings = read('src/inspection-findings/inspection-findings.service.ts');
const findingsController = read('src/inspection-findings/inspection-findings.controller.ts');
const verifications = read('src/inspection-verifications/inspection-verifications.service.ts');
const ledger = read('src/inspection-verifications/verification-event-ledger.ts');
test('D5.3.25 creates an append-only verification event ledger and reconstructs prior history', () => {
assert.match(migration, /CREATE TABLE inspection_finding_verification_events/);
assert.match(migration, /FROM inspection_finding_versions version/);
assert.match(migration, /FROM inspection_finding_verification_visits verification_link/);
assert.match(migration, /REVOKE UPDATE, DELETE ON TABLE inspection_finding_verification_events/);
assert.doesNotMatch(ledger, /UPDATE inspection_finding_verification_events/);
assert.doesNotMatch(ledger, /DELETE FROM inspection_finding_verification_events/);
});
test('D5.3.25 records every office control-date change without replacing historical events', () => {
assert.match(findings, /dto\.nextControlOn !== undefined && dto\.nextControlOn !== before\.nextControlOn/);
assert.match(findings, /InspectionFindingVerificationEventType\.CONTROL_DATE_DEFINED/);
assert.match(findings, /InspectionFindingVerificationEventType\.CONTROL_DATE_CHANGED/);
assert.match(findings, /InspectionFindingVerificationEventType\.CONTROL_DATE_CLEARED/);
assert.match(findings, /appendVerificationEvent\(manager/);
});
test('D5.3.25 makes one field result immutable and appends its result event', () => {
assert.match(verifications, /VERIFICATION_RESULT_IMMUTABLE/);
assert.match(verifications, /InspectionFindingVerificationEventType\.RESULT_RECORDED/);
assert.match(migration, /protect_inspection_verification_result_immutability/);
assert.match(migration, /trg_inspection_verification_result_immutable/);
assert.match(migration, /OLD\.result_recorded_at IS NOT NULL/);
});
test('D5.3.25 preserves successive verification visits as separate historical attempts', () => {
assert.match(verifications, /visit\.status IN \('DRAFT', 'PLANNED', 'IN_PROGRESS'\)/);
assert.match(verifications, /INSERT INTO inspection_finding_verification_visits/);
assert.match(verifications, /InspectionFindingVerificationEventType\.VISIT_PLANNED/);
assert.doesNotMatch(verifications, /DELETE FROM inspection_finding_verification_visits/);
});
test('D5.3.25 exposes the complete verification history through finding detail and a dedicated endpoint', () => {
assert.match(findingsController, /@Get\(':id\/verification-history'\)/);
assert.match(findings, /loadVerificationHistory/);
assert.match(findings, /inspection_finding_verification_events event/);
assert.match(findings, /verificationHistory/);
assert.match(findings, /evidenceCount/);
});
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import test from 'node:test';
async function source(relativePath: string): Promise<string> {
return readFile(resolve(process.cwd(), 'src', relativePath), 'utf8');
}
test('D5.3.3 separates operational state from data quality and adds registry structures', async () => {
const migration = await source('database/migrations/1787508000000-phase-d5-3-3-definitive-operational-model.ts');
assert.match(migration, /CREATE TYPE asset_operational_status/);
assert.match(migration, /CREATE TABLE organization_profiles/);
assert.match(migration, /CREATE TABLE organization_memberships/);
assert.match(migration, /CREATE TABLE source_documents/);
assert.match(migration, /CREATE TABLE asset_external_identifiers/);
assert.match(migration, /CREATE TABLE area_legal_rights/);
assert.match(migration, /CREATE TABLE area_legal_right_organizations/);
assert.match(migration, /relation_role='OPERATOR'/);
assert.match(migration, /organization cannot be inactivated while operational relations, assignments, memberships or legal participation are active/);
});
test('D5.3.3 keeps physical hierarchy separate from legal and organizational relations', async () => {
const bootstrap = await source('asset-master/asset-master-bootstrap.ts');
const registry = await source('asset-master/asset-registry.service.ts');
assert.match(bootstrap, /name: 'Área'/);
assert.match(bootstrap, /name: 'Organización'/);
assert.match(bootstrap, /text\('cuenca', 'Cuenca'/);
assert.match(bootstrap, /text\('departamento', 'Departamento'/);
assert.match(registry, /OrganizationKind\.UTE/);
assert.match(registry, /AreaLegalRight/);
assert.match(registry, /source_documents/);
});
test('D5.3.3 only accepts OPERATOR relations for exclusive asset assignment', async () => {
const assets = await source('asset-master/assets.service.ts');
const relations = await source('asset-master/asset-operational-relations.service.ts');
assert.match(assets, /relation_role = 'OPERATOR'/);
assert.match(relations, /relation\.relation_role = 'OPERATOR'/);
assert.match(relations, /relationRole/);
});
@@ -0,0 +1,13 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const service = readFileSync(resolve(process.cwd(), 'src/inspection-findings/finding-catalog.service.ts'), 'utf8');
test('D5.4.1 exports controller-visible selection views so Nest declaration build can name them', () => {
assert.match(service, /export interface CatalogSelectionItem/);
assert.match(service, /export interface AssetSelectionItem extends CatalogSelectionItem/);
assert.match(service, /export interface AssetTypeSelectionView/);
assert.match(service, /export interface AssetSelectionView/);
});
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const read = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8');
const migration = read('src/database/migrations/1788976800000-phase-d5-4-contextual-finding-catalog.ts');
const catalogService = read('src/inspection-findings/finding-catalog.service.ts');
const catalogController = read('src/inspection-findings/finding-catalog.controller.ts');
const findingsService = read('src/inspection-findings/inspection-findings.service.ts');
const createFindingDto = read('src/inspection-findings/dto/create-inspection-finding.dto.ts');
const catalogItemDto = read('src/inspection-findings/dto/create-finding-catalog-item.dto.ts');
const closingService = read('src/inspection-closing/inspection-closing.service.ts');
const wordBuilder = read('src/inspection-reports/inspection-report-word-builder.ts');
test('D5.4 models type applicability and concrete Inventory exceptions separately', () => {
assert.match(migration, /CREATE TABLE finding_catalog_asset_type_profiles/);
assert.match(migration, /CREATE TABLE finding_catalog_item_asset_types/);
assert.match(migration, /CREATE TABLE finding_catalog_asset_overrides/);
assert.match(catalogService, /replaceAssetTypeSelection/);
assert.match(catalogService, /replaceAssetSelection/);
assert.match(catalogService, /typeDefaultEnabled/);
});
test('D5.4 exposes only applicable catalog items and enforces the same rule on field creation', () => {
assert.match(catalogController, /@Get\('applicable\/:assetId'\)/);
assert.match(catalogService, /listApplicableForAsset/);
assert.match(findingsService, /FINDING_CATALOG_ITEM_NOT_APPLICABLE/);
assert.match(findingsService, /finding_catalog_asset_type_profiles profile/);
assert.match(findingsService, /finding_catalog_asset_overrides override/);
assert.match(findingsService, /await this\.requireCatalogItem\(manager, finding\.catalogItemId, dto\.assetId\)/);
});
test('D5.4 stores suggested and actual severity independently on the 1 to 10 scale', () => {
assert.match(migration, /suggested_severity smallint/);
assert.match(migration, /severity smallint/);
assert.match(migration, /BETWEEN 1 AND 10/);
assert.match(catalogItemDto, /@Min\(1\)/);
assert.match(catalogItemDto, /@Max\(10\)/);
assert.match(createFindingDto, /severity\?: number/);
assert.match(findingsService, /suggestedSeverity: catalog\?\.suggestedSeverity/);
assert.match(findingsService, /severity: dto\.severity \?\? catalog\?\.suggestedSeverity/);
assert.match(closingService, /finding\.suggested_severity AS "suggestedSeverity"/);
assert.match(closingService, /finding\.severity/);
assert.match(wordBuilder, /'Gravedad'/);
});
test('D5.4 turns OTHER into an office proposal without rewriting the original finding', () => {
assert.match(migration, /CREATE TABLE finding_catalog_proposals/);
assert.match(findingsService, /INSERT INTO finding_catalog_proposals/);
assert.match(catalogService, /FINDING_CATALOG_PROPOSAL_ALREADY_REVIEWED/);
assert.match(catalogService, /status = 'MATCHED'/);
assert.match(catalogService, /status = 'REJECTED'/);
assert.doesNotMatch(catalogService, /UPDATE inspection_findings SET catalog_item_id/);
});
test('D5.4 keeps contextual catalog administration in office permissions and audited routes', () => {
assert.match(catalogController, /@Put\('asset-types\/:assetTypeId\/selection'\)/);
assert.match(catalogController, /@Put\('assets\/:assetId\/selection'\)/);
assert.match(catalogController, /@Get\('proposals'\)/);
assert.match(catalogController, /finding_catalog\.manage/);
assert.match(catalogService, /FINDING_CATALOG_TYPE_APPLICABILITY_UPDATED/);
assert.match(catalogService, /FINDING_CATALOG_ASSET_SELECTION_UPDATED/);
assert.match(catalogService, /FINDING_CATALOG_PROPOSAL_MATCHED/);
});
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const root = process.cwd();
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
test('D5.5 stores explicit Area and Operator context plus append-only checklist generations', () => {
const migration = read('src/database/migrations/1789063200000-phase-d5-5-web-planning-checklist.ts');
assert.match(migration, /ADD COLUMN operational_area_id uuid/);
assert.match(migration, /ADD COLUMN operator_company_id uuid/);
assert.match(migration, /CREATE TABLE inspection_visit_checklist_items/);
assert.match(migration, /generation_number integer NOT NULL/);
assert.match(migration, /REVOKE UPDATE, DELETE ON TABLE[\s\S]*inspection_visit_checklist_items, inspection_visit_asset_events/);
});
test('D5.5 exposes Area then active OPERATOR choices and a server-generated planning checklist', () => {
const controller = read('src/inspection-visits/inspection-visits.controller.ts');
const service = read('src/inspection-visits/inspection-visits.service.ts');
assert.match(controller, /@Get\('planning-context\/areas'\)/);
assert.match(controller, /@Get\('planning-context\/areas\/:areaId\/operators'\)/);
assert.match(controller, /@Post\(':id\/checklist\/generate'\)/);
assert.match(service, /relation\.relation_role = 'OPERATOR'/);
assert.match(service, /INSPECTION_OPERATOR_NOT_ACTIVE_IN_AREA/);
});
test('D5.5 classifies antecedents overdue items and next controls while adding actionable assets automatically', () => {
const service = read('src/inspection-visits/inspection-visits.service.ts');
assert.match(service, /'COMPANY_OVERDUE'/);
assert.match(service, /'VERIFICATION_OVERDUE'/);
assert.match(service, /'UPCOMING_CONTROL'/);
assert.match(service, /'ANTECEDENT'/);
assert.match(service, /item_kind IN \('COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL'\)/);
assert.match(service, /'AUTO_INCLUDED'/);
});
test('D5.5 treats manual additions as preventive and requires an auditable reason for exclusions', () => {
const service = read('src/inspection-visits/inspection-visits.service.ts');
const dto = read('src/inspection-visits/dto/exclude-inspection-visit-asset.dto.ts');
assert.match(service, /planning_source[^\n]*[\s\S]*'PREVENTIVE'/);
assert.match(service, /INSPECTION_EXCLUSION_REASON_REQUIRED/);
assert.match(service, /INSPECTION_VISIT_ASSET_EXCLUDED/);
assert.match(service, /INSPECTION_VISIT_ASSET_REINCLUDED/);
assert.match(dto, /@MinLength\(10\)/);
});
test('D5.5 keeps verification visits inside the same Area Operator planning contract and start remains APK-only', () => {
const verification = read('src/inspection-verifications/inspection-verifications.service.ts');
const visits = read('src/inspection-visits/inspection-visits.service.ts');
assert.match(verification, /operationalAreaId: areaId/);
assert.match(verification, /operatorCompanyId: companyId/);
assert.match(verification, /planning_source = 'VERIFICATION'/);
assert.match(visits, /assertMobileInspector\(principal\)/);
assert.match(visits, /INSPECTION_CHECKLIST_REQUIRED/);
});
test('D5.5 confirmation requires a reviewed current checklist and keeps visit start under mobile inspector policy', () => {
const visits = read('src/inspection-visits/inspection-visits.service.ts');
const policy = read('src/inspection-operations/mobile-inspector-policy.ts');
assert.match(visits, /INSPECTION_CHECKLIST_REQUIRED/);
assert.match(visits, /checklistGeneratedAt/);
assert.match(visits, /assertMobileInspector\(principal\)/);
assert.match(policy, /transport !== 'bearer'/);
assert.match(policy, /includes\('inspector'\)/);
});
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const root = process.cwd();
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
test('D5.6.1 exposes controlled administrative password reset with the existing user-update permission', () => {
const controller = read('src/administration/users/users.controller.ts');
const dto = read('src/administration/users/dto/reset-user-password.dto.ts');
assert.match(controller, /@Post\(':id\/reset-password'\)/);
assert.match(controller, /@RequirePermissions\('users.update'\)/);
assert.match(dto, /@MinLength\(12\)/);
assert.match(dto, /mustChangePassword = true/);
});
test('D5.6.1 resets lockout revokes sessions and audits without persisting the password value', () => {
const service = read('src/administration/users/users.service.ts');
const audit = read('src/database/entities/audit-event.entity.ts');
assert.match(service, /user\.failedLoginAttempts = 0/);
assert.match(service, /user\.lockedUntil = null/);
assert.match(service, /revokeUserSessions\(id, undefined, manager\)/);
assert.match(service, /AuditAction\.USER_PASSWORD_RESET/);
assert.match(service, /passwordValueRecorded: false/);
assert.match(audit, /USER_PASSWORD_RESET = 'USER_PASSWORD_RESET'/);
});
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const root = process.cwd();
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
test('D5.6.4 generates inspection codes server-side with an annual transactional sequence', () => {
const generator = read('src/inspection-visits/inspection-visit-code.ts');
assert.match(generator, /INS-\$\{year\}-\$\{String\(sequence\)\.padStart\(6, '0'\)\}/);
assert.match(generator, /pg_advisory_xact_lock/);
assert.match(generator, /America\/Argentina\/Mendoza/);
});
test('D5.6.4 creates one inspection from Area Operator start and lead inspector without manual title or end date', () => {
const dto = read('src/inspection-visits/dto/create-inspection-visit.dto.ts');
const service = read('src/inspection-visits/inspection-visits.service.ts');
assert.doesNotMatch(dto, /code!/);
assert.doesNotMatch(dto, /title!/);
assert.doesNotMatch(dto, /plannedEndAt/);
assert.match(dto, /operationalAreaId!/);
assert.match(dto, /operatorCompanyId!/);
assert.match(dto, /plannedStartAt!/);
assert.match(dto, /leadInspectorUserId!/);
assert.match(service, /title: code/);
assert.match(service, /plannedEndAt: null/);
});
test('D5.6.4 assigns the lead inspector and generates the checklist in the same creation transaction', () => {
const service = read('src/inspection-visits/inspection-visits.service.ts');
assert.match(service, /leadInspectorUserId: dto\.leadInspectorUserId/);
assert.match(service, /await this\.replaceMemberLinks\(/);
assert.match(service, /await this\.generateChecklistInternal\(manager, visit, principal\.userId\)/);
});
test('D5.6.4 uses the same inspection numbering and start-only planning for verification visits', () => {
const dto = read('src/inspection-verifications/dto/plan-verification-visit.dto.ts');
const service = read('src/inspection-verifications/inspection-verifications.service.ts');
assert.doesNotMatch(dto, /plannedEndAt/);
assert.match(service, /nextInspectionVisitCode\(manager, plannedStartAt\)/);
assert.match(service, /plannedEndAt: null/);
});
@@ -0,0 +1,83 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const root = process.cwd();
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
test('D5.6 preserves one visit one immutable act with a frozen closure snapshot', () => {
const acts = read('src/inspection-acts/inspection-acts.service.ts');
const closing = read('src/inspection-closing/inspection-closing.service.ts');
assert.match(acts, /INSPECTION_VISIT_ACT_ALREADY_EXISTS/);
assert.match(closing, /final_snapshot = \$2/);
assert.match(closing, /final_sha256 = \$3/);
assert.match(closing, /InspectionActVersionEvent\.CLOSED/);
});
test('D5.6 preserves findings responses evidence and verification history as non destructive follow-up', () => {
const findings = read('src/inspection-findings/inspection-findings.service.ts');
const evidence = read('src/inspection-findings/inspection-evidence.service.ts');
const verification = read('src/inspection-verifications/inspection-verifications.service.ts');
const ledger = read('src/inspection-verifications/verification-event-ledger.ts');
assert.match(findings, /INSPECTION_FINDING_RESPONSE_IMMUTABLE/);
assert.match(evidence, /immutable: true/);
assert.match(verification, /VERIFICATION_RESULT_IMMUTABLE/);
assert.match(ledger, /inspection_finding_verification_events/);
});
test('D5.6 keeps Inventory temporal context and contextual finding applicability independent and historical', () => {
const temporal = read('src/asset-master/asset-temporal.service.ts');
const assets = read('src/asset-master/assets.service.ts');
const findings = read('src/inspection-findings/inspection-findings.service.ts');
assert.match(temporal, /asset_context_history/);
assert.match(assets, /asset_context_history/);
assert.match(findings, /FINDING_CATALOG_ITEM_NOT_APPLICABLE/);
assert.match(findings, /finding_catalog_asset_overrides/);
});
test('D5.6 preserves reviewed web planning and APK only visit start', () => {
const visits = read('src/inspection-visits/inspection-visits.service.ts');
const policy = read('src/inspection-operations/mobile-inspector-policy.ts');
assert.match(visits, /INSPECTION_CHECKLIST_REQUIRED/);
assert.match(visits, /inspection_visit_checklist_items/);
assert.match(visits, /assertMobileInspector\(principal\)/);
assert.match(policy, /transport !== 'bearer'/);
assert.match(policy, /includes\('inspector'\)/);
});
test('D5.6 closes the act then freezes the report and produces its Word artifact', () => {
const closing = read('src/inspection-closing/inspection-closing.service.ts');
const word = read('src/inspection-reports/inspection-report-word.service.ts');
assert.match(closing, /ensureFrozenReport\(manager, actId, principal, request\)/);
assert.match(closing, /ensureWordForAct\(actId\)/);
assert.match(word, /buildInspectionReportWord/);
assert.match(word, /inspection_report_revisions/);
});
test('D5.6 leaves report revision approval and final signature exclusively under Director validation', () => {
const review = read('src/inspection-reports/inspection-report-review.service.ts');
assert.match(review, /await this\.assertDirector\(principal\.userId/);
assert.match(review, /review_status = 'APPROVED'/);
assert.match(review, /INSPECTION_REPORT_NOT_APPROVED/);
assert.match(review, /inspection_report_signatures/);
assert.match(review, /Director de Hidrocarburos/);
});
test('D5.6 preserves auditable document delivery while allowing SMTP to remain pending', () => {
const delivery = read('src/inspection-reports/inspection-document-delivery.service.ts');
const smtp = read('src/inspection-reports/smtp-delivery.service.ts');
assert.match(delivery, /WAITING_TRANSPORT/);
assert.match(delivery, /ACT_PDF/);
assert.match(delivery, /REPORT_WORD/);
assert.match(smtp, /SMTP_HOST/);
assert.match(smtp, /MAIL_FROM/);
});
test('D5.6 declares a stable Phase D closure across maintenance hotfixes without introducing another functional migration', () => {
const apiVersion = read('src/version.ts');
assert.match(apiVersion, /API_VERSION = '0\.19\.6(?:-\d+)?'/);
assert.match(apiVersion, /API_PHASE = 'D5\.6(?:\.\d+)?'/);
const migrations = fs.readdirSync(path.join(root, 'src/database/migrations'));
assert.equal(migrations.some((name) => /phase-d5-6/i.test(name)), false);
});
@@ -0,0 +1,18 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
import { SurveyExecutionController } from '../../src/survey-execution/survey-execution.controller';
function permissionFor(method: string): string[] {
const controller = SurveyExecutionController.prototype;
const handler = controller[method as keyof typeof controller];
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
}
test('survey report endpoints separate reading, capture and review', () => {
assert.deepEqual(permissionFor('get'), ['surveys.read_reports']);
assert.deepEqual(permissionFor('save'), ['surveys.capture']);
assert.deepEqual(permissionFor('submit'), ['surveys.capture']);
assert.deepEqual(permissionFor('review'), ['surveys.review']);
});
@@ -0,0 +1,48 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { validate } from 'class-validator';
import { ReviewSurveyReportDto } from '../../src/survey-execution/dto/review-survey-report.dto';
import { SaveSurveyReportDto } from '../../src/survey-execution/dto/save-survey-report.dto';
const MEDIA_ID = '16e54e65-60cf-4739-b0d1-ccdd904fbfd5';
test('field report DTO accepts outcome, GPS and protected media references', async () => {
const dto = Object.assign(new SaveSurveyReportDto(), {
outcome: 'CONFIRMED',
observedAt: '2026-08-14T18:00:00.000Z',
latitude: -32.8895,
longitude: -68.8458,
accuracyM: 4.25,
notes: 'Activo verificado en campo.',
mediaIds: [MEDIA_ID],
});
assert.deepEqual(await validate(dto), []);
});
test('field report DTO rejects invalid coordinates and repeated evidence', async () => {
const dto = Object.assign(new SaveSurveyReportDto(), {
outcome: 'CONFIRMED',
observedAt: 'not-a-date',
latitude: -95,
longitude: -68.8458,
accuracyM: 4.25,
mediaIds: [MEDIA_ID, MEDIA_ID],
});
const properties = new Set((await validate(dto)).map((error) => error.property));
assert.equal(properties.has('observedAt'), true);
assert.equal(properties.has('latitude'), true);
assert.equal(properties.has('mediaIds'), true);
});
test('review DTO only accepts explicit approval or rejection decisions', async () => {
const dto = Object.assign(new ReviewSurveyReportDto(), {
decision: 'APPROVE',
notes: null,
});
assert.deepEqual(await validate(dto), []);
dto.decision = 'MAYBE' as typeof dto.decision;
const properties = new Set((await validate(dto)).map((error) => error.property));
assert.equal(properties.has('decision'), true);
});
@@ -0,0 +1,31 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
import {
SurveyCampaignsController,
SurveyCampaignTargetsController,
} from '../../src/survey-planning/survey-campaigns.controller';
function permissionFor(controller: object, method: string): string[] {
const handler = controller[method as keyof typeof controller];
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
}
test('campaign endpoints separate reading, management and assignment', () => {
const controller = SurveyCampaignsController.prototype;
assert.deepEqual(permissionFor(controller, 'list'), ['surveys.read']);
assert.deepEqual(permissionFor(controller, 'get'), ['surveys.read']);
assert.deepEqual(permissionFor(controller, 'assignees'), ['surveys.assign']);
assert.deepEqual(permissionFor(controller, 'create'), ['surveys.manage']);
assert.deepEqual(permissionFor(controller, 'update'), ['surveys.manage']);
assert.deepEqual(permissionFor(controller, 'changeStatus'), ['surveys.manage']);
assert.deepEqual(permissionFor(controller, 'addTarget'), ['surveys.manage']);
});
test('target planning, assignment and execution use distinct permissions', () => {
const controller = SurveyCampaignTargetsController.prototype;
assert.deepEqual(permissionFor(controller, 'update'), ['surveys.manage']);
assert.deepEqual(permissionFor(controller, 'assign'), ['surveys.assign']);
assert.deepEqual(permissionFor(controller, 'changeStatus'), ['surveys.execute']);
});
@@ -0,0 +1,35 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { validate } from 'class-validator';
import { AddSurveyTargetDto } from '../../src/survey-planning/dto/add-survey-target.dto';
import { CreateSurveyCampaignDto } from '../../src/survey-planning/dto/create-survey-campaign.dto';
const ASSET_ID = '16e54e65-60cf-4739-b0d1-ccdd904fbfd5';
test('campaign DTO accepts dynamic asset scope and ISO planning dates', async () => {
const dto = Object.assign(new CreateSurveyCampaignDto(), {
code: 'REL-2026-001',
name: 'Campaña operativa',
description: null,
plannedStartAt: '2026-08-15T12:00:00.000Z',
plannedEndAt: '2026-08-20T12:00:00.000Z',
scopeAssetId: ASSET_ID,
coordinatorUserId: null,
});
assert.deepEqual(await validate(dto), []);
});
test('target DTO references an existing Maestro asset instead of copying it', async () => {
const dto = Object.assign(new AddSurveyTargetDto(), {
assetId: ASSET_ID,
assignedUserId: null,
dueAt: null,
instructions: null,
});
assert.deepEqual(await validate(dto), []);
dto.assetId = 'not-a-uuid';
const properties = new Set((await validate(dto)).map((error) => error.property));
assert.equal(properties.has('assetId'), true);
});
+53
View File
@@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JwtService } from '@nestjs/jwt';
import { AuthConfigService } from '../../src/common/config/auth-config.service';
import { TokenService } from '../../src/auth/services/token.service';
function config(): AuthConfigService {
return {
accessTokenSecret: 'a'.repeat(64),
refreshTokenPepper: 'b'.repeat(64),
accessTokenTtlSeconds: 900,
refreshTokenTtlSeconds: 604800,
maxLoginAttempts: 5,
lockoutSeconds: 900,
webOrigin: 'https://dhv2.korexlabs.com',
accessCookieName: 'dhv2_access',
refreshCookieName: 'dhv2_refresh',
csrfCookieName: 'dhv2_csrf',
} as AuthConfigService;
}
test('TokenService issues and verifies access tokens', async () => {
const service = new TokenService(new JwtService(), config());
const token = await service.issueAccessToken({
userId: 'b6f6b10e-434a-4d72-9ec9-3520212dd290',
sessionId: '1bab61ac-65d1-40e6-816b-63d90131a795',
username: 'admin',
});
const payload = await service.verifyAccessToken(token);
assert.equal(payload.sub, 'b6f6b10e-434a-4d72-9ec9-3520212dd290');
assert.equal(payload.sid, '1bab61ac-65d1-40e6-816b-63d90131a795');
assert.equal(payload.typ, 'access');
});
test('TokenService rotates opaque refresh credentials without storing plaintext', () => {
const service = new TokenService(new JwtService(), config());
const issued = service.issueRefreshToken();
assert.deepEqual(service.parseRefreshToken(issued.token), {
sessionId: issued.sessionId,
});
assert.equal(service.verifyRefreshToken(issued.token, issued.tokenHash), true);
const replacement = issued.token.endsWith('x') ? 'y' : 'x';
assert.equal(
service.verifyRefreshToken(
`${issued.token.slice(0, -1)}${replacement}`,
issued.tokenHash,
),
false,
);
assert.equal(issued.tokenHash.includes(issued.token), false);
});