Files
Investidor-V2/tests/Feature/InvestmentWorkspaceTest.php

209 lines
14 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\InterestRequest;
use App\Models\User;
use App\Services\CountryCatalogue;
use App\Services\DeclaredOriginStatistics;
use Database\Seeders\InvestmentReferenceSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\Support\AuthenticatesQaActors;
use Tests\TestCase;
class InvestmentWorkspaceTest extends TestCase
{
use AuthenticatesQaActors;
use RefreshDatabase;
private function draft(User $user): string
{
return $this->actingAs($user)->postJson('/api/v1/investment/submissions')->assertCreated()->json('id');
}
public function test_country_meanings_are_persisted_separately_without_inference_and_recovered_by_account(): void
{
$user = User::factory()->create();
$this->actingAs($user)->putJson('/api/v1/investment/profile', ['version' => 0, 'payload' => ['country' => 'Valor histórico', 'residence_country_code' => 'PT']])->assertOk();
$company = $this->postJson('/api/v1/investment/companies', ['version' => 0, 'payload' => ['name' => 'Entidade QA', 'role' => 'Representante', 'incorporation_country_code' => 'MZ']])->assertOk()->json('id');
$draft = $this->postJson('/api/v1/investment/submissions', ['company_id' => $company])->assertCreated()->json('id');
$this->getJson('/api/v1/investment/submissions/'.$draft)->assertJsonPath('submission.payload.residence_country_code', 'PT')->assertJsonPath('submission.payload.incorporation_country_code', 'MZ')->assertJsonPath('submission.payload.country', 'Valor histórico')->assertJsonMissingPath('submission.payload.declared_origin_country_code');
$this->putJson('/api/v1/investment/submissions/'.$draft, ['version' => 1, 'payload' => ['residence_country_code' => 'PT', 'incorporation_country_code' => 'MZ', 'declared_origin_country_code' => 'ZA']])->assertOk();
// Discard the original authenticated context: recovery uses server data, not local storage.
$this->actingAs(User::factory()->create())->getJson('/api/v1/investment/submissions/'.$draft)->assertNotFound();
$this->actingAs(User::findOrFail($user->id))->getJson('/api/v1/investment/submissions/'.$draft)->assertJsonPath('submission.payload.declared_origin_country_code', 'ZA');
$this->putJson('/api/v1/investment/submissions/'.$draft, ['version' => 2, 'payload' => ['declared_origin_country_code' => 'ZZ']])->assertUnprocessable()->assertJsonValidationErrors('payload.declared_origin_country_code');
$this->getJson('/api/v1/investment/submissions/'.$draft)->assertJsonPath('submission.version', 2)->assertJsonPath('submission.payload.declared_origin_country_code', 'ZA');
$countries = app(CountryCatalogue::class)->all();
$this->assertCount(249, $countries);
$this->assertCount(249, array_unique(array_column($countries, 'code')));
}
public function test_declared_origin_is_snapshotted_on_submission_and_statistics_do_not_infer_old_country(): void
{
$this->seed(InvestmentReferenceSeeder::class);
$user = User::factory()->create();
$id = $this->draft($user);
$data = $this->payload();
$data['payload']['declared_origin_country_code'] = 'ZA';
$this->postJson('/api/v1/investment/submissions/'.$id.'/submit', $data)->assertOk();
$interest = InterestRequest::firstOrFail();
$this->assertSame('ZA', $interest->metadata['declared_origin_country_code']);
$this->assertSame('Moçambique', $interest->country);
$this->expectException(HttpException::class);
app(DeclaredOriginStatistics::class)->forManager($user);
}
public function test_authorized_origin_statistics_keep_historical_missing_as_not_informed(): void
{
$this->seed(InvestmentReferenceSeeder::class);
$user = User::factory()->create();
$id = $this->draft($user);
$this->postJson('/api/v1/investment/submissions/'.$id.'/submit', $this->payload())->assertOk();
config(['portal.managers' => [$user->email]]);
$this->actingAsWithMfa($user);
$stats = app(DeclaredOriginStatistics::class)->forManager($user);
$this->assertSame([['code' => null, 'name' => 'Não informado', 'count' => 1]], $stats['interests']);
$this->assertSame([], $stats['desk']);
}
public function test_crm_origin_filter_separates_explicit_missing_and_empty_results_without_writing(): void
{
$this->withoutVite();
$this->seed(InvestmentReferenceSeeder::class);
$user = User::factory()->create();
foreach ([null, 'PT', 'ZA'] as $origin) {
$id = $this->draft($user);
$data = $this->payload();
if ($origin !== null) {
$data['payload']['declared_origin_country_code'] = $origin;
}
$this->postJson('/api/v1/investment/submissions/'.$id.'/submit', $data)->assertOk();
}
$before = InterestRequest::orderBy('id')->get()->toArray();
config(['portal.managers' => [$user->email]]);
$this->actingAsWithMfa($user);
$this->get('/management/crm/leads?origin=PT')->assertOk()->assertInertia(fn (AssertableInertia $page) => $page
->where('filters.origin', 'PT')->has('originStatistics.interests', 1)
->where('originStatistics.interests.0.code', 'PT')->where('originStatistics.interests.0.count', 1));
$this->get('/management/crm/leads?origin=unknown')->assertOk()->assertInertia(fn (AssertableInertia $page) => $page
->has('originStatistics.interests', 1)->where('originStatistics.interests.0.code', null)
->where('originStatistics.interests.0.count', 1));
$this->get('/management/crm/leads?origin=MZ')->assertOk()->assertInertia(fn (AssertableInertia $page) => $page
->has('originStatistics.interests', 0)->has('originStatistics.desk', 0));
$this->get('/management/crm/leads')->assertOk()->assertInertia(fn (AssertableInertia $page) => $page
->has('originStatistics.interests', 3));
$this->getJson('/management/crm/leads?origin=ZZ')->assertUnprocessable()->assertJsonValidationErrors('origin');
$this->assertSame($before, InterestRequest::orderBy('id')->get()->toArray());
$this->actingAs(User::factory()->create())->get('/management/crm/leads?origin=PT')->assertForbidden();
}
private function payload(int $version = 1): array
{
return ['version' => $version, 'payload' => ['full_name' => 'Investidor sintético', 'email' => 'qa@example.test', 'country' => 'Moçambique',
'profile_id' => DB::table('investment_reference_options')->where('group', 'investor_profile')->where('code', 'individual')->value('id'),
'participation_id' => DB::table('investment_reference_options')->where('group', 'participation')->where('code', 'invest')->value('id'),
'sector' => 'Educação', 'objective' => 'Objectivo exclusivamente sintético para teste.', 'horizon' => '12 meses', 'contact_consent' => true], 'needs' => []];
}
public function test_profile_and_company_are_private_versioned_and_do_not_grant_organization_access(): void
{
$user = User::factory()->create();
$this->actingAs($user)->putJson('/api/v1/investment/profile', ['version' => 0, 'payload' => ['country' => 'Moçambique', 'phone' => '123456', 'sector' => 'Educação']])->assertOk()->assertJsonPath('version', 1);
$this->putJson('/api/v1/investment/profile', ['version' => 0, 'payload' => ['country' => 'Outro']])->assertConflict();
$data = ['version' => 0, 'payload' => ['name' => 'Empresa sintética', 'country' => 'Moçambique', 'role' => 'Representante declarado']];
$company = $this->postJson('/api/v1/investment/companies', $data)->assertOk()->json('id');
$id = $this->postJson('/api/v1/investment/submissions', ['company_id' => $company])->assertCreated()->json('id');
$this->getJson('/api/v1/investment/submissions/'.$id)->assertJsonPath('submission.payload.organisation', 'Empresa sintética')->assertJsonPath('submission.payload.phone', '123456');
$this->assertStringNotContainsString('Empresa sintética', DB::table('investment_company_declarations')->value('payload'));
$this->assertStringNotContainsString('123456', DB::table('investment_accounts')->value('payload'));
$this->assertDatabaseCount('memberships', 0);
$this->assertDatabaseCount('organizations', 0);
config(['portal.managers' => [$user->email]]);
$this->actingAsWithMfa($user);
$this->get('/management/history')->assertInertia(fn (AssertableInertia $page) => $page
->has('events.data', 3)->where('events.data.0.after_data.values.version', 1));
$this->actingAs(User::factory()->create())->getJson('/api/v1/investment/profile')->assertJsonCount(0, 'companies');
$this->postJson('/api/v1/investment/companies/'.$company, [...$data, 'version' => 1])->assertNotFound();
$this->postJson('/api/v1/investment/submissions', ['company_id' => $company])->assertNotFound();
$this->putJson('/api/v1/investment/profile', ['version' => 0, 'payload' => ['is_admin' => true]])->assertUnprocessable();
}
public function test_conversation_is_encrypted_authorized_and_idempotent_with_private_notifications(): void
{
$this->seed(InvestmentReferenceSeeder::class);
$owner = User::factory()->create();
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$id = $this->draft($owner);
$message = ['client_key' => (string) Str::uuid(), 'body' => 'Mensagem privada sintética do investidor.'];
$url = '/api/v1/investment/submissions/'.$id.'/messages';
$this->postJson($url, $message)->assertConflict();
$this->postJson('/api/v1/investment/submissions/'.$id.'/submit', $this->payload())->assertOk();
$first = $this->postJson($url, $message)->assertOk()->json('id');
$this->postJson($url, $message)->assertJsonPath('id', $first);
$this->postJson($url, [...$message, 'body' => 'Outro conteúdo com a mesma chave.'])->assertConflict();
$this->assertDatabaseCount('investment_messages', 1);
$this->assertStringNotContainsString('Mensagem privada', DB::table('investment_messages')->value('body'));
$this->actingAsWithMfa($manager)->getJson('/api/v1/investment/notifications')->assertJsonPath('unread', 1);
$this->postJson($url, ['client_key' => (string) Str::uuid(), 'body' => 'Resposta sintética da equipa.'])->assertOk();
$this->actingAs($owner)->getJson('/api/v1/investment/notifications')->assertJsonPath('unread', 2);
$this->getJson('/api/v1/investment/submissions/'.$id)->assertJsonCount(2, 'messages')->assertJsonPath('messages.1.sender_role', 'support');
$notification = DB::table('investment_notifications')->where('user_id', $owner->id)->first();
$this->postJson('/api/v1/investment/notifications/'.$notification->id.'/read')->assertOk();
$this->getJson('/api/v1/investment/notifications')->assertJsonPath('unread', 1);
$this->actingAs(User::factory()->create())->postJson($url, $message)->assertNotFound();
$this->postJson('/api/v1/investment/notifications/'.$notification->id.'/read')->assertNotFound();
config(['portal.managers' => []]);
$this->actingAsWithMfa($manager)->getJson('/api/v1/investment/notifications')->assertJsonCount(0, 'notifications.data');
$this->getJson('/api/v1/investment/submissions/'.$id)->assertNotFound();
}
public function test_revisions_preserve_encrypted_prior_values_without_exposing_them_to_other_users(): void
{
$this->seed(InvestmentReferenceSeeder::class);
$owner = User::factory()->create();
$id = $this->draft($owner);
$this->putJson('/api/v1/investment/submissions/'.$id, $this->payload())->assertOk();
$next = $this->payload(2);
$next['payload']['objective'] = 'Objectivo posterior exclusivamente sintético.';
$this->postJson('/api/v1/investment/submissions/'.$id.'/submit', $next)->assertOk();
$this->assertDatabaseCount('investment_submission_revisions', 3);
$url = '/api/v1/investment/submissions/'.$id.'/revisions/2';
$this->getJson($url)->assertOk()->assertJsonPath('snapshot.payload.objective', $this->payload()['payload']['objective'])->assertHeader('Cache-Control', 'no-store, private');
$this->assertStringNotContainsString('Objectivo', DB::table('investment_submission_revisions')->where('version', 2)->value('snapshot'));
$this->actingAs(User::factory()->create())->getJson($url)->assertNotFound();
}
public function test_status_review_notifies_owner_once_per_effective_change(): void
{
$this->seed(InvestmentReferenceSeeder::class);
$owner = User::factory()->create();
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$id = $this->draft($owner);
$this->postJson('/api/v1/investment/submissions/'.$id.'/submit', $this->payload())->assertOk();
$interest = InterestRequest::firstOrFail();
$this->actingAsWithMfa($manager)->patch('/management/interests/'.$interest->id, ['status' => 'in_review'])->assertRedirect();
$this->patch('/management/interests/'.$interest->id, ['status' => 'in_review'])->assertRedirect();
$this->patchJson('/api/v1/investment/admin/interests/'.$interest->id.'/status', ['status' => 'in_review'])->assertOk()->assertJsonPath('status', 'in_review');
$this->assertDatabaseCount('investment_notifications', 2);
$this->actingAs($owner)->getJson('/api/v1/investment/notifications')->assertJsonPath('unread', 2);
}
public function test_incomplete_text_can_be_saved_as_draft_but_cannot_be_submitted(): void
{
$owner = User::factory()->create();
$id = $this->draft($owner);
$partial = ['version' => 1, 'payload' => ['full_name' => 'A', 'email' => 'parcial@', 'objective' => 'Ideia']];
$this->putJson('/api/v1/investment/submissions/'.$id, $partial)->assertOk()->assertJsonPath('version', 2);
$this->getJson('/api/v1/investment/submissions/'.$id)->assertJsonPath('submission.payload.email', 'parcial@');
$this->postJson('/api/v1/investment/submissions/'.$id.'/submit', [...$partial, 'version' => 2])->assertUnprocessable();
$this->assertDatabaseCount('interest_requests', 0);
}
}