306 lines
21 KiB
PHP
306 lines
21 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\OpportunityManagement;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Inertia\Testing\AssertableInertia;
|
|
use Tests\Support\AuthenticatesQaActors;
|
|
use Tests\TestCase;
|
|
|
|
class PortalManagementTest extends TestCase
|
|
{
|
|
use AuthenticatesQaActors;
|
|
use RefreshDatabase;
|
|
|
|
public function test_catalog_filters_combine_and_survive_pagination(): void
|
|
{
|
|
$this->actingAsWithMfa($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->actingAsWithMfa($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]]);
|
|
$this->actingAsWithMfa($user);
|
|
$user->forceFill(['email_verified_at' => null])->save();
|
|
$this->get('/management/interests')->assertRedirect(route('verification.notice'));
|
|
$this->actingAsWithMfa($this->manager())->get('/management/interests')->assertOk();
|
|
}
|
|
|
|
public function test_manager_creates_and_edits_sectors_and_invalidates_home_cache(): void
|
|
{
|
|
$this->actingAsWithMfa($this->manager());
|
|
Cache::put('portal_home_pt', ['old' => true]);
|
|
$data = ['name' => 'Energia', 'slug' => 'energia', 'sort_order' => 1, 'is_active' => true, 'editorial_published' => false];
|
|
$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_draft_money_precision_is_preserved_without_editorial_publication_bypass(): void
|
|
{
|
|
$this->actingAsWithMfa($this->manager());
|
|
$data = ['sector_id' => $this->sector()->id, 'reference' => 'INV-TEST', 'title' => 'Solar',
|
|
'slug' => 'solar', 'is_featured' => true, 'maturity_level' => 4,
|
|
'estimated_investment_amount' => '1234567890123456.78', 'estimated_investment_currency' => 'MZN'];
|
|
$this->postJson('/management/opportunities/drafts', [...$data, 'creation_key' => (string) Str::uuid(), 'reason' => 'Rascunho financeiro incompleto em QA'])->assertCreated();
|
|
$opportunity = Opportunity::firstOrFail();
|
|
$this->getJson('/api/v1/opportunities/solar')->assertNotFound();
|
|
$this->assertSame('1234567890123456.78', $opportunity->estimated_investment_amount);
|
|
$this->put('/management/opportunities/'.$opportunity->id, [...$data, 'status' => 'published'])->assertSessionHasErrors('status');
|
|
$this->getJson('/api/v1/home')->assertJsonPath('data.stats.opportunities', 0);
|
|
$this->putJson('/management/opportunities/'.$opportunity->id.'/draft', [...$data, 'etag' => app(OpportunityManagement::class)->etag($opportunity), 'reason' => 'Correção financeira testada apenas em QA', 'estimated_investment_amount' => '-1'])->assertUnprocessable()->assertJsonValidationErrors('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->actingAsWithMfa($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
|
|
{
|
|
$this->actingAs(User::factory()->create());
|
|
$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, ['Idempotency-Key' => (string) Str::uuid()])->assertUnprocessable()->assertJsonValidationErrors('opportunityReference');
|
|
$payload['opportunityReference'] = $opportunity->reference;
|
|
$this->postJson('/api/v1/interest-requests', $payload, ['Idempotency-Key' => (string) Str::uuid()])->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, ['Idempotency-Key' => (string) Str::uuid()])->assertUnprocessable()->assertJsonValidationErrors('opportunityReference');
|
|
$opportunity->update(['published_at' => now()->subMinute()]);
|
|
$opportunity->delete();
|
|
$this->postJson('/api/v1/interest-requests', $payload, ['Idempotency-Key' => (string) Str::uuid()])->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->actingAsWithMfa($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->actingAsWithMfa($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->actingAsWithMfa($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
|
|
{
|
|
$this->actingAs(User::factory()->create());
|
|
// 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' => null];
|
|
$first = $this->postJson('/api/v1/interest-requests', $payload, ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.reference');
|
|
$second = $this->postJson('/api/v1/interest-requests', $payload, ['Idempotency-Key' => (string) Str::uuid()])->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' => null, 'privacy_consent' => true]);
|
|
$this->postJson('/api/v1/interest-requests', [...$payload, 'privacyConsent' => false], ['Idempotency-Key' => (string) Str::uuid()])->assertUnprocessable()->assertJsonValidationErrors('privacyConsent');
|
|
$this->assertDatabaseCount('interest_requests', 2);
|
|
}
|
|
}
|