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]]); $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->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->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->actingAs($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->actingAs($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->actingAs($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); } }