Files
Investidor-V2/tests/Feature/PortalServicesTest.php
T
nala 287e991486
tests / ci (push) Waiting to run
oportunidades 7
2026-10-13 11:40:29 +02:00

569 lines
42 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\InterestRequest;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Models\User;
use App\Services\Malware\MalwareScanner;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class PortalServicesTest extends TestCase
{
use RefreshDatabase;
public function test_service_intake_preserves_same_origin_entry_page_and_replay_without_trusting_external_referers(): void
{
$key = (string) Str::uuid();
$payload = ['kind' => 'investor', 'fullName' => 'Pessoa sintética QA', 'email' => 'qa-entry-source@example.invalid',
'country' => 'PT', 'declared_origin_country_code' => 'ZA', 'sectorInterest' => 'Ensaio da origem do formulário',
'message' => 'Descrição sintética QA para testar a rastreabilidade do formulário.', 'privacyConsent' => true, 'submission_key' => $key];
$this->post('http://localhost/apoio', $payload, ['Referer' => 'http://localhost/formularios/investidor?context=qa'])->assertRedirect();
$row = InterestRequest::where('email', $payload['email'])->firstOrFail();
$this->assertSame('/formularios/investidor', $row->source_page);
$this->assertSame('ZA', $row->metadata['declared_origin_country_code']);
$this->post('http://localhost/apoio', $payload, ['Referer' => 'http://localhost/apoio'])->assertRedirect();
$this->assertDatabaseCount('interest_requests', 1);
$this->assertSame('/formularios/investidor', $row->fresh()->source_page);
foreach (['https://foreign.example/formularios/empresa', 'http://localhost/management/interests', 'http://localhost:9999/formularios/investidor'] as $referer) {
$this->post('http://localhost/apoio', [...$payload, 'submission_key' => (string) Str::uuid()], ['Referer' => $referer])->assertRedirect();
$this->assertSame('/apoio', InterestRequest::latest('id')->first()->source_page);
}
}
protected function setUp(): void
{
parent::setUp();
$this->app->bind(MalwareScanner::class, fn () => new class implements MalwareScanner
{
public function scan(string $path, int $fileSizeBytes, string $storageRoot): array
{
return ['result' => 'clean', 'reason_code' => null, 'engine' => 'ClamAV-Test-Double', 'engine_version' => 'fixture',
'signatures_at' => new \DateTimeImmutable('2026-10-05T00:00:00Z'), 'duration_ms' => 1, 'detail' => null];
}
public function health(): array
{
return ['available' => true, 'engine' => 'ClamAV-Test-Double', 'engine_version' => 'fixture',
'signatures_at' => new \DateTimeImmutable('2026-10-05T00:00:00Z'), 'signature_age_hours' => 0.0, 'reason_code' => null];
}
});
}
public function test_public_sections_render_and_desk_requires_login(): void
{
foreach (['sectores', 'como-investir', 'legislacao', 'documentos', 'apoio', 'parcerias', 'simulador-fiscal', 'Simulador', 'formularios/investidor', 'formularios/empresa'] as $path) {
$this->get('/'.$path)->assertOk()->assertInertia(fn (Assert $page) => $page->component($path === 'parcerias' ? 'partnerships/catalogue' : 'portal/services'));
}
$this->get('/balcao-virtual')->assertRedirect('/login');
$this->get('/management/portal-services')->assertRedirect('/login');
}
public function test_document_management_writes_redirect_to_the_management_get_route(): void
{
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$this->actingAs($manager);
// A stale Referer can point at the write-only resource endpoint. Both
// validation failures and successful writes must finish at a real GET.
$this->from('/management/portal-services/resources/4')
->post('/management/portal-services/resources/4', ['published' => 'invalid'])
->assertRedirect(route('services.manage'))
->assertSessionHasErrors('published');
$this->get(route('services.manage'))->assertOk()->assertInertia(fn (Assert $page) => $page->component('portal/manage'));
$this->from('/management/portal-services/resources/4')
->post('/management/portal-services/resources', [])
->assertRedirect(route('services.manage'))
->assertSessionHasErrors('document_id');
$this->get('/management/portal-services/resources/4')->assertStatus(405);
}
public function test_support_receipt_is_idempotent_private_and_requires_consent(): void
{
$owner = User::factory()->create();
$payload = ['kind' => 'company', 'fullName' => 'Pessoa de Teste', 'email' => 'test@example.com', 'country' => 'Moçambique',
'company' => 'Empresa de teste', 'sectorInterest' => 'Agricultura', 'message' => 'Pretendo orientação sobre este investimento.',
'privacyConsent' => true, 'submission_key' => (string) Str::uuid()];
$this->actingAs($owner)->post('/apoio', [...$payload, 'privacyConsent' => false])->assertSessionHasErrors('privacyConsent');
$this->post('/apoio', $payload)->assertSessionHasNoErrors()->assertSessionHas('success');
$this->post('/apoio', $payload)->assertSessionHasNoErrors();
$this->assertSame(1, InterestRequest::count());
$this->assertDatabaseCount('outbox_events', 1);
$this->get('/balcao-virtual')->assertRedirect('/balcao-virtual/painel');
$this->get('/balcao-virtual/painel')->assertInertia(fn (Assert $page) => $page->has('legacyRequests', 1));
$this->actingAs(User::factory()->create())->get('/balcao-virtual/painel')->assertInertia(fn (Assert $page) => $page->has('legacyRequests', 0));
$this->post('/apoio', $payload)->assertStatus(409);
}
public function test_home_remains_available_when_document_catalogue_schema_is_incompatible(): void
{
DB::shouldReceive('table')->once()->with('portal_resources as r')
->andThrow(new QueryException('pgsql', 'select missing catalogue columns', [], new \PDOException('SQLSTATE[42703]')));
$this->get('/')->assertOk()->assertInertia(fn (Assert $page) => $page
->component('PortalInvestidor')
->has('essentialResources', 0));
}
public function test_partnership_requires_moderation_and_hides_owner_identity(): void
{
$owner = User::factory()->create();
$this->actingAs($owner)->post('/parcerias', ['title' => 'Parceria de teste', 'sector' => 'Agricultura', 'province' => 'Gaza',
'offer' => 'Capacidade de processamento de teste.', 'seeking' => 'Parceiro de logística para teste.', 'publication_consent' => true])->assertSessionHasNoErrors();
$id = DB::table('portal_partnerships')->value('id');
$this->get('/parcerias')->assertInertia(fn (Assert $page) => $page->has('results.data', 0));
$this->post('/management/portal-services/partnerships/'.$id, ['published' => true])->assertForbidden();
$moderator = User::factory()->create();
config(['portal.managers' => [$moderator->email]]);
$this->actingAs($moderator)->post('/management/portal-services/partnerships/'.$id, ['published' => true])->assertRedirect('/parcerias/'.$id);
$this->get('/parcerias')->assertInertia(fn (Assert $page) => $page->has('results.data', 0));
$version = DB::table('portal_partnerships')->where('id', $id)->value('version');
$this->post('/parcerias/'.$id.'/estado', ['version' => $version, 'action' => 'publish', 'reason' => 'Divulgação autorizada e revista pelo moderador QA.'])->assertSessionHasNoErrors();
$this->get('/parcerias')->assertInertia(fn (Assert $page) => $page->has('results.data', 1)->missing('results.data.0.user_id'));
$version = DB::table('portal_partnerships')->where('id', $id)->value('version');
$this->post('/parcerias/'.$id.'/estado', ['version' => $version, 'action' => 'suspend', 'reason' => 'Retirada de divulgação na revisão QA.'])->assertSessionHasNoErrors();
$this->get('/parcerias')->assertInertia(fn (Assert $page) => $page->has('results.data', 0));
}
public function test_document_download_rechecks_classification_checksum_and_publication(): void
{
Storage::fake('local');
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$this->actingAs($manager)->post('/management/portal-services/upload', ['file' => UploadedFile::fake()->createWithContent('exemplo.txt', 'Documento sintético de teste.'),
'issuer' => 'Entidade sintética de QA', 'original_source' => 'https://example.gov.mz/source'])->assertSessionHasNoErrors();
$document = DB::table('documents')->first();
$this->assertSame('active', $document->status);
$this->assertSame('clean', $document->malware_scan_status);
$this->assertSame($document->sha256, $document->malware_scanned_sha256);
$this->assertSame('internal', $document->data_classification);
$this->assertSame($manager->id, $document->uploaded_by);
$this->assertSame(strlen('Documento sintético de teste.'), (int) $document->size_bytes);
$this->get('/management/documents/'.$document->id.'/download')->assertOk()->assertDownload('exemplo.txt');
auth()->logout();
$this->get('/management/documents/'.$document->id.'/download')->assertRedirect('/login');
$this->actingAs($manager);
$this->get('/documentos')->assertInertia(fn (Assert $page) => $page->has('resources.data', 0));
$id = $this->registerDocumentResource($document->id, 'Guia de teste');
$this->assertDatabaseHas('portal_resources', ['id' => $id, 'published' => false, 'rights_status' => 'authorized']);
$this->post('/management/portal-services/resources/'.$id, ['published' => true])->assertRedirect(route('services.manage'))->assertSessionHasErrors('published');
// A malware scan releases technical quarantine without changing privacy or editorial publication.
DB::table('documents')->where('id', $document->id)->update(['status' => 'active', 'data_classification' => 'public']);
$this->post('/management/portal-services/resources/'.$id, ['published' => true])->assertRedirect(route('services.manage'))->assertSessionHasNoErrors();
$this->get('/documentos/'.$id.'/download')->assertOk()->assertDownload('exemplo.txt');
$this->get('/documentos/'.$id.'?back=%2Fdocumentos%3Ftopic%3DInvestimento')->assertInertia(fn (Assert $page) => $page
->component('portal/document')->where('resource.title', 'Guia de teste')->where('backUrl', '/documentos?topic=Investimento')
->has('resource.links', 3)->missing('resource.storage_key')->missing('resource.sha256'));
$this->get('/')->assertInertia(fn (Assert $page) => $page->has('essentialResources', 1));
$this->get('/documentos')->assertInertia(fn (Assert $page) => $page->has('resources.data', 1)->where('resources.data.0.title', 'Guia de teste')->missing('resources.data.0.storage_key'));
$this->get('/documentos?q=Guia%20de%20teste&document_type=guide')->assertInertia(fn (Assert $page) => $page
->component('portal/services')->has('resources.data', 1)->where('resources.data.0.title', 'Guia de teste'));
$this->getJson('/api/v1/documents/recommendations?module=simulator&activity_id=CAE-TEST&stage=review')->assertOk()->assertJsonPath('data.0.title', 'Guia de teste');
$this->put('/management/portal-services/resources/'.$id, [...$this->documentResourcePayload($document->id, 'Guia de teste revisto'), 'metadata_version' => 2, 'description' => 'Resumo editado pelo gestor.'])
->assertRedirect(route('services.manage'))->assertSessionHasNoErrors();
$this->assertDatabaseHas('portal_resources', ['id' => $id, 'published' => true, 'title' => 'Guia de teste revisto']);
$this->assertDatabaseHas('portal_resource_revisions', ['resource_id' => $id, 'revision_no' => 3, 'change_type' => 'metadata_updated']);
DB::table('documents')->where('id', $document->id)->update(['data_classification' => 'internal']);
$this->get('/documentos/'.$id.'/download')->assertNotFound();
$this->get('/documentos')->assertInertia(fn (Assert $page) => $page->has('resources.data', 0));
$this->get('/')->assertInertia(fn (Assert $page) => $page->has('essentialResources', 0));
DB::table('documents')->where('id', $document->id)->update(['data_classification' => 'public']);
Storage::disk('local')->put($document->storage_key, 'Conteúdo alterado');
$this->get('/documentos/'.$id.'/download')->assertNotFound();
}
public function test_document_search_combines_exact_activity_and_territory_filters_without_duplicates(): void
{
Storage::fake('local');
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$this->actingAs($manager);
$provinceA = (string) Str::uuid();
$provinceB = (string) Str::uuid();
foreach ([[$provinceA, 'Província QA A'], [$provinceB, 'Província QA B']] as [$id, $name]) {
DB::table('geo_locations')->insert(['id' => $id, 'name' => $name, 'location_type' => 'province', 'code' => strtoupper(Str::random(4)),
'data_classification' => 'public', 'version_no' => 1, 'source_system' => 'qa', 'created_at' => now(), 'updated_at' => now()]);
}
$districtA = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $districtA, 'name' => 'Distrito QA A1', 'location_type' => 'district', 'parent_id' => $provinceA,
'code' => strtoupper(Str::random(4)), 'data_classification' => 'public', 'version_no' => 1, 'source_system' => 'qa', 'created_at' => now(), 'updated_at' => now()]);
$records = [];
foreach ([['A', $provinceA, 'provincial'], ['District', $districtA, 'district'], ['B', $provinceB, 'provincial'], ['National', null, 'national'], ['Unknown', null, 'unknown']] as [$suffix, $territory, $scope]) {
$file = UploadedFile::fake()->createWithContent('doc-'.$suffix.'.txt', 'document content '.$suffix);
$this->post('/management/portal-services/upload', ['file' => $file])->assertSessionHasNoErrors();
$document = DB::table('documents')->where('file_name', 'doc-'.$suffix.'.txt')->first();
$id = $this->registerDocumentResource($document->id, 'Document '.$suffix, [
'territory_id' => $territory, 'territorial_scope' => $scope,
'associations' => [
['target_type' => 'module', 'target_key' => 'simulator', 'relationship' => 'supports', 'applicability' => 'evidence', 'basis_reference' => 'Registo documental QA, secção 1.'],
['target_type' => 'module', 'target_key' => 'project_dossier', 'relationship' => 'supports', 'applicability' => 'evidence', 'basis_reference' => 'Registo documental QA, secção 2.'],
['target_type' => 'activity', 'target_key' => 'CAE-TEST', 'relationship' => 'applies_to', 'applicability' => 'evidence', 'basis_reference' => 'Registo documental QA, secção 3.'],
['target_type' => 'stage', 'target_key' => 'review', 'relationship' => 'supports', 'applicability' => 'evidence', 'basis_reference' => 'Registo documental QA, secção 4.'],
],
]);
DB::table('documents')->where('id', $document->id)->update(['status' => 'active', 'data_classification' => 'public']);
$this->post('/management/portal-services/resources/'.$id, ['published' => true])->assertSessionHasNoErrors();
$records[$suffix] = $id;
}
$query = '/documentos?'.http_build_query(['territory_id' => $provinceA, 'activity' => ['CAE-TEST'], 'module' => ['simulator'], 'stage' => ['review']]);
$this->get($query)->assertOk()->assertInertia(fn (Assert $page) => $page->has('resources.data', 3)
->where('resources.data.0.id', $records['A'])->where('resources.data.1.id', $records['District'])->where('resources.data.2.id', $records['National']));
$this->get('/documentos?'.http_build_query(['territory_id' => $provinceA, 'activity' => ['NOT-FOUND']]))
->assertInertia(fn (Assert $page) => $page->has('resources.data', 0)->where('resources.total', 0));
$this->assertDatabaseCount('portal_resource_links', 20);
}
public function test_legislation_with_an_identified_source_can_be_published_for_consultation_without_claiming_confirmation(): void
{
Storage::fake('local');
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$this->actingAs($manager)->post('/management/portal-services/upload', ['file' => UploadedFile::fake()->createWithContent('proposal.txt', 'Proposal document content.')])->assertSessionHasNoErrors();
$document = DB::table('documents')->first();
$payload = [...$this->documentResourcePayload($document->id, 'Proposta legislativa QA'), 'document_type' => 'legislation',
'legislation' => true, 'source_confirmation' => 'identified', 'legal_status' => 'proposed'];
$this->post('/management/portal-services/resources', $payload)->assertSessionHasNoErrors();
$id = (int) DB::table('portal_resources')->value('id');
DB::table('documents')->where('id', $document->id)->update(['status' => 'active', 'data_classification' => 'public']);
$this->post('/management/portal-services/resources/'.$id, ['published' => true])->assertSessionHasNoErrors();
$this->get('/legislacao')->assertInertia(fn (Assert $page) => $page->where('resources.data.0.legal_status', 'proposed')
->where('resources.data.0.source_confirmation', 'identified'));
}
public function test_recorded_user_authorization_publishes_only_eligible_document_scope_in_one_manager_action(): void
{
Storage::fake('local');
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$this->post('/management/portal-services/disclosure-authorization/apply')->assertRedirect('/login');
$this->actingAs($manager);
$resourceIds = [];
$titles = [
1 => 'Lei n.º 22/2022, de 28 de Dezembro — alteração ao Código do IVA (republicação)',
2 => 'Boletim da República n.º 248/2025 — Suplemento n.º 1',
3 => 'Boletim da República n.º 89, I Série (08-06-2017) — aviso de rectificação da Lei n.º 13/2016',
4 => 'Codificador da Divisão Político-Administrativa (INE, 07-06-2012)',
];
foreach ([1, 2, 3, 4] as $number) {
$this->post('/management/portal-services/upload', [
'file' => UploadedFile::fake()->createWithContent("reference-$number.pdf", "document bytes $number"),
'original_source' => 'https://example.gov.mz/reference',
])->assertSessionHasNoErrors();
$document = DB::table('documents')->where('file_name', "reference-$number.pdf")->firstOrFail();
$resourceIds[] = $this->registerDocumentResource($document->id, $titles[$number], [
'document_type' => $number === 3 ? 'legislation' : 'reference_data',
'legislation' => $number === 3,
'source_confirmation' => $number === 3 ? 'identified' : 'official_confirmed',
'legal_status' => $number === 3 ? 'unknown' : 'historical',
'rights_status' => 'unconfirmed', 'data_classification' => 'internal',
]);
DB::table('documents')->where('id', $document->id)->update([
'status' => 'active', 'malware_scan_status' => 'clean', 'malware_scanned_sha256' => $document->sha256,
]);
}
$this->assertCount(4, $resourceIds);
DB::table('audit_events')->insert([
'source_system' => 'user-instruction', 'external_id' => 'document-repository-disclosure-authorization-2026-10-06-v1',
'data_classification' => 'internal', 'entity_type' => 'portal_document_repository', 'entity_id' => null,
'action' => 'public_disclosure_authorized', 'actor_user_id' => null,
'after_data' => json_encode(['resource_ids' => $resourceIds], JSON_THROW_ON_ERROR),
]);
$this->post('/management/portal-services/disclosure-authorization/apply')
->assertRedirect(route('services.manage'))->assertSessionHasNoErrors();
$this->assertDatabaseCount('portal_resource_revisions', 8);
$this->assertDatabaseCount('audit_events', 13);
$this->assertSame(4, DB::table('portal_resources')->where('published', true)->count());
$this->assertSame(4, DB::table('documents')->where('data_classification', 'public')->where('rights_status', 'authorized')->count());
$this->assertDatabaseHas('portal_resources', ['id' => $resourceIds[2], 'published' => true, 'source_confirmation' => 'identified', 'legal_status' => 'unknown']);
$this->assertDatabaseHas('portal_resource_links', ['resource_id' => $resourceIds[0], 'target_type' => 'requirement',
'target_key' => 'fiscal.iva.lei_22_2022', 'review_status' => 'confirmed', 'applicability' => 'evidence']);
$this->assertDatabaseHas('portal_resource_links', ['resource_id' => $resourceIds[1], 'target_type' => 'requirement',
'target_key' => 'fiscal.iva.lei_10_2025', 'review_status' => 'pending', 'applicability' => 'evidence']);
$this->assertDatabaseHas('portal_resource_links', ['resource_id' => $resourceIds[2], 'target_type' => 'requirement',
'target_key' => 'fiscal.iva.lei_13_2016_rectification_2017', 'review_status' => 'pending', 'applicability' => 'evidence']);
$this->assertDatabaseHas('audit_events', ['source_system' => 'portal-services', 'entity_type' => 'portal_resources',
'actor_user_id' => $manager->id, 'action' => 'published_under_user_authorization']);
$this->get('/documentos')->assertInertia(fn (Assert $page) => $page->has('resources.data', 4));
$this->get('/documentos?q=22%2F2022')->assertInertia(fn (Assert $page) => $page
->has('resources.data', 1)->where('resources.data.0.id', $resourceIds[0]));
$this->get('/documentos?category=Guia&document_type=legislation')->assertInertia(fn (Assert $page) => $page
->has('resources.data', 1)->where('resources.data.0.id', $resourceIds[2]));
$this->get('/documentos/'.$resourceIds[2])->assertInertia(fn (Assert $page) => $page->component('portal/document')
->where('resource.source_confirmation', 'identified')->where('resource.legal_status', 'unknown'));
$this->get('/documentos/'.$resourceIds[0].'/download')->assertOk()->assertDownload('reference-1.pdf');
$this->assertDatabaseHas('portal_resource_links', ['resource_id' => $resourceIds[0], 'target_type' => 'module', 'target_key' => 'simulator']);
$this->post('/management/portal-services/disclosure-authorization/apply')->assertSessionHasNoErrors();
$this->assertDatabaseCount('portal_resource_revisions', 8);
}
public function test_qa_library_preserves_an_original_without_inventing_a_topic_or_auto_publishing(): void
{
Storage::fake('local');
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email], 'portal.legacy_content_qa_enabled' => false]);
$this->actingAs($manager)->post('/management/portal-services/upload', [
'file' => UploadedFile::fake()->createWithContent('original-image-reference.txt', 'Synthetic original without a thematic classification.'),
])->assertSessionHasNoErrors();
$document = DB::table('documents')->firstOrFail();
$payload = [...$this->documentResourcePayload($document->id, 'Original sem assunto'), 'topic' => '', 'category' => 'media',
'associations' => [], 'data_classification' => 'public'];
$this->post('/management/portal-services/resources', $payload)->assertSessionHasErrors('topic');
$this->assertDatabaseCount('portal_resources', 0);
config(['portal.legacy_content_qa_enabled' => true]);
$this->post('/management/portal-services/resources', $payload)->assertSessionHasNoErrors();
$resource = DB::table('portal_resources')->firstOrFail();
$this->assertSame('', $resource->topic);
$this->assertFalse($resource->published);
$this->assertDatabaseCount('portal_resource_links', 0);
$this->get('/documentos/'.$resource->id.'/download')->assertNotFound();
$this->post('/management/portal-services/resources/'.$resource->id, ['published' => true])->assertSessionHasNoErrors();
$this->get('/documentos/'.$resource->id.'/download')->assertOk();
}
public function test_manager_classifies_a_reviewed_document_public_before_separate_publication(): void
{
Storage::fake('local');
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$this->actingAs($manager)->post('/management/portal-services/upload', [
'file' => UploadedFile::fake()->createWithContent('public-guide.txt', 'A guide whose source and rights are reviewed.'),
])->assertSessionHasNoErrors();
$document = DB::table('documents')->firstOrFail();
$payload = [...$this->documentResourcePayload($document->id, 'Guia público revisto'),
'rights_status' => 'authorized', 'source_confirmation' => 'identified', 'data_classification' => 'public'];
$this->post('/management/portal-services/resources', [...$payload, 'rights_status' => 'unconfirmed'])->assertUnprocessable();
$this->assertDatabaseCount('portal_resources', 0);
$this->post('/management/portal-services/resources', $payload)->assertSessionHasNoErrors();
$resource = DB::table('portal_resources')->firstOrFail();
$this->assertDatabaseHas('documents', ['id' => $document->id, 'data_classification' => 'public', 'rights_status' => 'authorized']);
$this->assertDatabaseHas('portal_resources', ['id' => $resource->id, 'published' => false, 'approved_by' => null]);
$this->get('/documentos')->assertInertia(fn (Assert $page) => $page->has('resources.data', 0));
$this->get('/documentos/'.$resource->id.'/download')->assertNotFound();
$this->post('/management/portal-services/resources/'.$resource->id, ['published' => true])->assertSessionHasNoErrors();
$this->get('/documentos')->assertInertia(fn (Assert $page) => $page->has('resources.data', 1)->where('resources.data.0.title', 'Guia público revisto'));
$this->get('/documentos/'.$resource->id.'/download')->assertOk()->assertDownload('public-guide.txt');
}
public function test_public_pdf_preview_is_available_only_after_publication_and_hash_verification(): void
{
Storage::fake('local');
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$this->actingAs($manager)->post('/management/portal-services/upload', [
'file' => UploadedFile::fake()->create('consultation.pdf', 10, 'application/pdf'),
])->assertSessionHasNoErrors();
$document = DB::table('documents')->firstOrFail();
$payload = [...$this->documentResourcePayload($document->id, 'Ficha PDF'),
'rights_status' => 'authorized', 'source_confirmation' => 'identified', 'data_classification' => 'public'];
$this->post('/management/portal-services/resources', $payload)->assertSessionHasNoErrors();
$id = (int) DB::table('portal_resources')->value('id');
$this->get('/documentos/'.$id.'/view')->assertNotFound();
$this->post('/management/portal-services/resources/'.$id, ['published' => true])->assertSessionHasNoErrors();
$this->get('/documentos/'.$id.'/view')->assertOk()->assertHeader('Content-Type', 'application/pdf')->assertHeader('Content-Disposition', 'inline; filename=consultation.pdf');
$this->get('/documentos/'.$id)->assertInertia(fn (Assert $page) => $page->where('resource.view_url', route('services.document.view', ['id' => $id])));
Storage::disk('local')->put($document->storage_key, 'replaced bytes');
$this->get('/documentos/'.$id.'/view')->assertNotFound();
}
public function test_threat_and_unavailable_results_stay_quarantined_with_hash_history(): void
{
Storage::fake('local');
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$this->bindMalwareResult('infected', 'threat_detected');
$this->actingAs($manager)->post('/management/portal-services/upload', [
'file' => UploadedFile::fake()->createWithContent('infected-fixture.txt', 'synthetic infected scanner result fixture'),
])->assertSessionHasNoErrors();
$infected = DB::table('documents')->where('file_name', 'infected-fixture.txt')->firstOrFail();
$this->assertSame('quarantined', $infected->status);
$this->assertSame('infected', $infected->malware_scan_status);
$this->assertSame($infected->sha256, $infected->malware_scanned_sha256);
$this->assertDatabaseHas('document_malware_scans', ['document_id' => $infected->id, 'file_sha256' => $infected->sha256,
'result' => 'infected', 'reason_code' => 'threat_detected']);
$this->bindMalwareResult('inconclusive', 'scanner_unavailable');
$this->post('/management/portal-services/upload', [
'file' => UploadedFile::fake()->createWithContent('unscanned-fixture.txt', 'synthetic scanner unavailable fixture'),
])->assertSessionHasNoErrors();
$unavailable = DB::table('documents')->where('file_name', 'unscanned-fixture.txt')->firstOrFail();
$this->assertSame('quarantined', $unavailable->status);
$this->assertSame('inconclusive', $unavailable->malware_scan_status);
$this->assertSame('scanner_unavailable', $unavailable->malware_failure_reason);
$this->assertSame(0, DB::table('portal_resources')->count());
}
public function test_manager_can_prepare_private_editorial_draft_before_malware_scan_finishes(): void
{
Storage::fake('local');
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$this->bindMalwareResult('inconclusive', 'scanner_unavailable');
$this->actingAs($manager)->post('/management/portal-services/upload', [
'file' => UploadedFile::fake()->createWithContent('pending-private.txt', 'Private pending document.'),
])->assertSessionHasNoErrors();
$document = DB::table('documents')->firstOrFail();
$this->assertSame('quarantined', $document->status);
$this->post('/management/portal-services/resources', $this->documentResourcePayload($document->id, 'Rascunho privado'))
->assertSessionHasNoErrors();
$resource = DB::table('portal_resources')->firstOrFail();
$this->assertFalse((bool) $resource->published);
$this->assertSame('internal', DB::table('documents')->where('id', $document->id)->value('data_classification'));
$this->post('/management/portal-services/resources/'.$resource->id, ['published' => true])->assertRedirect(route('services.manage'))->assertSessionHasErrors('published');
$this->get('/documentos')->assertInertia(fn (Assert $page) => $page->has('resources.data', 0));
$this->get('/documentos/'.$resource->id)->assertNotFound();
$this->get('/documentos/'.$resource->id.'/download')->assertNotFound();
}
public function test_identical_upload_is_rejected_and_required_applicability_needs_a_basis(): void
{
Storage::fake('local');
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$this->actingAs($manager);
$contents = 'same document body';
$this->post('/management/portal-services/upload', ['file' => UploadedFile::fake()->createWithContent('original.txt', $contents)])->assertSessionHasNoErrors();
$document = DB::table('documents')->first();
$this->from('/management/portal-services')->post('/management/portal-services/upload', ['file' => UploadedFile::fake()->createWithContent('duplicate.txt', $contents)])
->assertSessionHasErrors('file');
$payload = $this->documentResourcePayload($document->id, 'Manual QA');
$payload['associations'] = [['target_type' => 'procedure', 'target_key' => 'licence-test', 'relationship' => 'applies_to',
'applicability' => 'required', 'condition_text' => '', 'basis_reference' => '']];
$this->from('/management/portal-services')->post('/management/portal-services/resources', $payload)->assertSessionHasErrors('associations.0.basis_reference');
$this->assertDatabaseCount('documents', 1);
$payload['associations'] = [];
$payload['issuer'] = 'Gazeta oficial QA';
$payload['document_number'] = 'BR 44';
$payload['source_reference'] = 'Referência documental sintética de QA.';
$this->post('/management/portal-services/resources', $payload)->assertSessionHasNoErrors();
$firstId = (int) DB::table('portal_resources')->value('id');
$this->post('/management/portal-services/upload', ['file' => UploadedFile::fake()->createWithContent('new-version.txt', 'different version body')])->assertSessionHasNoErrors();
$secondDocument = DB::table('documents')->where('file_name', 'new-version.txt')->first();
$this->post('/management/portal-services/resources', [...$payload, 'document_id' => $secondDocument->id, 'title' => 'Manual QA v2'])->assertSessionHasNoErrors();
$this->assertDatabaseHas('portal_resources', ['title' => 'Manual QA v2', 'duplicate_of_resource_id' => $firstId]);
$this->assertDatabaseCount('portal_resources', 2);
$this->get('/documentos')->assertInertia(fn (Assert $page) => $page->has('resources.data', 0));
}
public function test_requirement_links_use_known_technical_keys_and_expose_only_confirmed_citations_as_evidence(): void
{
Storage::fake('local');
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$this->actingAs($manager)->post('/management/portal-services/upload', [
'file' => UploadedFile::fake()->createWithContent('evidence.txt', 'Documentary source for QA.'),
])->assertSessionHasNoErrors();
$document = DB::table('documents')->firstOrFail();
$payload = $this->documentResourcePayload($document->id, 'Evidência fiscal QA');
$payload['associations'][] = ['target_type' => 'requirement', 'target_key' => 'fiscal.iva.lei_22_2022',
'relationship' => 'cites', 'applicability' => 'evidence', 'review_status' => 'confirmed',
'condition_text' => 'Evidência documental, sem activar cálculo.', 'basis_reference' => 'Lei 22/2022, art. 17(1), página 3.'];
$this->post('/management/portal-services/resources', $payload)->assertSessionHasNoErrors();
$id = (int) DB::table('portal_resources')->where('title', 'Evidência fiscal QA')->value('id');
$this->assertDatabaseHas('portal_resource_links', ['resource_id' => $id, 'target_type' => 'requirement',
'target_key' => 'fiscal.iva.lei_22_2022', 'review_status' => 'confirmed']);
$payload['associations'][] = ['target_type' => 'requirement', 'target_key' => 'legal.tax.anything',
'relationship' => 'cites', 'applicability' => 'evidence', 'review_status' => 'confirmed', 'basis_reference' => 'Sem chave aprovada.'];
$this->from('/management/portal-services')->post('/management/portal-services/resources', $payload)
->assertSessionHasErrors('associations.4.target_key');
$this->assertDatabaseCount('documents', 1);
}
public function test_public_recommendations_return_only_confirmed_requirement_citations(): void
{
Storage::fake('local');
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$this->actingAs($manager)->post('/management/portal-services/upload', [
'file' => UploadedFile::fake()->createWithContent('public-evidence.txt', 'Documentary evidence fixture.'),
])->assertSessionHasNoErrors();
$document = DB::table('documents')->firstOrFail();
$payload = $this->documentResourcePayload($document->id, 'Fontes fiscais QA');
$payload['associations'][] = ['target_type' => 'requirement', 'target_key' => 'fiscal.iva.lei_22_2022', 'relationship' => 'cites',
'applicability' => 'evidence', 'review_status' => 'confirmed', 'basis_reference' => 'Citação fixture, artigo 17(1), página 3.'];
$payload['associations'][] = ['target_type' => 'requirement', 'target_key' => 'fiscal.iva.lei_10_2025', 'relationship' => 'cites',
'applicability' => 'evidence', 'review_status' => 'pending', 'basis_reference' => 'Citação ainda não conferida no fixture.'];
$this->post('/management/portal-services/resources', $payload)->assertSessionHasNoErrors();
$id = (int) DB::table('portal_resources')->where('title', 'Fontes fiscais QA')->value('id');
DB::table('documents')->where('id', $document->id)->update(['status' => 'active', 'data_classification' => 'public']);
$this->post('/management/portal-services/resources/'.$id, ['published' => true])->assertSessionHasNoErrors();
$this->getJson('/api/v1/documents/recommendations?module=simulator')->assertOk()
->assertJsonPath('data.0.documentary_evidence.0.key', 'fiscal.iva.lei_22_2022')
->assertJsonPath('data.0.documentary_evidence.0.status', 'citation_checked_application_pending')
->assertJsonMissing(['key' => 'fiscal.iva.lei_10_2025']);
}
private function registerDocumentResource(string $documentId, string $title, array $overrides = []): int
{
$payload = [...$this->documentResourcePayload($documentId, $title), ...$overrides];
$this->post('/management/portal-services/resources', $payload)->assertSessionHasNoErrors();
return (int) DB::table('portal_resources')->where('document_id', $documentId)->where('title', $title)->value('id');
}
private function documentResourcePayload(string $documentId, string $title): array
{
return ['document_id' => $documentId, 'title' => $title, 'description' => 'Resumo sintético de controlo de qualidade.',
'category' => 'Guia', 'topic' => 'Investimento', 'document_type' => 'guide', 'issuer' => 'Entidade sintética de QA',
'document_number' => null, 'document_date' => null, 'source_url' => 'https://example.gov.mz/source', 'language' => 'pt',
'version_label' => 'QA-1', 'document_group_id' => null, 'territorial_scope' => 'national', 'territory_id' => null,
'source_confirmation' => 'identified', 'legal_status' => 'not_applicable', 'rights_status' => 'authorized',
'source_reference' => 'Fonte sintética apenas para testes isolados.', 'legislation' => false, 'essential' => true, 'independent_review' => false,
'applicable_from' => '', 'applicable_until' => '',
'associations' => [
['target_type' => 'module', 'target_key' => 'simulator', 'relationship' => 'supports', 'applicability' => 'evidence', 'condition_text' => '', 'basis_reference' => 'Teste sintético, secção 1.'],
['target_type' => 'activity', 'target_key' => 'CAE-TEST', 'relationship' => 'applies_to', 'applicability' => 'evidence', 'condition_text' => '', 'basis_reference' => 'Teste sintético, secção 2.'],
['target_type' => 'stage', 'target_key' => 'review', 'relationship' => 'supports', 'applicability' => 'evidence', 'condition_text' => '', 'basis_reference' => 'Teste sintético, secção 3.'],
]];
}
public function test_amount_filters_do_not_mix_currencies_or_expose_drafts(): void
{
$sector = Sector::create(['name' => 'Sector de teste', 'slug' => 'sector-teste', 'is_active' => true]);
foreach ([['USD', 'published'], ['MZN', 'published'], ['USD', 'draft']] as [$currency, $status]) {
Opportunity::create(['sector_id' => $sector->id, 'reference' => (string) Str::uuid(), 'slug' => (string) Str::uuid(), 'title' => 'Teste de dimensão',
'status' => $status, 'published_at' => now(), 'estimated_investment_amount' => 500, 'estimated_investment_currency' => $currency]);
}
$this->getJson('/api/v1/opportunities/search?min_amount=100')->assertUnprocessable();
$this->getJson('/api/v1/opportunities/search?currency=USD&min_amount=100&max_amount=600')->assertOk()->assertJsonPath('total', 1);
$this->getJson('/api/v1/opportunities/search?currency=USD&min_amount=600&max_amount=100')->assertUnprocessable();
}
private function bindMalwareResult(string $result, ?string $reason): void
{
$this->app->instance(MalwareScanner::class, new class($result, $reason) implements MalwareScanner
{
public function __construct(private readonly string $result, private readonly ?string $reason) {}
public function scan(string $path, int $fileSizeBytes, string $storageRoot): array
{
return ['result' => $this->result, 'reason_code' => $this->reason, 'engine' => 'ClamAV-Test-Double',
'engine_version' => 'fixture', 'signatures_at' => new \DateTimeImmutable('2026-10-05T00:00:00Z'), 'duration_ms' => 1, 'detail' => null];
}
public function health(): array
{
return ['available' => true, 'engine' => 'ClamAV-Test-Double', 'engine_version' => 'fixture',
'signatures_at' => new \DateTimeImmutable('2026-10-05T00:00:00Z'), 'signature_age_hours' => 0.0, 'reason_code' => null];
}
});
}
}