202 lines
12 KiB
PHP
202 lines
12 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]);
|
|
$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);
|
|
}
|
|
}
|