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'; import type { HtmlPdfService } from './html-pdf.service.js'; 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()); vi.mock('./html-reports.js', async (orig) => ({ ...(await orig()), HTML_REPORTS: registry })); const dir = mkdtempSync(join(tmpdir(), 'plantillas-')); mkdirSync(join(dir, 'c')); writeFileSync(join(dir, 'c', 'etiquetas.njk'), '

{{ cliente }}

{% for n in numeros %}{{ n }}/{{ params.cantidad }}{% endfor %}'); writeFileSync(join(dir, 'c', 'f.ttf'), 'FUENTE'); writeFileSync(join(dir, 'secreto.txt'), 'no'); afterAll(() => rmSync(dir, { recursive: true, force: true })); const P = (over: Partial & Pick): 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/); expect(() => validateHtmlReports([{ ...def, params: [P({ name: 'papel', readOnly: true })] }])).toThrow(/solo lectura y necesita un valor por defecto/); expect(validateHtmlReports([def])).toHaveLength(1); }); }); describe('valores de los parámetros', () => { 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' }); }); 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: 'ACME & Cía', numeros: [1, 2], params: { cantidad: '2' } }); expect(html).toContain('

<b>ACME & Cía</b>

'); expect(html).toContain('1/22/2'); }); 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); const toPdf = vi.fn(async (html: string) => Buffer.from(`%PDF ${html.length}`)); 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, { toPdf } as unknown as HtmlPdfService, ); beforeEach(() => { paths = ['*']; run.mockClear(); record.mockClear(); toPdf.mockClear(); }); 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('

ACME

'); expect(html).toContain('3/3'); expect(run).toHaveBeenCalledWith(user, 'guia', { docEntry: 144153 }, '10.0.0.5'); 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('

ACME

')); 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(); }); 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' })); }); });