actingAs($this->manager()); $sector = $this->sector(); for ($i = 0; $i < 22; $i++) { Opportunity::create(['sector_id' => $sector->id, 'reference' => 'SOL-'.$i, 'title' => 'Solar '.$i, 'slug' => 'solar-'.$i, 'status' => $i === 21 ? 'draft' : 'published', 'published_at' => now()->subMinute()]); } $url = '/management/opportunities?q=SOLAR&status=published§or_id='.$sector->id; $this->get($url)->assertInertia(fn (AssertableInertia $page) => $page ->where('records.total', 21)->has('records.data', 20) ->where('records.next_page_url', fn ($url) => str_contains($url, 'status=published') && str_contains($url, 'q=SOLAR'))); $this->get($url.'&page=2')->assertInertia(fn (AssertableInertia $page) => $page ->has('records.data', 1)->where('records.data.0.reference', 'SOL-0')); $this->get('/management/opportunities?q=missing')->assertInertia(fn (AssertableInertia $page) => $page->where('records.total', 0)); $sector->update(['is_active' => false]); $this->get('/management/sectors?status=active')->assertInertia(fn (AssertableInertia $page) => $page->where('records.total', 0)); $this->get('/management/sectors?status=inactive&q=ENERGIA')->assertInertia(fn (AssertableInertia $page) => $page->where('records.total', 1)); $this->getJson('/management/opportunities?status=invalid')->assertUnprocessable(); $this->getJson('/management/opportunities?page=-1')->assertUnprocessable(); } public function test_unspecified_geography_has_a_private_review_filter_without_assigning_a_location(): void { $this->actingAs($this->manager()); $sector = $this->sector(); $pending = Opportunity::create(['sector_id' => $sector->id, 'reference' => 'GEO-PENDING', 'slug' => 'geo-pending', 'title' => 'Pendência territorial', 'status' => 'published', 'published_at' => now()->subMinute(), 'geography_scope' => 'unspecified', 'primary_territory_id' => null]); $specific = Opportunity::create(['sector_id' => $sector->id, 'reference' => 'GEO-SPECIFIC', 'slug' => 'geo-specific', 'title' => 'Território específico', 'status' => 'published', 'published_at' => now()->subMinute(), 'geography_scope' => 'specific']); $this->get('/management/opportunities?geography_scope=unspecified')->assertInertia(fn (AssertableInertia $page) => $page ->where('geographyReviewCount', 1)->where('records.total', 1) ->where('records.data.0.id', $pending->id)->where('records.data.0.geography_scope', 'unspecified')); $this->assertNull($pending->fresh()->primary_territory_id); $this->assertSame('specific', $specific->fresh()->geography_scope); } protected function beforeRefreshingDatabase(): void { $this->assertSame('testing', app()->environment()); $this->assertStringEndsWith('_test', config('database.connections.pgsql.database')); } private function manager(): User { $user = User::factory()->create(); config(['portal.managers' => [strtolower($user->email)]]); return $user; } private function sector(): Sector { return Sector::create(['name' => 'Energia', 'slug' => 'energia', 'is_active' => true]); } private function opportunity(array $attributes = []): Opportunity { return Opportunity::create(array_merge([ 'sector_id' => $this->sector()->id, 'reference' => 'INV-TEST-001', 'title' => 'Energia solar', 'slug' => 'energia-solar', 'status' => 'published', 'published_at' => now()->subMinute(), ], $attributes)); } public function test_management_requires_an_explicit_verified_manager(): void { $this->get('/management/interests')->assertRedirect('/login'); $user = User::factory()->create(); $this->actingAs($user)->get('/management/interests')->assertForbidden(); $this->post('/management/sectors', ['name' => 'Intruso'])->assertForbidden(); config(['portal.managers' => [$user->email]]); $user->forceFill(['email_verified_at' => null])->save(); $this->get('/management/interests')->assertRedirect(route('verification.notice')); $this->actingAs($this->manager())->get('/management/interests')->assertOk(); } public function test_manager_creates_and_edits_sectors_and_invalidates_home_cache(): void { $this->actingAs($this->manager()); Cache::put('portal_home_pt', ['old' => true]); $data = ['name' => 'Energia', 'slug' => 'energia', 'sort_order' => 1, 'is_active' => true]; $this->post('/management/sectors', $data)->assertRedirect('/management/sectors'); $this->assertNull(Cache::get('portal_home_pt')); $sector = Sector::firstOrFail(); $this->put('/management/sectors/'.$sector->id, [...$data, 'is_active' => false])->assertRedirect(); $this->assertFalse($sector->fresh()->is_active); $this->post('/management/sectors', $data)->assertSessionHasErrors('slug'); $this->get('/management/sectors/create')->assertOk(); $this->get('/management/sectors/'.$sector->id.'/edit')->assertOk(); } public function test_opportunities_can_be_published_and_withdrawn_without_losing_money_precision(): void { $this->actingAs($this->manager()); $data = ['sector_id' => $this->sector()->id, 'reference' => 'INV-TEST', 'title' => 'Solar', 'slug' => 'solar', 'status' => 'draft', 'is_featured' => true, 'maturity_level' => 4, 'estimated_investment_amount' => '1234567890123456.78', 'estimated_investment_currency' => 'MZN']; $this->post('/management/opportunities', $data)->assertRedirect('/management/opportunities'); $opportunity = Opportunity::firstOrFail(); $this->getJson('/api/v1/opportunities/solar')->assertNotFound(); $this->put('/management/opportunities/'.$opportunity->id, [...$data, 'status' => 'published'])->assertRedirect(); $this->getJson('/api/v1/opportunities/solar')->assertOk() ->assertJsonPath('data.estimated_investment_amount', '1234567890123456.78') ->assertJsonPath('data.maturity_label', 'Projecto estruturado'); $this->getJson('/api/v1/home')->assertJsonPath('data.stats.opportunities', 1); $this->put('/management/opportunities/'.$opportunity->id, [...$data, 'status' => 'archived'])->assertRedirect(); $this->getJson('/api/v1/opportunities/solar')->assertNotFound(); $this->getJson('/api/v1/home')->assertJsonPath('data.stats.opportunities', 0); $this->put('/management/opportunities/'.$opportunity->id, [...$data, 'estimated_investment_amount' => '-1'])->assertSessionHasErrors('estimated_investment_amount'); } public function test_review_tracks_the_authenticated_reviewer_and_rejects_invalid_states(): void { $manager = $this->manager(); $interest = InterestRequest::create(['reference' => 'IR-TEST', 'full_name' => 'Pessoa de teste', 'email' => 'test@example.invalid', 'investor_type' => 'empresa', 'country' => 'Moçambique', 'sector_interest' => 'Energia', 'message' => 'Pedido de informação para teste.', 'privacy_consent' => true]); $this->actingAs($manager)->get('/management/interests/'.$interest->id)->assertOk(); $this->patch('/management/interests/'.$interest->id, ['status' => 'in_review', 'reviewed_by' => 999, 'next_action' => 'Contactar o investidor', 'next_action_due_at' => now()->addDays(2)->toDateString(), 'assigned_to' => $manager->id, 'note' => 'O pedido foi recebido e encaminhado para análise interna.', 'next_action' => 'Contactar o investidor', 'next_action_due_at' => now()->addDays(2)->toDateString(), 'assigned_to' => $manager->id])->assertRedirect(); $this->assertSame($manager->id, $interest->fresh()->reviewed_by); $this->assertNotNull($interest->fresh()->reviewed_at); $transition = DB::table('business_workflow_transitions')->where('interest_request_id', $interest->id)->first(); $this->assertSame('received', $transition->from_status); $this->assertSame('in_review', $transition->to_status); $this->assertSame($manager->id, $interest->fresh()->assigned_to); $this->assertDatabaseHas('tasks', ['interest_request_id' => $interest->id, 'assignee_user_id' => $manager->id, 'title' => 'Contactar o investidor', 'status' => 'open']); $this->patch('/management/interests/'.$interest->id, ['status' => 'in_review', 'next_action' => 'Contactar o investidor', 'next_action_due_at' => now()->addDays(2)->toDateString(), 'assigned_to' => $manager->id])->assertRedirect(); $this->assertSame(1, DB::table('tasks')->where('interest_request_id', $interest->id)->where('status', 'open')->count()); $this->assertDatabaseHas('tasks', ['interest_request_id' => $interest->id, 'assignee_user_id' => $manager->id, 'title' => 'Contactar o investidor', 'status' => 'open']); $this->patch('/management/interests/'.$interest->id, ['status' => 'in_review', 'next_action' => 'Enviar resumo ao investidor', 'next_action_due_at' => now()->addDays(4)->toDateString(), 'assigned_to' => $manager->id])->assertRedirect(); $this->assertDatabaseHas('tasks', ['interest_request_id' => $interest->id, 'title' => 'Contactar o investidor', 'status' => 'cancelled', 'outcome' => 'superseded']); $this->assertSame(1, DB::table('tasks')->where('interest_request_id', $interest->id)->where('status', 'open')->count()); $this->patch('/management/interests/'.$interest->id, ['status' => 'in_review'])->assertRedirect(); $this->assertSame('Enviar resumo ao investidor', $interest->fresh()->next_action); $this->get('/management/interests?follow_up=upcoming')->assertInertia(fn (AssertableInertia $page) => $page ->component('management/interests')->where('requests.data.0.reference', 'IR-TEST')->where('filters.follow_up', 'upcoming')); $this->assertSame('O pedido foi recebido e encaminhado para análise interna.', Crypt::decryptString($transition->reason)); $this->assertSame($manager->id, $interest->fresh()->assigned_to); $this->patch('/management/interests/'.$interest->id, ['status' => 'received'])->assertSessionHasErrors('status'); $this->patch('/management/interests/'.$interest->id, ['status' => 'invalid'])->assertSessionHasErrors('status'); } public function test_public_requests_reject_missing_draft_deleted_and_future_opportunities(): void { $opportunity = $this->opportunity(['status' => 'draft']); $payload = ['fullName' => 'Teste do portal', 'email' => 'invalid', 'investorType' => 'empresa', 'country' => 'Moçambique', 'sectorInterest' => 'Energia', 'message' => 'Pedido para verificar o contrato.', 'privacyConsent' => true, 'opportunityReference' => 'MISSING']; $this->postJson('/api/v1/interest-requests', $payload)->assertUnprocessable()->assertJsonValidationErrors('opportunityReference'); $payload['opportunityReference'] = $opportunity->reference; $this->postJson('/api/v1/interest-requests', $payload)->assertUnprocessable()->assertJsonValidationErrors('opportunityReference'); $opportunity->update(['status' => 'published', 'published_at' => now()->addDay()]); $this->getJson('/api/v1/opportunities/'.$opportunity->slug)->assertNotFound(); $this->postJson('/api/v1/interest-requests', $payload)->assertUnprocessable()->assertJsonValidationErrors('opportunityReference'); $opportunity->update(['published_at' => now()->subMinute()]); $opportunity->delete(); $this->postJson('/api/v1/interest-requests', $payload)->assertUnprocessable()->assertJsonValidationErrors('opportunityReference'); $this->assertDatabaseCount('interest_requests', 0); } public function test_proponent_sharing_consent_can_be_revoked_with_audited_outbox_event(): void { $manager = $this->manager(); $interest = InterestRequest::create(['reference' => 'IR-CONSENT', 'full_name' => 'Pessoa de teste', 'email' => 'consent@example.invalid', 'investor_type' => 'empresa', 'country' => 'MZ', 'sector_interest' => 'Energia', 'message' => 'Pedido de partilha para teste.', 'privacy_consent' => true]); $shareConsentId = (string) Str::uuid(); DB::table('consents')->insert(['id' => $shareConsentId, 'source_system' => 'portal-intake', 'external_id' => 'test-share-consent', 'version_no' => 1, 'data_classification' => 'confidential', 'correlation_id' => (string) Str::uuid(), 'subject_ref' => $interest->reference, 'purpose' => 'share_contact_with_proponent', 'status' => 'granted', 'granted_at' => now(), 'interest_request_id' => $interest->id, 'policy_version' => '2026-09-30', 'created_at' => now(), 'updated_at' => now()]); $this->actingAs($manager)->patch('/management/interests/'.$interest->id.'/consent/proponent', [ 'reason' => 'O titular pediu para não partilhar os seus dados com o proponente.', ])->assertRedirect(); $this->assertDatabaseHas('consents', ['id' => $shareConsentId, 'status' => 'withdrawn']); $this->assertDatabaseHas('business_workflow_transitions', ['interest_request_id' => $interest->id, 'to_status' => 'received']); $event = DB::table('outbox_events')->where('event_type', 'InterestConsentWithdrawn')->first(); $this->assertNotNull($event); $this->assertSame($shareConsentId, $event->aggregate_id); $this->assertStringNotContainsString('consent@example.invalid', $event->payload); } public function test_contact_consent_revocation_blocks_conversion_and_emits_no_duplicate_events(): void { $manager = $this->manager(); $interest = InterestRequest::create(['reference' => 'IR-CONTACT-CONSENT', 'integration_id' => (string) Str::uuid(), 'full_name' => 'Pessoa de teste', 'email' => 'contact-consent@example.invalid', 'investor_type' => 'empresa', 'country' => 'MZ', 'sector_interest' => 'Energia', 'message' => 'Pedido de contacto para teste.', 'privacy_consent' => true]); $consentId = (string) Str::uuid(); DB::table('consents')->insert(['id' => $consentId, 'source_system' => 'portal-intake', 'external_id' => 'test-contact-consent', 'version_no' => 1, 'data_classification' => 'confidential', 'correlation_id' => (string) Str::uuid(), 'subject_ref' => $interest->reference, 'purpose' => 'respond_to_interest', 'status' => 'granted', 'granted_at' => now(), 'interest_request_id' => $interest->id, 'policy_version' => '2026-09-30', 'created_at' => now(), 'updated_at' => now()]); $this->actingAs($manager)->get('/management/interests/'.$interest->id) ->assertInertia(fn (AssertableInertia $page) => $page ->where('consents.0.purpose', 'respond_to_interest')->where('consents.0.status', 'granted')); $this->actingAs(User::factory()->create()) ->patch('/management/interests/'.$interest->id.'/consent/contact', [ 'reason' => 'O titular pediu para cessar o contacto sobre o pedido.', ])->assertForbidden(); $this->actingAs($manager); $this->patch('/management/interests/'.$interest->id.'/consent/contact', [ 'reason' => 'O titular pediu para cessar o contacto sobre o pedido.', ])->assertRedirect(); $this->patch('/management/interests/'.$interest->id.'/consent/contact', [ 'reason' => 'O titular pediu para cessar o contacto sobre o pedido.', ])->assertRedirect(); $this->post('/management/crm/convert/'.$interest->id, [ 'legal_name' => 'Empresa de teste', 'investor_type' => 'domestic', 'owner_user_id' => $manager->id, 'title' => 'Lead de teste', 'next_action' => 'Rever pedido', 'due_at' => now()->addDay()->toIso8601String(), ])->assertSessionHasErrors('investor_id'); $this->assertDatabaseHas('consents', ['id' => $consentId, 'status' => 'withdrawn']); $this->assertDatabaseHas('business_workflow_transitions', ['interest_request_id' => $interest->id, 'to_status' => 'received']); $event = DB::table('outbox_events')->where('event_type', 'InterestConsentWithdrawn') ->where('aggregate_id', $consentId)->first(); $this->assertNotNull($event); $this->assertStringNotContainsString('contact-consent@example.invalid', $event->payload); $this->assertStringContainsString('respond_to_interest', $event->payload); $this->assertSame(1, DB::table('outbox_events')->where('event_type', 'InterestConsentWithdrawn') ->where('aggregate_id', $consentId)->count()); $this->assertSame(1, DB::table('business_workflow_transitions') ->where('interest_request_id', $interest->id)->count()); $this->assertDatabaseCount('leads', 0); } public function test_overdue_task_escalation_is_opt_in_and_idempotent(): void { $interest = InterestRequest::create(['reference' => 'IR-SLA', 'full_name' => 'Pessoa de teste', 'email' => 'sla@example.invalid', 'investor_type' => 'empresa', 'country' => 'MZ', 'sector_interest' => 'Energia', 'message' => 'Pedido de teste de escalada.', 'privacy_consent' => true]); DB::table('tasks')->insert(['source_system' => 'portal-interest', 'external_id' => 'sla-test-task', 'version_no' => 1, 'data_classification' => 'confidential', 'correlation_id' => (string) Str::uuid(), 'interest_request_id' => $interest->id, 'task_type' => 'interest_follow_up', 'title' => 'Contactar', 'status' => 'open', 'priority' => 3, 'due_at' => now()->subHour(), 'created_at' => now(), 'updated_at' => now()]); config(['existing-business.sla_escalation_enabled' => false]); $this->artisan('investmoz:escalate-overdue-tasks')->assertSuccessful(); $this->assertDatabaseHas('tasks', ['external_id' => 'sla-test-task', 'priority' => 3]); config(['existing-business.sla_escalation_enabled' => true]); $this->artisan('investmoz:escalate-overdue-tasks')->assertSuccessful(); $this->artisan('investmoz:escalate-overdue-tasks')->assertSuccessful(); $this->assertDatabaseHas('tasks', ['external_id' => 'sla-test-task', 'priority' => 1, 'outcome' => 'sla_overdue']); } public function test_valid_public_submissions_persist_and_return_distinct_references(): void { // DNS validation remains enabled; no mail is sent by this endpoint. $opportunity = $this->opportunity(); $payload = ['fullName' => 'Pessoa de teste', 'email' => 'portal-integration-test@gmail.com', 'investorType' => 'empresa', 'country' => 'Moçambique', 'sectorInterest' => 'Energia', 'message' => 'Pedido fictício na base isolada de testes.', 'privacyConsent' => true, 'opportunityReference' => $opportunity->reference]; $first = $this->postJson('/api/v1/interest-requests', $payload)->assertCreated()->json('data.reference'); $second = $this->postJson('/api/v1/interest-requests', $payload)->assertCreated()->json('data.reference'); $this->assertNotSame($first, $second); $this->assertMatchesRegularExpression('/^IR-\d{4}-[0-9A-Z]{26}$/', $first); $this->assertDatabaseHas('interest_requests', ['reference' => $first, 'opportunity_id' => $opportunity->id, 'privacy_consent' => true]); $this->postJson('/api/v1/interest-requests', [...$payload, 'privacyConsent' => false])->assertUnprocessable()->assertJsonValidationErrors('privacyConsent'); $this->assertDatabaseCount('interest_requests', 2); } }