assertSame('testing', app()->environment()); $this->assertSame('pgsql', DB::connection()->getDriverName()); $this->assertStringEndsWith('_test', DB::connection()->getDatabaseName()); $this->assertSame(DB::connection()->getDatabaseName(), DB::selectOne('select current_database() as name')->name); DB::beginTransaction(); $this->beforeApplicationDestroyed(fn () => DB::rollBack()); $this->withoutVite(); Http::preventStrayRequests(); config(['investmoz_ai.enabled' => true]); DB::table('ai_controls')->update(['suspended' => false]); } private function manager(): User { $user = User::factory()->create(); config(['portal.managers' => [...config('portal.managers', []), $user->email]]); $this->actingAs($user); return $user; } private function source(string $classification = 'internal', string $language = 'pt', ?string $text = null): string { $id = (string) Str::uuid(); $text ??= 'Documento exclusivamente sintético: formação técnica e investimento em equipamentos para oficinas.'; $key = 'ai-test-'.$id.'.txt'; Storage::disk('local')->put($key, $text); $this->beforeApplicationDestroyed(fn () => Storage::disk('local')->delete($key)); DB::table('documents')->insert(['id' => $id, 'file_name' => $key, 'media_type' => 'text/plain', 'storage_key' => $key, 'sha256' => hash('sha256', $text), 'status' => 'active', 'data_classification' => $classification, 'malware_scan_status' => 'clean', 'malware_scanned_sha256' => hash('sha256', $text), 'malware_engine' => 'ClamAV-Test-Double', 'malware_engine_version' => 'fixture', 'malware_scanned_at' => now(), 'malware_signatures_at' => now()]); $response = $this->postJson('/api/v1/ai/sources', ['document_id' => $id, 'title' => 'Fonte sintética', 'language' => $language, 'nature' => 'illustrative', 'source_url' => 'https://example.org/synthetic', 'source_owner' => 'Entidade de teste', 'evidence_reference' => 'Autorização exclusivamente sintética.', 'source_date' => now()->toDateString(), 'valid_until' => now()->addMonth()->toDateString(), 'authorised' => true]); if (in_array($classification, ['confidential', 'restricted'], true)) { $response->assertUnprocessable(); return $id; } $response->assertOk(); return DB::table('ai_sources')->where('document_id', $id)->value('id'); } private function ask(string $query = 'formação', string $language = 'pt'): string { return $this->postJson('/api/v1/ai/runs', ['capability' => 'assistant', 'purpose' => 'investment_information', 'query' => $query, 'language' => $language])->assertCreated()->json('id'); } public function test_authentication_authorisation_flag_and_purpose_are_enforced(): void { $this->get('/management/ai')->assertRedirect('/login'); $this->actingAs(User::factory()->create())->get('/management/ai')->assertForbidden(); $this->manager(); $this->get('/management/ai')->assertInertia(fn (AssertableInertia $page) => $page->component('ai/index')); $this->postJson('/api/v1/ai/runs', ['capability' => 'assistant', 'purpose' => 'interest_triage', 'query' => 'formação', 'language' => 'pt'])->assertUnprocessable(); config(['investmoz_ai.enabled' => false]); $this->get('/management/ai')->assertNotFound(); $this->postJson('/api/v1/ai/runs', [])->assertNotFound(); } public function test_unreviewed_confidential_and_revoked_sources_never_leak(): void { $this->manager(); $source = $this->source(); $this->source('confidential'); $pending = $this->ask(); $this->getJson('/api/v1/ai/runs/'.$pending)->assertJsonCount(0, 'result.items'); $this->postJson('/api/v1/ai/sources/'.$source.'/review', ['status' => 'approved', 'expected_status' => 'pending'])->assertForbidden(); $this->manager(); $this->postJson('/api/v1/ai/sources/'.$source.'/review', ['status' => 'approved', 'expected_status' => 'pending'])->assertOk(); $run = $this->ask(); $this->getJson('/api/v1/ai/runs/'.$run)->assertJsonCount(1, 'result.items')->assertJsonPath('result.items.0.source_id', $source) ->assertJsonPath('result.mode', 'lexical_evidence')->assertJsonPath('result.model', null)->assertJsonPath('result.confidence', 'not_calibrated'); $stored = DB::table('ai_runs')->where('id', $run)->first(); $this->assertStringNotContainsString('equipamentos', $stored->result); $event = DB::table('audit_events')->where('entity_id', $run)->first(); $this->assertStringNotContainsString('formação', $event->after_data); $this->postJson('/api/v1/ai/sources/'.$source.'/review', ['status' => 'revoked', 'expected_status' => 'approved'])->assertOk(); $this->getJson('/api/v1/ai/runs/'.$run)->assertJsonPath('unavailable', true)->assertJsonPath('result', null); $this->getJson('/api/v1/ai/runs/'.$this->ask())->assertJsonCount(0, 'result.items'); } public function test_language_expiry_and_document_version_are_checked_before_retrieval(): void { $this->manager(); $source = $this->source('internal', 'en', 'Synthetic document: vocational training for renewable energy maintenance.'); $this->manager(); $this->postJson('/api/v1/ai/sources/'.$source.'/review', ['status' => 'approved', 'expected_status' => 'pending'])->assertOk(); $this->getJson('/api/v1/ai/runs/'.$this->ask('renewable energy', 'en'))->assertJsonCount(1, 'result.items'); $this->getJson('/api/v1/ai/runs/'.$this->ask('renewable energy', 'pt'))->assertJsonCount(0, 'result.items'); $doc = DB::table('ai_sources')->where('id', $source)->value('document_id'); DB::table('documents')->where('id', $doc)->increment('version_no'); $this->getJson('/api/v1/ai/runs/'.$this->ask('renewable energy', 'en'))->assertJsonCount(0, 'result.items'); DB::table('documents')->where('id', $doc)->decrement('version_no'); $this->travel(2)->months(); $this->getJson('/api/v1/ai/runs/'.$this->ask('renewable energy', 'en'))->assertJsonCount(0, 'result.items'); $this->travelBack(); } public function test_unsafe_feedback_suspends_capability_and_review_cannot_change_official_state(): void { $actor = $this->manager(); $run = $this->ask('sem evidência'); $this->postJson('/api/v1/ai/runs/'.$run.'/review', ['status' => 'accepted', 'version_no' => 1])->assertForbidden(); $this->manager(); $this->postJson('/api/v1/ai/runs/'.$run.'/review', ['status' => 'rejected', 'version_no' => 1])->assertOk(); $this->postJson('/api/v1/ai/runs/'.$run.'/review', ['status' => 'accepted', 'version_no' => 1])->assertConflict(); $this->postJson('/api/v1/ai/runs/'.$run.'/feedback', ['rating' => 'unsafe', 'review_requested' => true])->assertOk(); $this->assertDatabaseHas('ai_controls', ['capability' => 'assistant', 'suspended' => true]); $this->postJson('/api/v1/ai/runs', ['capability' => 'assistant', 'purpose' => 'investment_information', 'query' => 'teste', 'language' => 'pt'])->assertStatus(503); $this->getJson('/api/v1/ai/runs/'.$run)->assertJsonPath('result', null); $this->assertSame($actor->id, AiRun::findOrFail($run)->actor_id); $this->assertDatabaseCount('ai_feedback', 1); } public function test_rules_and_extractive_summary_do_not_call_external_models(): void { $this->manager(); foreach (['recommend' => 'investment_information', 'quality' => 'data_quality', 'sla' => 'execution_monitoring'] as $capability => $purpose) { $id = $this->postJson('/api/v1/ai/runs', compact('capability', 'purpose') + ['language' => 'pt'])->assertCreated()->json('id'); $this->getJson('/api/v1/ai/runs/'.$id)->assertJsonPath('result.mode', 'structured_rules')->assertJsonPath('result.model', null); } $source = $this->source(); $this->manager(); $this->postJson('/api/v1/ai/sources/'.$source.'/review', ['status' => 'approved', 'expected_status' => 'pending'])->assertOk(); $id = $this->postJson('/api/v1/ai/runs', ['capability' => 'summary', 'purpose' => 'investment_information', 'language' => 'pt', 'source_id' => $source])->assertCreated()->json('id'); $this->getJson('/api/v1/ai/runs/'.$id)->assertJsonPath('result.mode', 'extractive_preview')->assertJsonCount(1, 'result.items'); Http::assertNothingSent(); } public function test_text_upload_uses_canonical_documents_and_does_not_auto_authorise_retrieval(): void { $this->manager(); $response = $this->postJson('/api/v1/ai/documents', ['file' => UploadedFile::fake()->createWithContent('source.txt', 'Synthetic authorised plain text.'), 'authorised' => true])->assertCreated(); $document = DB::table('documents')->where('id', $response->json('document_id'))->first(); $this->beforeApplicationDestroyed(fn () => Storage::disk('local')->delete($document->storage_key)); $this->assertSame('internal', $document->data_classification); $this->assertSame(hash('sha256', 'Synthetic authorised plain text.'), $document->sha256); $this->assertDatabaseCount('ai_sources', 0); $this->postJson('/api/v1/ai/documents', ['file' => UploadedFile::fake()->create('unsafe.pdf', 10, 'application/pdf'), 'authorised' => true])->assertUnprocessable(); } public function test_triage_requires_specific_consent_and_withdrawal_hides_old_result(): void { $actor = $this->manager(); $interest = InterestRequest::create(['reference' => (string) Str::uuid(), 'full_name' => 'Private synthetic name', 'email' => 'private@example.invalid', 'investor_type' => 'empresa', 'country' => 'MZ', 'sector_interest' => 'Energia', 'message' => 'Private synthetic message.', 'privacy_consent' => true]); $input = ['capability' => 'classify', 'purpose' => 'interest_triage', 'interest_id' => $interest->id, 'language' => 'pt']; $this->postJson('/api/v1/ai/runs', $input)->assertUnprocessable(); $consent = (string) Str::uuid(); DB::table('consents')->insert(['id' => $consent, 'user_id' => $actor->id, 'subject_ref' => $interest->reference, 'purpose' => 'ai_assistance', 'status' => 'granted', 'granted_at' => now()]); $run = $this->postJson('/api/v1/ai/runs', $input)->assertCreated()->json('id'); $response = $this->getJson('/api/v1/ai/runs/'.$run)->assertJsonPath('result.items.0.intention', 'general_information'); $this->assertStringNotContainsString('private@example.invalid', $response->getContent()); $this->assertStringNotContainsString('Private synthetic', $response->getContent()); DB::table('consents')->where('id', $consent)->update(['status' => 'withdrawn', 'withdrawn_at' => now()]); $this->getJson('/api/v1/ai/runs/'.$run)->assertJsonPath('result', null); $this->manager(); $this->postJson('/api/v1/ai/runs/'.$run.'/review', ['status' => 'accepted', 'version_no' => 1])->assertUnprocessable(); $this->assertSame('received', $interest->fresh()->status); } public function test_recommendations_use_explicit_filters_and_unpublished_results_are_hidden(): void { $this->manager(); $sector = Sector::create(['name' => 'Sector sintético', 'slug' => (string) Str::uuid()]); $opportunity = Opportunity::create(['sector_id' => $sector->id, 'reference' => (string) Str::uuid(), 'title' => 'Oportunidade sintética', 'slug' => (string) Str::uuid(), 'status' => 'published', 'published_at' => now(), 'province' => 'Território de teste']); $run = $this->postJson('/api/v1/ai/runs', ['capability' => 'recommend', 'purpose' => 'investment_information', 'language' => 'pt', 'province' => 'Território de teste'])->assertCreated()->json('id'); $this->getJson('/api/v1/ai/runs/'.$run)->assertJsonCount(1, 'result.items')->assertJsonPath('result.items.0.score', 1); $opportunity->update(['status' => 'draft']); $this->getJson('/api/v1/ai/runs/'.$run)->assertJsonPath('result', null); } }