seed(InvestmentPortfolioSeeder::class); $dossier = InvestmentDossier::firstOrFail(); $dossier->update(['version_no' => $dossier->version_no, 'title' => 'Revisão humana preservada']); $this->seed(InvestmentPortfolioSeeder::class); $this->assertDatabaseCount('projects', 15); $this->assertDatabaseCount('economic_clusters', 8); $this->assertDatabaseCount('development_corridors', 6); $this->assertDatabaseCount('investment_dossiers', 29); $this->assertSame('Revisão humana preservada', $dossier->fresh()->title); $this->assertSame(15, DB::table('corridor_projects')->distinct()->count('project_id')); $this->assertDatabaseHas('cluster_projects', ['role' => 'anchor']); $this->get('/carteira')->assertInertia(fn (Assert $page) => $page->component('portfolio/index')->has('records.data', 0)); $this->get('/carteira/'.$dossier->id)->assertNotFound(); } public function test_stale_editor_cannot_overwrite_or_publish_a_newer_version(): void { $this->seed(InvestmentPortfolioSeeder::class); $manager = User::factory()->create(); $reviewer = User::factory()->create(); config(['portal.managers' => [$manager->email, $reviewer->email]]); $dossier = InvestmentDossier::firstOrFail(); $url = '/management/portfolio/'.$dossier->id; $payload = ['version_no' => $dossier->version_no, 'title' => 'Revisao mais recente', 'source' => $dossier->source, 'content' => $dossier->content]; $this->actingAsWithMfa($manager)->put($url, $payload)->assertRedirect()->assertSessionHasNoErrors(); $this->putJson($url, [...$payload, 'title' => 'Alteracao obsoleta'])->assertStatus(409); $approval = ['version_no' => $dossier->version_no, 'approval_evidence' => 'Aprovacao sintetica da versao revista']; $this->postJson($url.'/publish', $approval)->assertStatus(409); $this->assertSame('Revisao mais recente', $dossier->fresh()->title); $this->assertSame('draft', $dossier->fresh()->publication_status); $this->assertSame(2, $dossier->fresh()->version_no); $this->post($url.'/publish', [...$approval, 'version_no' => 2])->assertForbidden(); $this->actingAs($reviewer)->post($url.'/publish', [...$approval, 'version_no' => 2])->assertRedirect()->assertSessionHasNoErrors(); $this->assertSame(3, $dossier->fresh()->version_no); $this->assertSame('published', $dossier->fresh()->publication_status); $this->postJson($url.'/publish', [...$approval, 'version_no' => 2])->assertStatus(409); $this->putJson($url, array_diff_key($payload, ['version_no' => true]))->assertUnprocessable()->assertJsonValidationErrors('version_no'); $this->get('/carteira/'.$dossier->id)->assertInertia(fn (Assert $page) => $page->missing('record.version_no')->missing('record.approval_evidence')); $audit = DB::table('audit_events')->where('entity_id', $dossier->id)->orderByDesc('created_at')->get(); $this->assertCount(2, $audit); $this->assertStringNotContainsString('Aprovacao sintetica', json_encode($audit)); } private function publish(): InvestmentDossier { $this->seed(InvestmentPortfolioSeeder::class); $editor = User::factory()->create(); $reviewer = User::factory()->create(); config(['portal.managers' => [$editor->email, $reviewer->email]]); $dossier = InvestmentDossier::whereNotNull('project_id')->firstOrFail(); $this->actingAs($editor)->put('/management/portfolio/'.$dossier->id, ['version_no' => $dossier->version_no, 'title' => $dossier->title, 'source' => $dossier->source, 'content' => $dossier->content])->assertSessionHasNoErrors(); $evidence = ['approval_evidence' => 'Autorização fictícia apenas para teste']; $this->post('/management/portfolio/'.$dossier->id.'/publish', [...$evidence, 'version_no' => $dossier->fresh()->version_no])->assertForbidden(); $this->actingAs($reviewer)->post('/management/portfolio/'.$dossier->id.'/publish', [...$evidence, 'version_no' => $dossier->fresh()->version_no])->assertSessionHasNoErrors()->assertRedirect(); $this->assertSame($reviewer->id, $dossier->fresh()->reviewer_id); return $dossier->fresh(); } public function test_authorized_manager_can_publish_and_internal_evidence_is_hidden(): void { $dossier = $this->publish(); $this->get('/carteira/'.$dossier->id)->assertInertia(fn (Assert $page) => $page->component('portfolio/show') ->where('record.title', $dossier->title)->missing('record.approval_evidence')->missing('record.editor_id')->has('related', 0)); $this->put('/management/portfolio/'.$dossier->id, ['version_no' => $dossier->version_no, 'title' => 'Conteúdo alterado', 'source' => $dossier->source, 'content' => $dossier->content])->assertSessionHasNoErrors(); $this->get('/carteira/'.$dossier->id)->assertNotFound(); $this->actingAs(User::factory()->create())->get('/management/portfolio')->assertForbidden(); $this->post('/management/portfolio/'.$dossier->id.'/publish', ['approval_evidence' => 'Tentativa sem privilégios de gestão'])->assertForbidden(); } public function test_import_keeps_documentary_dossiers_private_and_preserves_later_edits(): void { $approval = 'Aprovação expressa do proprietário para executar a carteira v4.'; $this->artisan('portfolio:import')->assertSuccessful(); $this->assertSame(0, InvestmentDossier::where('publication_status', 'published')->count()); $this->assertSame(29, InvestmentDossier::where('publication_status', 'draft')->count()); $this->assertSame(0, DB::table('audit_events')->where('action', 'owner_approved')->count()); $dossier = InvestmentDossier::firstOrFail(); $manager = User::factory()->create(); config(['portal.managers' => [$manager->email]]); $this->actingAsWithMfa($manager)->post('/management/portfolio/'.$dossier->id.'/publish', [ 'version_no' => $dossier->version_no, 'approval_evidence' => 'Synthetic owner approval is not an editor assignment', ])->assertStatus(422); $this->get('/carteira/'.$dossier->id)->assertNotFound(); $this->artisan('portfolio:import')->assertSuccessful(); $this->assertSame(0, DB::table('audit_events')->where('action', 'owner_approved')->count()); $editor = User::factory()->create(); config(['portal.managers' => [$editor->email]]); $this->actingAs($editor)->put('/management/portfolio/'.$dossier->id, [ 'version_no' => $dossier->version_no, 'title' => 'Alteração posterior', 'source' => $dossier->source, 'content' => $dossier->content, ])->assertSessionHasNoErrors(); $this->artisan('portfolio:import')->assertSuccessful(); $this->assertSame('draft', $dossier->fresh()->publication_status); $this->assertSame('Alteração posterior', $dossier->fresh()->title); $this->get('/carteira/'.$dossier->id)->assertNotFound(); } public function test_personal_journey_is_isolated_and_retries_do_not_duplicate_interest_or_event(): void { $dossier = $this->publish(); $owner = User::factory()->create(); $other = User::factory()->create(); $this->actingAs($owner)->post('/investidor/guardar/'.$dossier->id)->assertRedirect('/investidor'); $this->post('/investidor/guardar/'.$dossier->id)->assertRedirect(); $this->assertDatabaseCount('investor_journeys', 1); $journey = InvestorJourney::firstOrFail(); $payload = ['message' => 'Pretendo investir neste projecto de teste.', 'country' => 'Moçambique', 'privacy_consent' => true, 'role' => 'investir', 'horizon' => '12 meses', 'declared_origin_country_code' => 'PT']; $url = '/investidor/'.$journey->id.'/interesse'; $this->postJson($url, [...$payload, 'declared_origin_country_code' => 'ZZ'])->assertUnprocessable(); $this->assertDatabaseCount('interest_requests', 0); $this->post($url, [...$payload, 'privacy_consent' => false])->assertSessionHasErrors('privacy_consent'); $this->actingAs($other)->post($url, $payload)->assertNotFound(); $this->get('/investidor')->assertInertia(fn (Assert $page) => $page->has('journeys', 0)); $this->actingAs($owner)->post($url, $payload)->assertSessionHasNoErrors()->assertRedirect(); $this->post($url, $payload)->assertSessionHasNoErrors(); $this->assertDatabaseCount('interest_requests', 1); $this->assertDatabaseCount('outbox_events', 1); $this->assertDatabaseHas('investor_journeys', ['id' => $journey->id, 'status' => 'active']); $metadata = json_decode(DB::table('interest_requests')->value('metadata'), true); $this->assertSame($journey->id, $metadata['journey_id']); $this->assertSame('PT', $metadata['declared_origin_country_code']); $this->assertSame($dossier->project_id, $metadata['project_id']); $this->get('/investidor')->assertInertia(fn (Assert $page) => $page->has('journeys', 1)->where('journeys.0.request_status', 'received') ->where('journeys.0.country', 'Moçambique')->where('journeys.0.declared_origin.code', 'PT')); $conversion = ['owner_user_id' => $owner->id, 'title' => 'Interesse da jornada', 'next_action' => 'Contactar investidor', 'due_at' => now()->addDay()->toIso8601String()]; $interest = InterestRequest::firstOrFail(); try { app(ConvertInterestToLead::class)->convert($interest, $conversion, $owner->id); $this->fail('A conversão deve preservar o investidor da jornada.'); } catch (ValidationException $exception) { $this->assertArrayHasKey('investor_id', $exception->errors()); } $lead = app(ConvertInterestToLead::class)->convert($interest, [...$conversion, 'investor_id' => $journey->investor_id], $owner->id); $this->assertSame($journey->investor_id, $lead->investor_id); $this->assertDatabaseCount('investors', 1); config(['portal.managers' => [$owner->email]]); $this->actingAsWithMfa($owner); $this->get('/management/interests?origin=PT')->assertInertia(fn (Assert $page) => $page ->where('requests.total', 1)->where('requests.data.0.declaredOrigin.code', 'PT') ->where('originStatistics.interests.0.code', 'PT')->where('originStatistics.interests.0.count', 1)); $this->get('/management/interests?origin=MZ')->assertInertia(fn (Assert $page) => $page->where('requests.total', 0)->has('originStatistics.interests', 0)); } public function test_private_manifestation_link_requires_authentication_and_preserves_the_public_dossier(): void { $dossier = $this->publish(); $this->app['auth']->forgetGuards(); $dossierId = $dossier->id; $owner = User::factory()->create(); $other = User::factory()->create(); $this->postJson('/api/v1/investment/submissions', ['dossier_id' => $dossierId])->assertUnauthorized(); $this->assertDatabaseCount('investment_submissions', 0); $this->actingAs($owner)->get('/carteira/'.$dossierId) ->assertInertia(fn (Assert $page) => $page->component('portfolio/show')->has('existingSubmissions', 0)); $submissionId = $this->postJson('/api/v1/investment/submissions', ['dossier_id' => $dossierId]) ->assertCreated()->json('id'); $this->assertDatabaseHas('investment_submissions', [ 'id' => $submissionId, 'user_id' => $owner->id, 'dossier_id' => $dossierId, 'status' => 'draft', ]); $this->assertSame('published', $dossier->fresh()->publication_status); $this->actingAs($owner)->get('/carteira/'.$dossierId) ->assertInertia(fn (Assert $page) => $page->has('existingSubmissions', 1)->where('existingSubmissions.0.id', $submissionId)); $this->actingAs($other)->get('/investidor/manifestacoes/'.$submissionId)->assertNotFound(); $this->assertDatabaseCount('investment_submissions', 1); $this->assertDatabaseCount('investment_dossiers', 29); } public function test_unpublished_and_deleted_entities_cannot_start_journeys(): void { $dossier = $this->publish(); DB::table('projects')->where('id', $dossier->project_id)->update(['deleted_at' => now()]); $this->get('/carteira/'.$dossier->id)->assertNotFound(); $this->post('/investidor/guardar/'.$dossier->id)->assertNotFound(); $this->assertDatabaseCount('investor_journeys', 0); } public function test_origin_context_is_validated_and_preserved_in_the_interest(): void { $project = $this->publish(); $corridorId = DB::table('corridor_projects')->where('project_id', $project->project_id)->value('corridor_id'); $origin = InvestmentDossier::where('corridor_id', $corridorId)->firstOrFail(); $origin->update(['editor_id' => $project->editor_id, 'reviewer_id' => $project->reviewer_id, 'publication_status' => 'published', 'published_at' => now(), 'approval_evidence' => 'Teste de contexto autorizado']); $this->actingAs(User::factory()->create())->post('/investidor/guardar/'.$project->id, ['origin_id' => $origin->id])->assertStatus(422); DB::table('corridor_projects')->where('project_id', $project->project_id)->where('corridor_id', $corridorId)->update([ 'validation_status' => 'validated', 'source_reference' => 'Fixture de teste com fonte identificada', 'evidence_reference' => 'fixture:test:association', 'valid_from' => today()->toDateString(), ]); $other = InvestmentDossier::whereNotNull('project_id')->where('id', '!=', $project->id)->firstOrFail(); $other->update(['editor_id' => $project->editor_id, 'reviewer_id' => $project->reviewer_id, 'publication_status' => 'published', 'published_at' => now(), 'approval_evidence' => 'Teste de contexto autorizado']); $this->actingAs(User::factory()->create())->post('/investidor/guardar/'.$project->id, ['origin_id' => $other->id])->assertStatus(422); $this->post('/investidor/guardar/'.$project->id, ['origin_id' => $origin->id])->assertRedirect(); $journey = InvestorJourney::firstOrFail(); $this->post('/investidor/'.$journey->id.'/interesse', ['message' => 'Participar no corredor através deste projecto.', 'country' => 'Moçambique', 'privacy_consent' => true, 'role' => 'financiar', 'horizon' => '24 meses'])->assertSessionHasNoErrors(); $metadata = json_decode(DB::table('interest_requests')->value('metadata'), true); $this->assertSame($origin->corridor_id, $metadata['corridor_id']); $this->assertSame($project->project_id, $metadata['project_id']); } }