Merge branch 'main' of https://git.tempo.co.mz/nala/Investidor-V2
tests / ci (push) Canceled after 0s
tests / ci (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\DemographicEvidence;
|
||||
use App\Services\DemographicInstallation;
|
||||
use App\Support\IsolatedQaDatabase;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class DemographicInstallationTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
private User $manager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutVite();
|
||||
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
|
||||
$this->manager = User::factory()->create();
|
||||
$this->manager->forceFill(['two_factor_secret' => Crypt::encrypt('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()])->save();
|
||||
$second = User::factory()->create();
|
||||
// Test-only installation identities. This entire transaction is rolled back in QA.
|
||||
if (DB::table('portal_superuser_pair')->where('id', 1)->exists()) {
|
||||
DB::table('portal_superuser_pair')->where('id', 1)->update(['first_user_id' => $this->manager->id, 'second_user_id' => $second->id]);
|
||||
}
|
||||
config(['portal.managers' => [$this->manager->email]]);
|
||||
}
|
||||
|
||||
public function test_installation_requires_fresh_session_mfa_even_for_a_designated_manager(): void
|
||||
{
|
||||
$this->actingAs($this->manager)->postJson('/management/demography/install', ['hash' => str_repeat('0', 64), 'operation_key' => (string) Str::uuid(), 'confirm' => true, 'reason' => 'Synthetic QA installation access test.'])->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_an_unauthorised_account_cannot_read_the_installation_plan(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create())->getJson('/management/demography/install')->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_designated_superuser_does_not_require_the_legacy_manager_gate(): void
|
||||
{
|
||||
config(['portal.managers' => []]);
|
||||
$this->assertFalse($this->manager->can('manage-portal'));
|
||||
$this->actingAs($this->manager)->withSession(['portal.mfa_actor' => $this->manager->id, 'portal.mfa_at' => time()]);
|
||||
$this->get('/management/demography/install')->assertOk()->assertInertia(fn (\Inertia\Testing\AssertableInertia $page) => $page
|
||||
->component('management/demography-install')->where('plan.mfa_confirmed', true));
|
||||
app(\App\Services\DemographicImport::class)->authorize($this->manager);
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function test_an_unavailable_interpreter_blocks_the_plan_before_installation(): void
|
||||
{
|
||||
config(['demography.python' => base_path('storage/nonexistent-demography-python.exe')]);
|
||||
$plan = app(DemographicInstallation::class)->preview($this->manager);
|
||||
$this->assertFalse($plan['runtime_ready']);
|
||||
$this->assertFalse($plan['ready']);
|
||||
$this->assertStringContainsString('interpretador', $plan['notice']);
|
||||
}
|
||||
|
||||
public function test_disabled_process_support_does_not_crash_the_installation_page(): void
|
||||
{
|
||||
if (function_exists('proc_open')) { $this->markTestSkipped('Run this regression with -d disable_functions=proc_open.'); }
|
||||
config(['demography.prepared_directory' => null]);
|
||||
$this->actingAs($this->manager)->withSession(['portal.mfa_actor' => $this->manager->id, 'portal.mfa_at' => time()]);
|
||||
$this->get('/management/demography/install')->assertOk()->assertInertia(fn (\Inertia\Testing\AssertableInertia $page) => $page
|
||||
->component('management/demography-install')->where('plan.runtime_ready', false)->where('plan.ready', false));
|
||||
}
|
||||
|
||||
public function test_schema_flag_alone_does_not_authorise_the_operational_target(): void
|
||||
{
|
||||
$this->app['env'] = 'local';
|
||||
config(['demography.authenticated_installation' => true, 'database.connections.pgsql.database' => 'investemoz', 'database.connections.pgsql.port' => '5432']);
|
||||
$this->assertFalse(app(DemographicInstallation::class)->schemaAllowed());
|
||||
}
|
||||
|
||||
public function test_stale_plan_is_rejected_before_schema_or_data_changes(): void
|
||||
{
|
||||
$this->actingAs($this->manager)->withSession(['portal.mfa_actor' => $this->manager->id, 'portal.mfa_at' => time()]);
|
||||
$this->postJson('/management/demography/install', ['hash' => str_repeat('0', 64), 'operation_key' => (string) Str::uuid(), 'confirm' => true, 'reason' => 'Synthetic QA stale installation plan test.'])->assertConflict();
|
||||
}
|
||||
|
||||
public function test_release_installs_imports_and_replays_on_the_restored_target(): void
|
||||
{
|
||||
if (getenv('DEMOGRAPHY_RELEASE_TESTS') !== '1') {
|
||||
$this->markTestSkipped('Opt in only on the specifically restored demographic QA database with the real supplied Excel files and scanner.');
|
||||
}
|
||||
$this->assertMatchesRegularExpression('/^demography_restore_\d{8}_\d{6}_test$/', DB::connection()->getDatabaseName());
|
||||
$this->assertFalse(Schema::hasTable('demographic_imports'));
|
||||
$this->actingAs($this->manager)->withSession(['portal.mfa_actor' => $this->manager->id, 'portal.mfa_at' => time()]);
|
||||
$service = app(DemographicInstallation::class);
|
||||
$plan = $service->preview($this->manager);
|
||||
$this->assertTrue($plan['ready']);
|
||||
$input = ['hash' => $plan['hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true, 'reason' => 'Homologação da entrega numa cópia restaurada; apenas dados demográficos oficiais e identidades sintéticas QA.'];
|
||||
$result = $service->apply($this->manager, $input);
|
||||
$this->assertTrue($result['installed']);
|
||||
$this->assertSame(154706, $result['record_count']);
|
||||
$this->assertSame(154706, DB::table('demographic_observations')->count());
|
||||
$this->assertSame(3, DB::table('demographic_imports')->where('status', 'approved')->count());
|
||||
$repeat = $service->apply($this->manager, $input);
|
||||
$this->assertTrue($repeat['repeated']);
|
||||
$this->assertSame(154706, DB::table('demographic_observations')->count());
|
||||
$districts = app(DemographicEvidence::class)->territories()->whereIn('name', ['Macate', 'Vanduzi'])->pluck('id')->all();
|
||||
$this->assertCount(2, $districts);
|
||||
$this->assertSame('available', app(DemographicEvidence::class)->analyse($districts, 2017, 2050, 'industry')['state']);
|
||||
$this->assertDatabaseHas('audit_events', ['source_system' => 'demography-installation', 'actor_user_id' => $this->manager->id, 'correlation_id' => $input['operation_key']]);
|
||||
$originals = DB::table('demographic_imports')->orderBy('id')->get()->toJson();
|
||||
$originalIds = DB::table('demographic_imports')->pluck('id')->all();
|
||||
$originalEvents = DB::table('demographic_events')->whereIn('import_id', $originalIds)->orderBy('id')->get()->toJson();
|
||||
$plan = $service->preview($this->manager);
|
||||
$this->assertSame('remaining', $plan['mode']);
|
||||
$this->assertSame(8, $plan['pending_count']);
|
||||
$this->assertCount(8, $plan['new_districts']);
|
||||
$next = ['hash' => $plan['hash'], 'mode' => 'remaining', 'operation_key' => (string) Str::uuid(), 'confirm' => true, 'reason' => 'Oito províncias e oito distritos autorizados em13/11;QA restaurada sem alterações no activo.'];
|
||||
$result = $service->apply($this->manager, $next);
|
||||
$this->assertSame(639066, $result['record_count']);
|
||||
$this->assertSame(793772, DB::table('demographic_observations')->count());
|
||||
$this->assertSame(11, DB::table('demographic_imports')->where('status', 'approved')->count());
|
||||
$this->assertSame($originals, DB::table('demographic_imports')->whereIn('id', $originalIds)->orderBy('id')->get()->toJson());
|
||||
$this->assertSame($originalEvents, DB::table('demographic_events')->whereIn('import_id', $originalIds)->orderBy('id')->get()->toJson());
|
||||
$this->assertTrue($service->apply($this->manager, $next)['repeated']);
|
||||
$this->assertSame(793772, DB::table('demographic_observations')->count());
|
||||
$this->assertSame('zambezia', $service->preview($this->manager)['mode']);
|
||||
$this->assertSame(1, $service->preview($this->manager)['pending_count']);
|
||||
$this->assertTrue($service->preview($this->manager)['ready']);
|
||||
$this->assertCount(10, app(DemographicEvidence::class)->territories()->where('source_system', 'ine-demography')->get());
|
||||
foreach (['Chongoene','Larde','Chimbonila','Doa','KaMphumu','Beira'] as $name) {
|
||||
$literal = $name === 'KaMphumu' ? 'DISTRITO URBANO 1' : ($name === 'Beira' ? 'CIDADE DA BEIRA' : $name);
|
||||
$territory = app(DemographicEvidence::class)->territories()->whereRaw('lower(name)=?', [mb_strtolower($literal)])->value('id');
|
||||
$this->assertNotNull($territory, $name);
|
||||
$this->assertSame('available', app(DemographicEvidence::class)->analyse([$territory],2017,2050,'general')['state']);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\DemographicEvidence;
|
||||
use App\Services\DemographicInstallation;
|
||||
use App\Support\IsolatedQaDatabase;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class DemographicZambeziaTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutVite();
|
||||
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
|
||||
$this->assertMatchesRegularExpression('/^demography_restore_\d{8}_\d{6}_test$/', DB::connection()->getDatabaseName());
|
||||
}
|
||||
|
||||
private function manager(): User
|
||||
{
|
||||
$actor = User::factory()->create();
|
||||
$actor->forceFill(['two_factor_secret' => Crypt::encrypt('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()])->save();
|
||||
$second = User::factory()->create();
|
||||
DB::table('portal_superuser_pair')->where('id', 1)->update(['first_user_id' => $actor->id, 'second_user_id' => $second->id]);
|
||||
config(['portal.managers' => [$actor->email]]);
|
||||
$this->actingAs($actor);
|
||||
|
||||
return $actor;
|
||||
}
|
||||
|
||||
public function test_new_cohort_requires_current_session_mfa(): void
|
||||
{
|
||||
$this->manager();
|
||||
$this->postJson('/management/demography/install', ['mode' => 'zambezia', 'hash' => str_repeat('0', 64), 'operation_key' => (string) Str::uuid(), 'confirm' => true, 'reason' => 'Synthetic fresh session test.'])->assertForbidden();
|
||||
$this->assertSame(793772, DB::table('demographic_observations')->count());
|
||||
}
|
||||
|
||||
public function test_only_zambezia_is_added_and_all_previous_imports_are_preserved(): void
|
||||
{
|
||||
$actor = $this->manager();
|
||||
$this->withSession(['portal.mfa_actor' => $actor->id, 'portal.mfa_at' => time()]);
|
||||
$this->assertSame(11, DB::table('demographic_imports')->where('status', 'approved')->count());
|
||||
$oldIds = DB::table('demographic_imports')->pluck('id');
|
||||
$oldImports = DB::table('demographic_imports')->orderBy('id')->get()->toJson();
|
||||
$oldEvents = DB::table('demographic_events')->orderBy('id')->get()->toJson();
|
||||
$service = app(DemographicInstallation::class);
|
||||
$plan = $service->preview($actor);
|
||||
$this->assertSame('zambezia', $plan['mode']);
|
||||
$this->assertSame(1, $plan['pending_count']);
|
||||
$this->assertCount(1, $plan['files']);
|
||||
$this->assertCount(5, $plan['new_districts']);
|
||||
$body = ['mode' => 'zambezia', 'hash' => $plan['hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true, 'reason' => 'Incorporação de Zambezia.xlsx autorizada e cinco distritos;ensaio apenas na cópia restaurada.'];
|
||||
$result = $service->apply($actor, $body);
|
||||
$this->assertSame(126684, $result['record_count']);
|
||||
$this->assertCount(1, $result['imports']);
|
||||
$this->assertSame(12, DB::table('demographic_imports')->where('status', 'approved')->count());
|
||||
$this->assertSame(920456, DB::table('demographic_observations')->count());
|
||||
$this->assertSame($oldImports, DB::table('demographic_imports')->whereIn('id', $oldIds)->orderBy('id')->get()->toJson());
|
||||
$this->assertSame($oldEvents, DB::table('demographic_events')->whereIn('import_id', $oldIds)->orderBy('id')->get()->toJson());
|
||||
$this->assertTrue($service->apply($actor, $body)['repeated']);
|
||||
$this->assertSame(920456, DB::table('demographic_observations')->count());
|
||||
$this->assertFalse($service->preview($actor)['ready']);
|
||||
$this->assertSame(0, $service->preview($actor)['pending_count']);
|
||||
foreach (['Derre', 'Luabo', 'Mocubela', 'Molumbo', 'Mulevala'] as $name) {
|
||||
$ids = app(DemographicEvidence::class)->territories()->where('name', $name)->pluck('id')->all();
|
||||
$this->assertCount(1, $ids);
|
||||
$this->assertSame('available', app(DemographicEvidence::class)->analyse($ids, 2017, 2050)['state']);
|
||||
}
|
||||
$this->assertDatabaseHas('audit_events', ['source_system' => 'demography-installation', 'action' => 'extend_zambezia', 'correlation_id' => $body['operation_key']]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\DemographicEvidence;
|
||||
use App\Services\DemographicImport;
|
||||
use App\Services\InvestmentPreparationWorkspace;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class DemographyTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
private string $province;
|
||||
|
||||
private string $district;
|
||||
|
||||
private string $batch;
|
||||
|
||||
private User $manager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutVite();
|
||||
$this->manager = User::factory()->create();
|
||||
$this->manager->forceFill(['two_factor_secret' => Crypt::encryptString('SYNTHETIC QA ONLY'), 'two_factor_confirmed_at' => now()])->save();
|
||||
config(['portal.managers' => [$this->manager->email]]);
|
||||
$country = (string) Str::uuid();
|
||||
$this->province = (string) Str::uuid();
|
||||
$this->district = (string) Str::uuid();
|
||||
foreach ([[$country, null, 'country'], [$this->province, $country, 'province'], [$this->district, $this->province, 'district']] as [$id, $parent, $type]) {
|
||||
DB::table('geo_locations')->insert(['id' => $id, 'parent_id' => $parent, 'name' => 'SYNTHETIC '.$type, 'code' => $id, 'location_type' => $type,
|
||||
'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:QA:'.$id, 'data_classification' => 'public']);
|
||||
}
|
||||
$this->batch = $this->batch('approved');
|
||||
foreach ([$this->province, $this->district] as $id) {
|
||||
foreach ([2026 => '100.125', 2027 => '110.250'] as $year => $value) {
|
||||
DB::table('demographic_observations')->insert(['import_id' => $this->batch, 'geo_location_id' => $id, 'year' => $year, 'indicator' => 'population',
|
||||
'sex' => 'all', 'residence' => 'all', 'age_group' => 'all', 'unit' => 'pessoas', 'data_kind' => 'projected', 'original_value' => $value, 'numeric_value' => $value, 'sheet' => (string) $year, 'cell' => 'B4', 'validation_status' => 'valid']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function batch(string $status): string
|
||||
{
|
||||
$id = (string) Str::uuid();
|
||||
DB::table('demographic_imports')->insert(['id' => $id, 'sha256' => hash('sha256', $id), 'file_name' => 'SYNTHETIC-QA.xlsx', 'source' => 'SYNTHETIC TEST ONLY',
|
||||
'publication_version' => 'synthetic/1', 'status' => $status, 'report' => json_encode(['record_count' => 4, 'territories' => [], 'issue_counts' => []]),
|
||||
'preview_sha256' => str_repeat('0', 64), 'prepared_by' => $this->manager->id, 'created_at' => now(), 'updated_at' => now()]);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
public function test_public_comparison_preserves_decimals_versions_and_missing_years(): void
|
||||
{
|
||||
$response = $this->getJson('/api/v1/investment/demography?'.http_build_query(['territories' => [$this->province, $this->district], 'start_year' => 2026, 'end_year' => 2028, 'sector' => 'industry']))->assertOk();
|
||||
$response->assertJsonPath('state', 'available')->assertJsonCount(2, 'territories')
|
||||
->assertJsonPath('territories.0.series.0.values.0.original_value', '100.125')
|
||||
->assertJsonPath('territories.0.series.0.missing_years', [2028]);
|
||||
$this->assertStringNotContainsString('probability', $response->getContent());
|
||||
}
|
||||
|
||||
public function test_only_approved_series_and_public_territories_are_exposed(): void
|
||||
{
|
||||
DB::table('demographic_imports')->where('id', $this->batch)->update(['status' => 'disabled']);
|
||||
$this->assertSame('no_data', app(DemographicEvidence::class)->analyse([$this->province], 2026, 2027)['state']);
|
||||
DB::table('demographic_imports')->where('id', $this->batch)->update(['status' => 'approved']);
|
||||
DB::table('geo_locations')->where('id', $this->province)->update(['data_classification' => 'internal']);
|
||||
$this->assertSame([], app(DemographicEvidence::class)->analyse([$this->district], 2026, 2027)['territories']);
|
||||
}
|
||||
|
||||
public function test_new_editions_remain_separate_and_divergence_is_reported(): void
|
||||
{
|
||||
$other = $this->batch('approved');
|
||||
$row = (array) DB::table('demographic_observations')->where('import_id', $this->batch)->where('geo_location_id', $this->province)->where('year', 2026)->first();
|
||||
unset($row['id']);
|
||||
DB::table('demographic_observations')->insert([...$row, 'import_id' => $other, 'numeric_value' => '101.125', 'original_value' => '101.125']);
|
||||
$analysis = app(DemographicEvidence::class)->analyse([$this->province], 2026, 2026);
|
||||
$this->assertCount(2, $analysis['territories'][0]['series']);
|
||||
$this->assertSame('divergent_values', $analysis['differences'][0]['state']);
|
||||
$this->assertCount(1, app(DemographicEvidence::class)->analyse([$this->province], 2026, 2026, 'general', $other)['territories'][0]['series']);
|
||||
}
|
||||
|
||||
public function test_permissions_mfa_and_qa_guard_are_enforced(): void
|
||||
{
|
||||
$this->get('/management/demography')->assertRedirect();
|
||||
$this->actingAs(User::factory()->create())->getJson('/management/demography')->assertForbidden();
|
||||
$this->manager->forceFill(['two_factor_confirmed_at' => null])->save();
|
||||
$this->actingAs($this->manager)->getJson('/management/demography')->assertForbidden();
|
||||
$this->manager->forceFill(['two_factor_confirmed_at' => now()])->save();
|
||||
config(['database.connections.pgsql.port' => '5432']);
|
||||
$this->getJson('/management/demography')->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_confirmation_is_idempotent_and_status_changes_preserve_observations(): void
|
||||
{
|
||||
$service = app(DemographicImport::class);
|
||||
$sha = DB::table('demographic_imports')->where('id', $this->batch)->value('sha256');
|
||||
$receipt = $service->confirm($this->manager, $this->batch, $sha, [], 'Synthetic replay test only.');
|
||||
$this->assertTrue($receipt['repeated']);
|
||||
$this->assertSame(4, $receipt['records']);
|
||||
$service->transition($this->manager, $this->batch, 'disabled', 'Synthetic deactivate test only.');
|
||||
$this->assertSame(4, DB::table('demographic_observations')->where('import_id', $this->batch)->count());
|
||||
$this->assertSame('no_data', app(DemographicEvidence::class)->analyse([$this->province], 2026, 2026)['state']);
|
||||
app(DemographicImport::class)->transition($this->manager, $this->batch, 'approved', 'Synthetic restore test only.');
|
||||
$this->assertSame('available', app(DemographicEvidence::class)->analyse([$this->province], 2026, 2026)['state']);
|
||||
$this->assertSame(2, DB::table('demographic_events')->where('import_id', $this->batch)->count());
|
||||
}
|
||||
|
||||
public function test_invalid_period_and_duplicate_territory_are_rejected(): void
|
||||
{
|
||||
$this->getJson('/api/v1/investment/demography?'.http_build_query(['territories' => [$this->province, $this->province]]))->assertUnprocessable();
|
||||
$this->getJson('/api/v1/investment/demography?start_year=2050&end_year=2017')->assertUnprocessable();
|
||||
$this->getJson('/api/v1/investment/demography?start_year=2000&end_year=2100')->assertUnprocessable();
|
||||
}
|
||||
|
||||
public function test_same_level_comparison_computes_exact_difference_and_explains_it(): void
|
||||
{
|
||||
$id = (string) Str::uuid();
|
||||
$location = (array) DB::table('geo_locations')->where('id', $this->district)->first();
|
||||
DB::table('geo_locations')->insert([...$location, 'id' => $id, 'name' => 'SYNTHETIC second district', 'code' => $id, 'external_id' => $location['external_id'].'-second']);
|
||||
$row = (array) DB::table('demographic_observations')->where('geo_location_id', $this->district)->where('year', 2026)->first();
|
||||
unset($row['id']);
|
||||
DB::table('demographic_observations')->insert([...$row, 'geo_location_id' => $id, 'numeric_value' => '80.025', 'original_value' => '80.025']);
|
||||
$analysis = app(DemographicEvidence::class)->analyse([$this->district, $id], 2026, 2026, 'industry');
|
||||
$this->assertCount(1, $analysis['comparisons']);
|
||||
$this->assertSame('20.100', ltrim($analysis['comparisons'][0]['difference'], '-'));
|
||||
$this->assertStringContainsString('Não mede procura efectiva', $analysis['comparisons'][0]['explanation']);
|
||||
}
|
||||
|
||||
public function test_evidence_reuses_context_without_modifying_it(): void
|
||||
{
|
||||
$context = ['fields' => ['province_id' => $this->province, 'district_id' => $this->district]];
|
||||
$before = $context;
|
||||
$analysis = app(DemographicEvidence::class)->forContext($context);
|
||||
$this->assertSame('available', $analysis['state']);
|
||||
$this->assertSame($this->district, $analysis['territories'][0]['id']);
|
||||
$this->assertSame($before, $context);
|
||||
$fallback = app(DemographicEvidence::class)->forContext(['fields' => ['province_id' => $this->province, 'district_id' => '']]);
|
||||
$this->assertSame($this->province, $fallback['territories'][0]['id']);
|
||||
$method = new \ReflectionMethod(InvestmentPreparationWorkspace::class, 'sources');
|
||||
[, $sources] = $method->invoke(app(InvestmentPreparationWorkspace::class), $this->manager, (object) ['id' => (string) Str::uuid(), 'title' => 'Synthetic QA investment'], $context, '');
|
||||
$source = collect($sources)->firstWhere('key', 'demography');
|
||||
$this->assertSame('available', $source['current']['state']);
|
||||
}
|
||||
|
||||
public function test_preview_or_original_tampering_prevents_import(): void
|
||||
{
|
||||
$prepared = $this->batch('prepared');
|
||||
Storage::fake('local');
|
||||
Storage::disk('local')->put('demography/'.$prepared.'/preview.json', '{}');
|
||||
$sha = DB::table('demographic_imports')->where('id', $prepared)->value('sha256');
|
||||
$this->actingAs($this->manager)->postJson('/management/demography/'.$prepared.'/confirm', ['sha256' => $sha, 'mapping' => [], 'reason' => 'Synthetic integrity test only.'])->assertConflict();
|
||||
$this->assertSame(0, DB::table('demographic_observations')->where('import_id', $prepared)->count());
|
||||
}
|
||||
|
||||
public function test_stream_integrity_and_count_are_verified_before_import(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
foreach (['tampered', 'wrong-count'] as $case) {
|
||||
$prepared = $this->batch('prepared');
|
||||
$directory = 'demography/'.$prepared;
|
||||
$original = 'SYNTHETIC NDJSON integrity '.$prepared;
|
||||
$sha = hash('sha256', $original);
|
||||
$stream = $case === 'tampered' ? 'changed' : '';
|
||||
$summary = ['record_count' => 1, 'records_file_sha256' => hash('sha256', ''), 'territories' => [], 'issues' => [], 'issue_counts' => []];
|
||||
$preview = json_encode($summary, JSON_THROW_ON_ERROR);
|
||||
Storage::disk('local')->put($directory.'/original.xlsx', $original);
|
||||
Storage::disk('local')->put($directory.'/preview.json', $preview);
|
||||
Storage::disk('local')->put($directory.'/records.ndjson', $stream);
|
||||
DB::table('demographic_imports')->where('id', $prepared)->update(['sha256' => $sha, 'preview_sha256' => hash('sha256', $preview), 'report' => $preview]);
|
||||
$this->actingAs($this->manager)->postJson('/management/demography/'.$prepared.'/confirm', ['sha256' => $sha, 'mapping' => [], 'reason' => 'Synthetic stream integrity and count test.'])->assertConflict();
|
||||
$this->assertSame(0, DB::table('demographic_observations')->where('import_id', $prepared)->count());
|
||||
$this->assertSame('prepared', DB::table('demographic_imports')->where('id', $prepared)->value('status'));
|
||||
}
|
||||
}
|
||||
|
||||
public function test_database_rejects_duplicate_observation_dimensions(): void
|
||||
{
|
||||
$row = (array) DB::table('demographic_observations')->where('import_id', $this->batch)->first();
|
||||
unset($row['id']);
|
||||
$this->expectException(QueryException::class);
|
||||
DB::table('demographic_observations')->insert($row);
|
||||
}
|
||||
|
||||
public function test_rollback_refuses_to_delete_imported_history(): void
|
||||
{
|
||||
$migration = require database_path('migrations/2026_11_13_100000_create_demographic_series.php');
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$migration->down();
|
||||
}
|
||||
|
||||
public function test_cli_preparation_imports_without_process_execution_and_keeps_exact_values(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$disk = Storage::disk('local');
|
||||
$original = 'SYNTHETIC prepared original'; $sha = hash('sha256', $original);
|
||||
$directory = 'prepared/'.$sha; $disk->makeDirectory($directory);
|
||||
$disk->put('synthetic.xlsx', $original);
|
||||
$row = ['province' => 'SYNTHETIC province', 'territory' => 'SYNTHETIC province', 'level' => 'province',
|
||||
'year' => 2026, 'indicator' => 'population', 'sex' => 'all', 'residence' => 'all', 'age_group' => 'all', 'unit' => 'pessoas',
|
||||
'data_kind' => 'projected', 'original_value' => '100.125', 'numeric_value' => '100.125', 'sheet' => '2026', 'cell' => 'B4'];
|
||||
$records = json_encode($row, JSON_THROW_ON_ERROR)."\n";
|
||||
$report = ['sha256' => $sha, 'source' => 'SYNTHETIC QA ONLY', 'record_count' => 1, 'records_file_sha256' => hash('sha256', $records),
|
||||
'territories' => [['SYNTHETIC province', 'SYNTHETIC province', 'province']], 'issues' => [], 'issue_counts' => []];
|
||||
$preview = json_encode($report, JSON_THROW_ON_ERROR);
|
||||
$receipt = ['sha256' => $sha, 'baseline_year' => null, 'parser_sha256' => hash_file('sha256', base_path('tools/demography_workbook.py')),
|
||||
'preview_sha256' => hash('sha256', $preview), 'records_sha256' => hash('sha256', $records), 'record_count' => 1];
|
||||
$disk->put($directory.'/preview.json', $preview); $disk->put($directory.'/records.ndjson', $records);
|
||||
$disk->put($directory.'/preparation.json', json_encode($receipt, JSON_THROW_ON_ERROR));
|
||||
config(['demography.prepared_directory' => $disk->path('prepared'), 'demography.python' => '/missing-python-must-not-run']);
|
||||
$this->mock(\App\Services\Malware\MalwareScanner::class)->shouldReceive('scan')->once()->andReturn([
|
||||
'result' => 'clean', 'engine' => 'synthetic', 'engine_version' => 'test']);
|
||||
$service = app(DemographicImport::class);
|
||||
$this->assertTrue($service->preparedSourcesReady(['synthetic.xlsx' => ['sha256' => $sha, 'records' => 1]]));
|
||||
$result = $service->prepare($this->manager, $disk->path('synthetic.xlsx'), 'synthetic.xlsx', 'SYNTHETIC', null);
|
||||
$this->assertSame(0, DB::table('demographic_observations')->where('import_id', $result['id'])->count());
|
||||
$service->confirm($this->manager, $result['id'], $sha, [], 'Synthetic authenticated import from CLI preparation.');
|
||||
$this->assertSame('100.125', DB::table('demographic_observations')->where('import_id', $result['id'])->value('numeric_value'));
|
||||
$this->assertSame('imported', DB::table('demographic_imports')->where('id', $result['id'])->value('status'));
|
||||
$disk->put($directory.'/records.ndjson', 'TAMPERED');
|
||||
try { $service->preparedSource($sha, null); $this->fail('Changed prepared records must be refused.'); }
|
||||
catch (\Symfony\Component\HttpKernel\Exception\HttpException $error) { $this->assertSame(409, $error->getStatusCode()); }
|
||||
$receipt['parser_sha256'] = str_repeat('0', 64); $disk->put($directory.'/preparation.json', json_encode($receipt, JSON_THROW_ON_ERROR));
|
||||
$this->assertFalse($service->preparedSourcesReady(['synthetic.xlsx' => ['sha256' => $sha, 'records' => 1]]));
|
||||
}
|
||||
|
||||
public function test_remote_consultation_requires_explicit_activation_and_preserves_versions(): void
|
||||
{
|
||||
$environment = app()->environment();
|
||||
try {
|
||||
app()->instance('env', 'production');
|
||||
config(['demography.enabled' => false]);
|
||||
$service = app(DemographicEvidence::class);
|
||||
$this->assertSame('unavailable', $service->analyse([$this->province], 2026, 2026)['state']);
|
||||
config(['demography.enabled' => true]);
|
||||
$result = $service->analyse([$this->province], 2026, 2026);
|
||||
$this->assertSame('available', $result['state']);
|
||||
$this->assertSame($this->batch, $result['territories'][0]['series'][0]['import_id']);
|
||||
} finally {
|
||||
app()->instance('env', $environment);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,30 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\DemographicInstallation;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class DemographicTargetTest extends TestCase
|
||||
{
|
||||
public function test_homologation_requires_activation_exact_url_and_actual_database_identity(): void
|
||||
{
|
||||
app()->instance('env', 'production');
|
||||
config(['app.url' => 'https://teste-investidor.tempo.co.mz', 'demography.enabled' => true,
|
||||
'database.default' => 'pgsql', 'database.connections.pgsql.host' => '127.0.0.1',
|
||||
'database.connections.pgsql.port' => 5432, 'database.connections.pgsql.database' => 'teste_investidor',
|
||||
'database.connections.pgsql.url' => null]);
|
||||
DB::shouldReceive('selectOne')->twice()->with('SELECT current_database() AS database,inet_server_port() AS port')
|
||||
->andReturn((object) ['database' => 'teste_investidor', 'port' => 5432], (object) ['database' => 'other_database', 'port' => 5432]);
|
||||
$service = app(DemographicInstallation::class);
|
||||
$this->assertTrue($service->activeTarget());
|
||||
$this->assertFalse($service->activeTarget());
|
||||
config(['demography.enabled' => false]);
|
||||
$this->assertFalse($service->activeTarget());
|
||||
config(['demography.enabled' => true, 'app.url' => 'https://other.example']);
|
||||
$this->assertFalse($service->activeTarget());
|
||||
config(['app.url' => 'https://teste-investidor.tempo.co.mz', 'database.connections.pgsql.host' => 'remote.example']);
|
||||
$this->assertFalse($service->activeTarget());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Run: python -m unittest discover -s tests -p demography_workbook_test.py"""
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from zipfile import ZipFile
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'tools'))
|
||||
from demography_workbook import age_group, parse
|
||||
|
||||
|
||||
def workbook(path, rows):
|
||||
cells = []
|
||||
for number, row in enumerate(rows, 1):
|
||||
content = ''
|
||||
for column, value in row.items():
|
||||
reference = f'{column}{number}'
|
||||
content += f'<c r="{reference}" t="inlineStr"><is><t>{escape(value)}</t></is></c>'
|
||||
cells.append(f'<row r="{number}">{content}</row>')
|
||||
with ZipFile(path, 'w') as archive:
|
||||
archive.writestr('xl/workbook.xml', '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="2017" sheetId="1" r:id="r1"/></sheets></workbook>')
|
||||
archive.writestr('xl/_rels/workbook.xml.rels', '<Relationships><Relationship Id="r1" Target="worksheets/custom.xml"/></Relationships>')
|
||||
archive.writestr('xl/worksheets/custom.xml', '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>' + ''.join(cells) + '</sheetData></worksheet>')
|
||||
|
||||
|
||||
class WorkbookTest(unittest.TestCase):
|
||||
def fixture(self, rows):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'synthetic.xlsx'
|
||||
workbook(path, rows)
|
||||
return parse(path)
|
||||
|
||||
def rows(self):
|
||||
return [dict(A='QUADRO 1. População projectada por área de residência e sexo segundo idade. Manica, 2017'),
|
||||
dict(A='Idade', C='TOTAL', F='URBANA', I='RURAL'),
|
||||
dict(C='Total', D='Homens', E='Mulheres', F='Total', G='Homens', H='Mulheres', I='Total', J='Homens', K='Mulheres'),
|
||||
dict(A='Total', C='100.125', D='40.025', E='60.100', F='40.025', G='20', H='20.025', I='60.100', J='20.025', K='40.075')]
|
||||
|
||||
def test_shifted_headers_exact_decimals_and_no_header_as_age_row(self):
|
||||
report = self.fixture(self.rows())
|
||||
self.assertEqual(9, report['record_count'])
|
||||
self.assertEqual({}, report['issue_counts'])
|
||||
self.assertEqual('100.125', report['records'][0]['original_value'])
|
||||
self.assertEqual(Decimal('100.125'), Decimal(report['records'][0]['numeric_value']))
|
||||
|
||||
def test_conflicting_duplicates_are_reported(self):
|
||||
rows = self.rows()
|
||||
rows.append({**rows[-1], 'C': '200'})
|
||||
self.assertEqual(1, self.fixture(rows)['issue_counts']['conflicting_value'])
|
||||
|
||||
def test_incomplete_cells_never_become_zero(self):
|
||||
rows = self.rows()
|
||||
del rows[-1]['K']
|
||||
report = self.fixture(rows)
|
||||
self.assertEqual(1, report['issue_counts']['missing_value'])
|
||||
self.assertEqual(8, report['record_count'])
|
||||
|
||||
def test_sex_inconsistency_detected(self):
|
||||
rows = self.rows()
|
||||
rows[-1]['C'] = '999'
|
||||
self.assertEqual(1, self.fixture(rows)['issue_counts']['sex_total_mismatch'])
|
||||
|
||||
def test_unknown_workbook_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.fixture([dict(A='unrecognised data')])
|
||||
|
||||
def test_city_da_and_municipal_headers_do_not_become_second_provinces(self):
|
||||
for title, expected in [('Cidade da Matola', 'Matola'), ('Distrito Municipal KaMphumu', 'KaMphumu')]:
|
||||
with self.subTest(title=title):
|
||||
rows = self.rows()
|
||||
rows += [dict(A='QUADRO 2. População projectada por área de residência e sexo segundo idade. '+title+', 2017'), rows[-1].copy()]
|
||||
report = self.fixture(rows)
|
||||
district = [r for r in report['records'] if r['level'] == 'district']
|
||||
self.assertEqual(9, len(district))
|
||||
self.assertEqual(expected, district[0]['territory'])
|
||||
self.assertEqual('Manica', district[0]['province'])
|
||||
self.assertEqual([title], report['territory_headers']['Manica|'+expected+'|district'])
|
||||
|
||||
def test_formula_uses_only_the_original_numeric_cache_and_reports_provenance(self):
|
||||
for cache in ['100.125', '']:
|
||||
with self.subTest(cache=cache), tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'cached.xlsx'
|
||||
workbook(path, self.rows())
|
||||
with ZipFile(path) as source:
|
||||
members = {n: source.read(n) for n in source.namelist()}
|
||||
xml = members['xl/worksheets/custom.xml'].decode()
|
||||
xml = xml.replace('<c r="C4" t="inlineStr"><is><t>100.125</t></is></c>', '<c r="C4"><f>SUM(D4:E4)</f><v>'+cache+'</v></c>')
|
||||
members['xl/worksheets/custom.xml'] = xml.encode()
|
||||
with ZipFile(path, 'w') as target:
|
||||
for name, content in members.items(): target.writestr(name, content)
|
||||
report = parse(path)
|
||||
if cache:
|
||||
self.assertEqual('100.125', report['records'][0]['original_value'])
|
||||
self.assertEqual(1, report['issue_counts']['cached_formula_value'])
|
||||
self.assertNotIn('invalid_numeric_or_formula', report['issue_counts'])
|
||||
self.assertEqual('SUM(D4:E4)', report['issues'][0]['formula'])
|
||||
else:
|
||||
self.assertEqual(1, report['issue_counts']['invalid_numeric_or_formula'])
|
||||
self.assertEqual(8, report['record_count'])
|
||||
|
||||
def test_age_labels(self):
|
||||
self.assertEqual('15-64', age_group('População em idade activa (15–64 anos)'))
|
||||
self.assertEqual('65+', age_group('População idosa (65 ou mais anos)'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -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