253 lines
16 KiB
PHP
253 lines
16 KiB
PHP
<?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);
|
|
}
|
|
}
|
|
}
|