Merge branch 'main' of https://git.tempo.co.mz/nala/Investidor-V2
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-11-13 14:43:34 +02:00
42 changed files with 4400 additions and 10 deletions
@@ -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']);
}
}
}
+79
View File
@@ -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']]);
}
}
+252
View File
@@ -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();
+30
View File
@@ -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());
}
}
+109
View File
@@ -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/);
});