Group investor reports and preserved financial copies in their existing dossier
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-11-13 14:09:52 +02:00
parent bb6824d75d
commit 07182c7c73
12 changed files with 1211 additions and 1 deletions
@@ -71,6 +71,62 @@ class InvestmentPreparationWorkspaceTest extends TestCase
return $this->postJson('/investimentos/'.$this->caseId.'/preparacao', ['version' => $view['version'], 'operation_key' => (string) Str::uuid(), 'reason' => 'SYNTHETIC explicit review, not real external action', 'action' => $action, ...$data])->assertOk()->json();
}
public function test_dossier_reports_recover_preserved_exports_and_keep_financial_sources_private(): void
{
$view = $this->workspace();
$view = $this->action($view, 'apiex_review', ['fingerprint' => $view['api_fingerprint']]);
$view = $this->action($view, 'apiex_export');
$export = $view['apiex']['exports'][0]['id'];
$draft = (string) Str::uuid(); $report = (string) Str::uuid(); $simulation = (string) Str::uuid();
$source = ['simulation_id' => $simulation, 'report_id' => $report, 'report_version_no' => 2, 'report_sha256' => str_repeat('a', 64), 'report_snapshot' => ['private' => 'SYNTHETIC NEVER EXPOSED'], 'consented_at' => now()->toIso8601String()];
$otherReport = (string) Str::uuid();
$otherSource = [...$source, 'report_id' => $otherReport, 'association_opportunity' => 'other-opportunity'];
\App\Models\SimulationFormDraft::create(['id' => $draft, 'owner_user_id' => $this->owner->id,
'create_key_hash' => hash('sha256', $draft), 'create_payload_hash' => hash('sha256', $draft), 'snapshot_sha256' => hash('sha256', $draft),
'personal_snapshot' => \App\Models\SimulationFormDraft::encryptSnapshot([]),
'economic_snapshot' => \App\Models\SimulationFormDraft::encryptSnapshot(['purpose' => 'request_support', 'simulation_sources' => [$source, $otherSource]])]);
DB::table('desk_cases')->where('id', $this->caseId)->update(['simulation_id' => $draft]);
$land = \App\Models\LandAssistanceCase::create(['user_id' => $this->owner->id, 'reference' => 'QA-'.Str::uuid(), 'crm_submission_key' => (string) Str::uuid(),
'version' => 3, 'payload' => ['investor_path_case_id' => $this->caseId]]);
$before = DB::table('simulation_form_drafts')->where('id', $draft)->first();
$data = $this->getJson('/investimentos/'.$this->caseId)->assertOk()->json();
$this->assertSame($this->caseId, $data['preparation_report']['case_id']);
$this->assertSame($export, $data['preparation_report']['exports'][0]['id']);
$this->assertStringContainsString('/investimentos/'.$this->caseId.'/preparacao/exportar/'.$export, $data['preparation_report']['exports'][0]['href']);
$this->assertSame($report, $data['fiscal_reports'][0]['report_id']);
$this->assertCount(1, $data['fiscal_reports']);
$this->assertSame('/investidor/terra-duat/'.$land->id.'/imprimir', $data['land_report']['href']);
$this->assertStringNotContainsString('SYNTHETIC NEVER EXPOSED', json_encode($data, JSON_THROW_ON_ERROR));
$download = $this->get('/investimentos/'.$this->caseId.'/relatorios/fiscais/'.$report)->assertOk()
->assertHeader('Cache-Control', 'no-store, private')->assertJsonPath('case_id', $this->caseId)->assertJsonPath('report_snapshot.private', 'SYNTHETIC NEVER EXPOSED')->assertJsonPath('scope_state', 'unconfirmed');
$this->assertSame($download->getContent(), $this->get('/investimentos/'.$this->caseId.'/relatorios/fiscais/'.$report)->assertOk()->getContent());
$this->get('/investimentos/'.$this->caseId.'/relatorios/fiscais/'.Str::uuid())->assertNotFound();
$this->get('/investimentos/'.$this->caseId.'/relatorios/fiscais/'.$otherReport)->assertNotFound();
$this->assertEquals($before, DB::table('simulation_form_drafts')->where('id', $draft)->first());
$this->getJson('/investimentos/'.$this->caseId.'?opportunity=other-opportunity')->assertUnprocessable();
$this->getJson('/investimentos/'.$this->caseId)->assertOk()->assertJsonPath('preparation_report.exports.0.id', $export)->assertJsonPath('fiscal_reports.0.report_id', $report);
$drafts = app(\App\Services\SimulationFormDrafts::class);
$current = \App\Models\SimulationFormDraft::findOrFail($draft);
$request = \Illuminate\Http\Request::create('/api/v1/simulation-drafts/'.$draft.'/simulations', 'POST');
$request->setUserResolver(fn () => $this->owner);
$request->headers->set('If-Match', $drafts->etag($current));
$newSource = [...$source, 'simulation_id' => (string) Str::uuid(), 'report_id' => (string) Str::uuid()];
\App\Models\Simulation::create(['id' => $newSource['simulation_id'], 'owner_user_id' => $this->owner->id, 'status' => 'completed', 'input_snapshot' => ['synthetic' => true]]);
$attached = $drafts->attachSimulation($draft, $newSource, $request);
$saved = \App\Models\SimulationFormDraft::decryptSnapshot($attached['draft']->economic_snapshot)['simulation_sources'];
$this->assertSame($source, $saved[0]);
$this->assertArrayHasKey('association_opportunity', $saved[2]);
$this->assertNull($saved[2]['association_opportunity']);
$this->assertTrue($drafts->attachSimulation($draft, $newSource, $request)['replayed']);
config(['investor-path.enabled' => false]);
$this->get('/investimentos/'.$this->caseId.'/relatorios/fiscais/'.$report)->assertNotFound();
config(['investor-path.enabled' => true]);
$manager = User::factory()->create(); config(['portal.managers' => [$manager->email]]);
$this->actingAs($manager)->getJson('/investimentos/'.$this->caseId)->assertOk()->assertJsonPath('fiscal_reports', [])->assertJsonPath('results', [])->assertJsonPath('land_report', null);
$this->get('/investimentos/'.$this->caseId.'/relatorios/fiscais/'.$report)->assertForbidden();
$this->actingAs(User::factory()->create())->getJson('/investimentos/'.$this->caseId)->assertForbidden();
}
public function test_apiex_existing_dossier_requirements_review_zip_and_external_delivery_without_submission_or_approval(): void
{
$before = DB::table('journey_steps')->orderBy('id')->get()->toJson();
@@ -0,0 +1,311 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import JSZip from 'jszip';
import {
createInvestorDossierReport,
investorDossierReportIndex,
} from '../../resources/js/lib/investor-dossier-report.ts';
import {
createProjectDossierPrintHtml,
generateProjectDossierDocx,
} from '../../resources/js/lib/project-dossier-report.ts';
import { serializeFinancialToolsSnapshot } from '../../resources/js/lib/financial-calculators.ts';
function fixture() {
return {
case: {
id: 'case-a',
title: 'Investimento QA <script>alert(1)</script>',
version: 9,
},
context_opportunity: 'op-a',
opportunity_options: ['op-a', 'op-b'],
investment_context: {
revision: 3,
fields: { project_title: 'Projecto QA', province: 'Manica' },
},
phases: { 1: 'Viabilidade', 2: 'Preparação' },
steps: [
{
code: 'S03',
title: 'Viabilidade',
phase: 1,
version: 2,
state: 'preparing',
reason: 'Conferir pressupostos',
detail: {},
},
{
code: 'S08',
title: 'Licenciamento prévio',
phase: 2,
version: 4,
state: 'not_started',
reason: '',
detail: { document_id: 'doc-a' },
},
],
results: [],
land_report: {
id: 'land-a',
version: 3,
updated_at: '2026-11-13',
href: '/investidor/terra-duat/land-a/imprimir',
},
documents: [
{
id: 'link-a',
document_id: 'doc-a',
file_name: 'Estudo QA',
status: 'active',
malware_scan_status: 'clean',
version_no: 2,
sha256: 'a'.repeat(64),
},
],
preparation_report: {
case_id: 'case-a',
opportunity: 'op-a',
version: 7,
context_revision: 3,
href: '/investimentos/case-a/preparacao?opportunity=op-a',
api_state: 'delivery_recorded',
api_issues: [],
exports: [
{
id: 'export-old',
at: '2026-11-12',
context_revision: 2,
historical: true,
fingerprint: 'old',
href: '/investimentos/case-a/preparacao/exportar/export-old?opportunity=op-a',
},
{
id: 'export-new',
at: '2026-11-13',
context_revision: 3,
historical: false,
fingerprint: 'new',
href: '/investimentos/case-a/preparacao/exportar/export-new?opportunity=op-a',
},
],
external: [
{
kind: 'delivery',
date: '2026-11-13',
reference: 'QA-ENTREGA',
export_id: 'export-new',
},
],
swot_review_required: true,
swot: {
object_key: 'investment:case-a',
scope: 'Produção QA',
rows: [
{
id: 's1',
dimension: 'strength',
text: 'Equipa experiente',
reference: 'Fonte QA',
date: '2026-11-12',
classification: 'hypothesis',
},
],
review: null,
actions: [],
},
},
fiscal_reports: [
{
simulation_id: 'simulation-a',
report_id: 'report-a',
report_version_no: 2,
report_sha256: 'b'.repeat(64),
consented_at: '2026-11-13',
},
],
company_reports: {
comparison: { revision: 2, at: '2026-11-12' },
guides: {
energy: {
slug: 'energia',
title: 'Guia de energia',
revision: 1,
at: '2026-11-12',
},
},
},
};
}
void test('one dossier groups stage evidence, SWOT, fiscal and all preserved APIEX versions without modifying sources', () => {
const input = fixture();
input.documents.push({ ...input.documents[0], id: 'second-link' });
const before = JSON.stringify(input);
const index = investorDossierReportIndex(input);
assert.equal(
index.filter((entry) => entry.id.startsWith('apiex:')).length,
2,
);
assert.equal(
index.find((entry) => entry.id === 'document:link-a').stage,
'S08',
);
assert.equal(
index.filter((entry) => entry.id.startsWith('document:')).length,
1,
);
assert.match(
index.find((entry) => entry.id === 'apiex:export-old').state,
/histórica/,
);
assert.equal(
index.find((entry) => entry.id === 'swot').state,
'Revisão necessária',
);
assert.match(
index.find((entry) => entry.id === 'land:land-a').state,
/não é título DUAT/,
);
const report = createInvestorDossierReport(input, '2026-11-13T12:00:00Z');
assert.match(report.version, /case-a.*v9.*contexto v3.*op-a/);
assert.match(
report.sections.find((s) => s.id === 'execution').rows[1].value,
/Por iniciar.*versão 4/,
);
assert.match(
report.sections.find((s) => s.id === 'market').rows[0].value,
/Hipótese/,
);
assert.match(
report.sections.find((s) => s.id === 'sources').paragraphs.join(' '),
/Entrega externa registada.*não equivale a aprovação/,
);
assert.equal(JSON.stringify(input), before);
});
void test('cross-dossier or cross-opportunity preparation is rejected and an ambiguous scope requires selection', () => {
const input = fixture();
input.preparation_report.case_id = 'case-b';
assert.throws(() => createInvestorDossierReport(input), /outro dossier/);
input.preparation_report.case_id = 'case-a';
input.preparation_report.opportunity = 'op-b';
assert.throws(() => createInvestorDossierReport(input), /oportunidade/);
input.context_opportunity = null;
assert.throws(() => investorDossierReportIndex(input), /Seleccione/);
});
void test('financial results from a different opportunity do not enter the consolidated report or its index', () => {
const input = fixture();
input.results = [
{
id: 'other',
context_opportunity: 'op-b',
financial_snapshot: 'OTHER-PRIVATE-INPUTS',
draft_id: 'other-draft',
draft_version: 8,
recorded_at: '2026-11-13',
engine: 'unknown',
currency: 'MZN',
},
];
assert.ok(
!JSON.stringify(createInvestorDossierReport(input)).includes(
'OTHER-PRIVATE-INPUTS',
),
);
assert.ok(
!investorDossierReportIndex(input).some(
(entry) => entry.id === 'finance:other',
),
);
assert.match(
createInvestorDossierReport(input).sections.find(
(s) => s.id === 'finance',
).paragraphs[0],
/Nenhum resultado/,
);
});
void test('the consolidated report reuses loan, break-even, financing and budget outputs with currency and historical state', () => {
const input = fixture();
const financial_snapshot = serializeFinancialToolsSnapshot({
budgetPeriod: 'Mensal',
loan: {
principal: '100000',
rate: '0',
months: '12',
rateType: 'nominal',
periodMonths: '1',
gracePeriods: '0',
upfrontFee: '0',
recurringFee: '0',
},
breakEven: { fixed: '1000', price: '100', variable: '50' },
funding: { total: '100000', secured: '30000' },
incomes: [{ label: 'Receita QA', amount: '100' }],
expenses: [{ label: 'Custo QA', amount: '50' }],
});
input.results = [
{
id: 'finance-a',
context_opportunity: 'op-a',
context_revision: 2,
context_state: 'review_required',
financial_snapshot,
draft_id: 'draft-a',
draft_version: 5,
recorded_at: '2026-11-12',
engine: 'financial-calculators:v1',
currency: 'MZN',
},
];
const report = createInvestorDossierReport(input);
const rows = report.sections.find(
(section) => section.id === 'finance',
).rows;
assert.equal(
rows.find(
(item) => item.field === 'Financiamento — Diferença por financiar',
).value,
'70000.00 MZN',
);
assert.equal(
rows.find(
(item) => item.field === 'Ponto de equilíbrio — Unidades estimadas',
).value,
'20',
);
assert.equal(
rows.find(
(item) => item.field === 'Orçamento — Saldo operacional estimado',
).value,
'50.00 MZN',
);
assert.ok(
rows.some((item) => item.field.startsWith('Empréstimo educativo')),
);
assert.match(rows[0].classification, /Histórico/);
assert.equal(input.results[0].financial_snapshot, financial_snapshot);
});
void test('the existing Word and print exporters retain dossier identity, pending stages, source and history, escaping active HTML', async () => {
const report = createInvestorDossierReport(
fixture(),
'2026-11-13T12:00:00Z',
);
const html = await createProjectDossierPrintHtml(report, [], false);
assert.ok(!html.includes('<script>alert(1)</script>'));
assert.match(html, /case-a/);
assert.match(html, /Fonte QA/);
assert.match(html, /export-old/);
assert.ok(html.includes('a'.repeat(64)));
const docx = await generateProjectDossierDocx(report, [], false);
const zip = await JSZip.loadAsync(await docx.arrayBuffer());
const xml = await zip.file('word/document.xml').async('string');
assert.match(xml, /case-a/);
assert.match(xml, /Licenciamento prévio/);
assert.match(xml, /Por iniciar/);
assert.match(xml, /Fonte QA/);
assert.match(xml, /export-old/);
});