Group investor reports and preserved financial copies in their existing dossier
tests / ci (push) Canceled after 0s
tests / ci (push) Canceled after 0s
This commit is contained in:
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user