2026-10-20 16:20:53 -05:00
import { mkdtempSync , mkdirSync , rmSync , writeFileSync } from 'node:fs' ;
import { tmpdir } from 'node:os' ;
import { join } from 'node:path' ;
import { BadRequestException , ForbiddenException , NotFoundException } from '@nestjs/common' ;
import type { GatewayParam } from '@basic-erp/sap-types' ;
import { vi } from 'vitest' ;
import type { AuditService } from '../audit/audit.service.js' ;
import type { AuthUser } from '../auth/auth.types.js' ;
import type { AppConfigService } from '../config/app-config.service.js' ;
import type { PermissionsService } from '../permissions/permissions.service.js' ;
import type { SapDbService } from '../sapdb/sapdb.service.js' ;
2026-10-20 16:53:24 -05:00
import type { HtmlPdfService } from './html-pdf.service.js' ;
2026-10-20 16:20:53 -05:00
import { createRenderer } from './html-render.js' ;
import { validateHtmlReports } from './html-reports.js' ;
import type { HtmlReportDef } from './html-reports.types.js' ;
// el registro que usa el servicio (el de `main` está vacío): se llena con el reporte de prueba
const registry = vi . hoisted (() => new Map < string , unknown >());
vi . mock ( './html-reports.js' , async ( orig ) => ({ ...( await orig < typeof import ( './html-reports.js' ) >()), HTML_REPORTS : registry }));
const dir = mkdtempSync ( join ( tmpdir (), 'plantillas-' ));
mkdirSync ( join ( dir , 'c' ));
writeFileSync ( join ( dir , 'c' , 'etiquetas.njk' ), '<p>{{ cliente }}</p>{% for n in numeros %}<i>{{ n }}/{{ params.cantidad }}</i>{% endfor %}<style>@font-face{src:url("{{ asset(\'c/f.ttf\') }}")}</style>' );
writeFileSync ( join ( dir , 'c' , 'f.ttf' ), 'FUENTE' );
writeFileSync ( join ( dir , 'secreto.txt' ), 'no' );
afterAll (() => rmSync ( dir , { recursive : true , force : true }));
const P = ( over : Partial < GatewayParam > & Pick < GatewayParam , 'name' >) : GatewayParam => ({ type : 'xsd:string' , label : over.name , kind : 'text' , required : false , ... over });
const def : HtmlReportDef = {
code : 'etiquetas' ,
title : 'Etiquetas' ,
category : 'Ventas' ,
params : [ P ({ name : 'doc' , label : 'Entrega' , kind : 'lookup' , required : true , lookup : 'doc:DeliveryNotes' }), P ({ name : 'cantidad' , label : 'Cantidad' , kind : 'number' , required : true }), P ({ name : 'papel' , label : 'Papel' , kind : 'select' , options : [{ value : 'Carta' , label : 'Carta' }, { value : 'A4' , label : 'A4' }], defaultValue : 'Carta' }), P ({ name : 'fecha' , label : 'Fecha' , kind : 'date' })],
template : 'c/etiquetas.njk' ,
data : async ( v , ctx ) => {
const rows = await ctx . query ( 'guia' , { docEntry : Number ( v [ 'doc' ]) });
return { cliente : String ( rows [ 0 ] ? .[ 'CardName' ]), numeros : Array.from ({ length : Number ( v [ 'cantidad' ]) }, ( _ , i ) => i + 1 ) };
},
};
registry . set ( def . code , def );
const { HtmlReportsService , parseHtmlValues } = await import ( './html-reports.service.js' );
describe ( 'registro de reportes HTML' , () => {
it ( 'rechaza códigos repetidos o inválidos, parámetros repetidos y plantillas fuera de la carpeta' , () => {
expect (() => validateHtmlReports ([ def , def ])). toThrow ( /repetido/ );
expect (() => validateHtmlReports ([{ ... def , code : 'Mal Código' }])). toThrow ( /inválido/ );
expect (() => validateHtmlReports ([{ ... def , params : [ P ({ name : 'a' }), P ({ name : 'a' })] }])). toThrow ( /repite el parámetro/ );
expect (() => validateHtmlReports ([{ ... def , template : '../fuera.njk' }])). toThrow ( /dentro de la carpeta/ );
expect (() => validateHtmlReports ([{ ... def , template : '/etc/passwd' }])). toThrow ( /dentro de la carpeta/ );
2026-10-20 16:57:33 -05:00
expect (() => validateHtmlReports ([{ ... def , params : [ P ({ name : 'papel' , readOnly : true })] }])). toThrow ( /solo lectura y necesita un valor por defecto/ );
2026-10-20 16:20:53 -05:00
expect ( validateHtmlReports ([ def ])). toHaveLength ( 1 );
});
});
describe ( 'valores de los parámetros' , () => {
2026-10-20 16:57:33 -05:00
it ( 'un parámetro de solo lectura vale siempre su valor por defecto, aunque el cliente mande otro' , () => {
const fijo = { ... def , params : [ P ({ name : 'papel' , label : 'Papel' , kind : 'select' , options : [{ value : 'Carta' , label : 'Carta' }, { value : 'A4' , label : 'A4' }], defaultValue : 'Carta' , readOnly : true })] };
expect ( parseHtmlValues ( fijo , {})). toEqual ({ papel : 'Carta' });
expect ( parseHtmlValues ( fijo , { papel : 'A4' })). toEqual ({ papel : 'Carta' });
});
2026-10-20 16:20:53 -05:00
it ( 'exige los obligatorios, aplica el valor por defecto y comprueba tipos' , () => {
expect ( parseHtmlValues ( def , { doc : '144153' , cantidad : 6 })). toEqual ({ doc : '144153' , cantidad : '6' , papel : 'Carta' , fecha : '' });
expect (() => parseHtmlValues ( def , { cantidad : '6' })). toThrow ( /Falta indicar: Entrega/ );
expect (() => parseHtmlValues ( def , { doc : '1' , cantidad : 'x' })). toThrow ( /debe ser un número/ );
expect (() => parseHtmlValues ( def , { doc : '1' , cantidad : '2' , fecha : '20/10/2026' })). toThrow ( /yyyy-MM-dd/ );
expect (() => parseHtmlValues ( def , { doc : '1' , cantidad : '2' , papel : 'Oficio' })). toThrow ( /opción válida/ );
expect (() => parseHtmlValues ( def , { doc : '1' , cantidad : '2' , otro : 'x' })). toThrow ( /desconocidos/ );
});
});
describe ( 'plantillas' , () => {
const r = createRenderer ( dir );
it ( 'rellena la plantilla y escapa el HTML de los valores' , () => {
const html = r . render ( 'c/etiquetas.njk' , { cliente : '<b>ACME & Cía</b>' , numeros : [ 1 , 2 ], params : { cantidad : '2' } });
expect ( html ). toContain ( '<p><b>ACME & Cía</b></p>' );
expect ( html ). toContain ( '<i>1/2</i><i>2/2</i>' );
});
it ( 'asset() entrega un archivo de la carpeta como data: URI' , () => {
expect ( r . render ( 'c/etiquetas.njk' , { cliente : 'x' , numeros : [], params : {} })). toContain ( `data:font/ttf;base64, ${ Buffer . from ( 'FUENTE' ). toString ( 'base64' ) } ` );
});
it ( 'asset() no sale de la carpeta de plantillas ni entrega tipos que no son de recursos' , () => {
writeFileSync ( join ( dir , 'malo.njk' ), "{{ asset('../etc/passwd') }}" );
writeFileSync ( join ( dir , 'malo2.njk' ), "{{ asset('secreto.txt') }}" );
expect (() => r . render ( 'malo.njk' , {})). toThrow ( /No existe el archivo/ );
expect (() => r . render ( 'malo2.njk' , {})). toThrow ( /Tipo de archivo no admitido/ );
});
});
describe ( 'servicio de reportes HTML' , () => {
const user : AuthUser = { webUser : 'ana' , empId : 7 , firstName : 'Ana' , lastName : 'P' };
let paths : string [];
const run = vi . fn ( async () => ({ rows : [{ CardName : 'ACME' }], truncated : false }));
const record = vi . fn ( async () => undefined );
2026-10-20 16:53:24 -05:00
const toPdf = vi . fn ( async ( html : string ) => Buffer . from ( `%PDF ${ html . length } ` ));
2026-10-20 16:20:53 -05:00
const service = () =>
new HtmlReportsService (
{ get : () => dir } as unknown as AppConfigService ,
{ getPaths : async () => paths } as unknown as PermissionsService ,
{ record } as unknown as AuditService ,
{ run } as unknown as SapDbService ,
2026-10-20 16:53:24 -05:00
{ toPdf } as unknown as HtmlPdfService ,
2026-10-20 16:20:53 -05:00
);
beforeEach (() => {
paths = [ '*' ];
run . mockClear ();
record . mockClear ();
2026-10-20 16:53:24 -05:00
toPdf . mockClear ();
2026-10-20 16:20:53 -05:00
});
it ( 'lista los reportes con sus parámetros, sin la plantilla ni el código de datos' , async () => {
const list = await service (). list ( user );
expect ( list ). toEqual ([{ code : 'etiquetas' , title : 'Etiquetas' , category : 'Ventas' , enabled : true , params : def.params }]);
expect ( list [ 0 ]). not . toHaveProperty ( 'template' );
});
it ( 'genera el HTML con los datos de la consulta y audita solo los nombres de los parámetros' , async () => {
const html = await service (). render ( user , 'etiquetas' , { doc : '144153' , cantidad : '3' }, '10.0.0.5' );
expect ( html ). toContain ( '<p>ACME</p>' );
expect ( html ). toContain ( '<i>3/3</i>' );
expect ( run ). toHaveBeenCalledWith ( user , 'guia' , { docEntry : 144153 }, '10.0.0.5' );
2026-10-20 16:53:24 -05:00
expect ( record ). toHaveBeenCalledWith ( expect . objectContaining ({ action : 'html_report' , resource : 'etiquetas' , detail : { format : 'html' , params : [ 'doc' , 'cantidad' , 'papel' , 'fecha' ] } }));
});
it ( 'el PDF pasa el mismo HTML por Chrome y se audita como pdf' , async () => {
const pdf = await service (). pdf ( user , 'etiquetas' , { doc : '144153' , cantidad : '3' }, '10.0.0.5' );
expect ( pdf . subarray ( 0 , 4 ). toString ()). toBe ( '%PDF' );
expect ( toPdf ). toHaveBeenCalledWith ( expect . stringContaining ( '<p>ACME</p>' ));
expect ( record ). toHaveBeenCalledWith ( expect . objectContaining ({ detail : expect.objectContaining ({ format : 'pdf' }) }));
});
it ( 'el PDF exige los mismos permisos y parámetros que el HTML' , async () => {
paths = [ '/ventas/*' ];
await expect ( service (). pdf ( user , 'etiquetas' , { doc : '1' , cantidad : '1' }, '' )). rejects . toThrow ( ForbiddenException );
paths = [ '*' ];
await expect ( service (). pdf ( user , 'etiquetas' , { doc : '1' }, '' )). rejects . toThrow ( BadRequestException );
expect ( toPdf ). not . toHaveBeenCalled ();
2026-10-20 16:20:53 -05:00
});
it ( 'sin el permiso de menú no se lista ni se ejecuta; un reporte que no existe da 404; un parámetro mal puesto, 400' , async () => {
paths = [ '/ventas/*' ];
await expect ( service (). list ( user )). rejects . toThrow ( ForbiddenException );
await expect ( service (). render ( user , 'etiquetas' , { doc : '1' , cantidad : '1' }, '' )). rejects . toThrow ( ForbiddenException );
paths = [ '/reportes-html/ejecutar' ];
await expect ( service (). render ( user , 'nada' , {}, '' )). rejects . toThrow ( NotFoundException );
await expect ( service (). render ( user , 'etiquetas' , { doc : '1' }, '' )). rejects . toThrow ( BadRequestException );
expect ( run ). not . toHaveBeenCalled ();
});
it ( 'un error al armar los datos se audita y se propaga' , async () => {
run . mockRejectedValueOnce ( new Error ( 'tabla inexistente' ));
await expect ( service (). render ( user , 'etiquetas' , { doc : '1' , cantidad : '1' }, '' )). rejects . toThrow ( 'tabla inexistente' );
expect ( record ). toHaveBeenCalledWith ( expect . objectContaining ({ outcome : 'error' }));
});
});