Files
Investidor-V2/tests/frontend/project-dossier-report.test.mjs
T
2026-10-21 08:43:12 +02:00

328 lines
13 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import JSZip from 'jszip';
import { serializeFinancialToolsSnapshot } from '../../resources/js/lib/financial-calculators.ts';
import {
createProjectDossierPrintHtml,
createProjectDossierReport,
dossierReportSections,
generateProjectDossierDocx,
generateProjectDossierPackage,
classifyDossierAttachment,
validateDossierAttachments,
} from '../../resources/js/lib/project-dossier-report.ts';
test('executive summary exposes the same financial result and period without replacing missing data by zero', () => {
const draft = { currency:'MZN', financial_tools_snapshot:serializeFinancialToolsSnapshot({budgetPeriod:'Mensal',loan:{principal:'1200',rate:'0',months:'12',rateType:'nominal',periodMonths:'1',gracePeriods:'0',upfrontFee:'0',recurringFee:'0'},breakEven:{fixed:'100.01',price:'30',variable:'20'},funding:{total:'1000',secured:'800'},incomes:[{label:'Receita QA',amount:'1000'}],expenses:[{label:'Despesa QA',amount:'1100'}]}) };
const report = createProjectDossierReport(draft,{selectedSections:new Set(['summary','finance'])});
const summary=report.sections.find(s=>s.id==='summary');
const detail=report.sections.find(s=>s.id==='finance');
assert.equal(summary.rows.find(r=>r.field.startsWith('Saldo operacional')).value,detail.rows.find(r=>r.field==='Saldo operacional estimado').value);
assert.match(summary.rows.find(r=>r.field.startsWith('Saldo operacional')).field,/Mensal/);
const missing=createProjectDossierReport({}, {selectedSections:new Set(['summary'])});
assert.equal(missing.sections[0].rows.some(r=>r.field.startsWith('Saldo operacional')),false);
});
import {
serializeBusinessPlanSnapshot,
forecastBusinessPlan,
} from '../../resources/js/lib/business-financial-plan.ts';
const allSections = new Set(dossierReportSections.map((section) => section.id));
function simplePlan() {
return {
currency: 'MZN',
openingCash: '0',
products: [
{
label: 'Pão',
monthlyUnits: '100',
unitPrice: '50',
unitVariableCost: '20',
},
],
monthlyCollections: '100',
monthlyVariableCostPayments: '100',
monthlyOperatingExpenses: '500',
monthlyOperatingPayments: '500',
monthlyPayrollExpense: '0',
monthlyPayrollPayments: '0',
assetInvestment: '0',
inventory: '0',
receivables: '0',
payables: '0',
equity: '10000',
shareholderLoans: '0',
bankLoanProceeds: '0',
grants: '0',
assetDepreciation: '0',
depreciationLifeMonths: 12,
monthlyInterestExpense: '0',
monthlyInterestPayment: '0',
monthlyPrincipalRepayment: '0',
periods: 12,
};
}
void test('incomplete dossier identifies unknowns without inventing zeroes', () => {
const report = createProjectDossierReport(
{},
{
selectedSections: allSections,
generatedAt: '2026-10-04T10:00:00.000Z',
},
);
assert.equal(report.title, 'Não informado');
assert.equal(
report.sections.find((section) => section.id === 'summary').rows[0]
.value,
'Não informado',
);
assert.match(
report.sections.find((section) => section.id === 'resources').rows[0]
.value,
/Informação insuficiente/,
);
assert.doesNotMatch(JSON.stringify(report), /"value":"0(\.00)?"/);
});
void test('report sections are selected explicitly and preserve declared versus estimated provenance', () => {
const selected = new Set(['summary', 'market', 'sources']);
const report = createProjectDossierReport(
{
project_title: 'Moagem',
market_assumption: 'procura crescente',
market_source: 'entrevista própria; 2026-09-01',
},
{ selectedSections: selected, version: 'Revisão 3' },
);
assert.deepEqual(
report.sections.map((section) => section.id),
['summary', 'market', 'sources'],
);
const market = report.sections.find((section) => section.id === 'market');
assert.ok(
market.rows.some(
(row) =>
row.value === 'procura crescente' &&
row.classification === 'Informação declarada, não verificada',
),
);
assert.equal(report.version, 'Revisão 3');
});
void test('business plan values in the dossier equal the existing simulator forecast', () => {
const plan = simplePlan();
const expected = forecastBusinessPlan(plan);
const snapshot = serializeBusinessPlanSnapshot(plan);
const report = createProjectDossierReport(
{
project_title: 'Padaria',
currency: 'MZN',
business_plan_snapshot: snapshot,
},
{ selectedSections: allSections },
);
const finance = report.sections.find((section) => section.id === 'finance');
const row = (field) =>
finance.rows.find((item) => item.field === field).value;
const numeric = (value) => {
assert.match(value, /MZN$/);
return value.replace(/\s|MZN/g, '').replace(',', '.');
};
assert.equal(
numeric(row('Facturação no horizonte')),
expected.totalRevenue,
);
assert.equal(
numeric(row('Resultado antes de impostos')),
expected.totalProfitBeforeTax,
);
assert.equal(numeric(row('Caixa final projectada')), expected.endingCash);
assert.equal(expected.fundingStatus, 'surplus');
assert.equal(row('Reconciliação do financiamento'), 'Fontes superiores aos usos previstos');
});
void test('an attached simulator execution keeps its exact snapshot result, explanation and version', () => {
const report = createProjectDossierReport(
{ project_title: 'Pequena indústria' },
{
selectedSections: allSections,
currentCalculation: {
status: 'completed',
version: 4,
updatedAt: '2026-10-03T12:00:00Z',
reportChecksum: 'a'.repeat(64),
snapshot: {
contract_version: 1,
indicative_total: '1250.75',
formula_version: 'v2',
base: '2000',
rate_percent: '75',
reduction_percent: '16.6',
currency: 'MZN',
context: {
reference_date: '2026-10-03',
engine_version: 'engine-4',
scenario_version: 4,
},
result: {
status: 'calculated',
value: '1250.75',
unit: null,
explanation: 'Cálculo indicativo guardado.',
missing_inputs: [],
source_ids: [],
},
},
},
},
);
const finance = report.sections.find((section) => section.id === 'finance');
assert.equal(
finance.rows.find((row) => row.field === 'Resultado indicativo').value,
'1250.75',
);
assert.equal(
finance.rows.find((row) => row.field === 'Explicação e dados em falta')
.value,
'Cálculo indicativo guardado.',
);
assert.equal(
finance.rows.find((row) => row.field === 'Revisão do resultado').value,
'4',
);
assert.equal(
finance.rows.find(
(row) => row.field === 'Hash SHA-256 do relatório associado',
).value,
'a'.repeat(64),
);
});
void test('DOCX is a readable OpenXML package with Portuguese accents, tables and page fields', async () => {
const report = createProjectDossierReport(
{
project_title: 'Produção de café',
project_summary: 'Ideia com acção e localização.',
},
{ selectedSections: allSections, version: 'Revisão 2' },
);
const blob = await generateProjectDossierDocx(report, [], true);
const bytes = new Uint8Array(await blob.arrayBuffer());
assert.deepEqual(Array.from(bytes.subarray(0, 2)), [0x50, 0x4b]);
const zip = await JSZip.loadAsync(bytes);
const documentXml = await zip.file('word/document.xml').async('string');
const footerXml = await zip.file('word/footer1.xml').async('string');
assert.match(documentXml, /Produção de café/);
assert.match(documentXml, /Classificação/);
assert.match(documentXml, /projecto/);
assert.match(footerXml, /PAGE/);
assert.ok(zip.file('[Content_Types].xml'));
});
void test('CSV and plain-text annexes are embedded, while other originals get a ZIP index', async () => {
const csv = new File(['Produto;Quantidade\r\nCafé;12'], 'tabela.csv', {
type: 'text/csv',
});
const pdf = new File(['%PDF-1.7 example'], '../apoio.pdf', {
type: 'application/pdf',
});
assert.equal(classifyDossierAttachment(csv), 'csv');
assert.equal(classifyDossierAttachment(pdf), 'original-only');
const attachments = [
{ file: csv, include: true, caption: '' },
{ file: pdf, include: true, caption: '' },
];
const report = createProjectDossierReport(
{ project_title: 'Projecto de café' },
{ selectedSections: allSections },
);
const docx = await generateProjectDossierDocx(report, attachments, true);
const docxZip = await JSZip.loadAsync(await docx.arrayBuffer());
const xml = await docxZip.file('word/document.xml').async('string');
assert.match(xml, /Café/);
assert.match(xml, /Este formato não foi incorporado/);
const bundle = await generateProjectDossierPackage(docx, attachments, true);
const bundleZip = await JSZip.loadAsync(await bundle.arrayBuffer());
assert.ok(bundleZip.file('Anexos/tabela.csv'));
assert.ok(bundleZip.file('Anexos/apoio.pdf'));
const index = await bundleZip.file('indice-anexos.txt').async('string');
assert.match(index, /Conteúdo não incorporado/);
assert.match(index, /Conteúdo não incorporado; original preservado/);
});
void test('PNG photographs are embedded in the Word package with an image relationship and caption', async () => {
const pngBytes = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/aZkAAAAASUVORK5CYII=',
'base64',
);
const photo = new File([pngBytes], 'local.jpg', { type: 'image/png' });
const report = createProjectDossierReport(
{ project_title: 'Projecto com fotografia' },
{ selectedSections: allSections },
);
const docx = await generateProjectDossierDocx(
report,
[{ file: photo, include: true, caption: 'Localização declarada' }],
true,
);
const zip = await JSZip.loadAsync(await docx.arrayBuffer());
const mediaPath = Object.keys(zip.files).find((path) =>
/^word\/media\/.+\.png$/.test(path),
);
assert.ok(mediaPath);
assert.match(
await zip.file('word/document.xml').async('string'),
/Localização declarada/,
);
assert.match(
await zip.file('word/_rels/document.xml.rels').async('string'),
new RegExp(`Target="media/${mediaPath.split('/').at(-1)}"`),
);
});
void test('print/PDF HTML escapes user content and explains omitted annexes', async () => {
const report = createProjectDossierReport(
{
project_title: '<script>alert(1)</script>',
project_summary: 'Acentuação: localização.',
},
{ selectedSections: new Set(['summary', 'annexes']) },
);
const pdf = await createProjectDossierPrintHtml(report, [], false);
assert.doesNotMatch(pdf, /<script>alert\(1\)<\/script>/);
assert.match(pdf, /&lt;script&gt;/);
assert.match(pdf, /não foram seleccionados|secção de anexos foi omitida/);
assert.match(pdf, /@page\{size:A4/);
assert.match(pdf, /@bottom-left\{content:/);
assert.match(pdf, /@bottom-right\{content:counter\(page\)/);
assert.doesNotMatch(pdf, /position:fixed|bottom:-14mm/);
const escapedVersion = await createProjectDossierPrintHtml(
{ ...report, version: 'Revisão "QA" </style><script>alert(2)</script>' },
[],
false,
);
assert.doesNotMatch(escapedVersion, /<script>alert\(2\)<\/script>/);
});
void test('attachment limits block oversized or excessive selections', () => {
assert.equal(validateDossierAttachments([{ size: 1 }]), null);
assert.match(
validateDossierAttachments(
Array.from({ length: 11 }, () => ({ size: 1 })),
),
/máximo 10/,
);
assert.match(
validateDossierAttachments([{ size: 15 * 1024 * 1024 + 1 }]),
/15 MB/,
);
assert.match(
validateDossierAttachments(
Array.from({ length: 4 }, () => ({ size: 15 * 1024 * 1024 })),
),
/50 MB/,
);
});