Files
Investidor-V2/tests/Feature/AiGatewayTest.php
nala e65cb26901
tests / ci (push) Canceled after 0s
varias alteracoes
2026-10-06 14:50:12 +02:00

204 lines
13 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\AiRun;
use App\Models\InterestRequest;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia;
use Tests\TestCase;
class AiGatewayTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}