173 خطوط
7.4 KiB
PHP
173 خطوط
7.4 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\AI;
|
|
|
|
use App\Models\User;
|
|
use App\Modules\AI\Infrastructure\AiEndpointPolicy;
|
|
use App\Modules\Identity\Domain\Enums\UserRole;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Laravel\Sanctum\Sanctum;
|
|
use Tests\TestCase;
|
|
|
|
class AiConnectionTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->app->instance(AiEndpointPolicy::class, new AiEndpointPolicy(
|
|
resolver: fn (string $host): array => ['93.184.216.34'],
|
|
customProviderHosts: ['ai.example.test'],
|
|
));
|
|
}
|
|
|
|
public function test_designer_connects_discovers_and_selects_an_online_model_without_exposing_the_key(): void
|
|
{
|
|
Http::fake([
|
|
'https://ai.example.test/v1/models' => Http::response(['data' => [['id' => 'model-b'], ['id' => 'model-a']]]),
|
|
]);
|
|
$designer = User::factory()->create(['role' => UserRole::CourseDesigner]);
|
|
Sanctum::actingAs($designer);
|
|
|
|
$connection = $this->postJson('/api/v1/ai-connections', [
|
|
'name' => 'سرویس سازمانی',
|
|
'provider' => 'openai_compatible',
|
|
'mode' => 'online',
|
|
'baseUrl' => 'https://ai.example.test/v1',
|
|
'apiKey' => 'secret-token',
|
|
'defaultModel' => null,
|
|
'timeoutSeconds' => 60,
|
|
'enabled' => true,
|
|
])->assertCreated()->assertJsonPath('data.hasApiKey', true)->assertJsonMissing(['apiKey' => 'secret-token']);
|
|
|
|
$id = $connection->json('data.id');
|
|
$this->postJson("/api/v1/ai-connections/{$id}/models")
|
|
->assertOk()
|
|
->assertJsonPath('data.models.0', 'model-a');
|
|
$this->postJson("/api/v1/ai-connections/{$id}/test")
|
|
->assertOk()
|
|
->assertJsonPath('data.connected', true);
|
|
|
|
$row = DB::table('ai_provider_connections')->where('id', $id)->first();
|
|
$this->assertNotSame('secret-token', $row->encrypted_api_key);
|
|
$this->assertSame('model-a', $row->default_model);
|
|
}
|
|
|
|
public function test_local_connections_allow_http_but_managers_cannot_manage_connections(): void
|
|
{
|
|
$designer = User::factory()->create(['role' => UserRole::CourseDesigner]);
|
|
Sanctum::actingAs($designer);
|
|
$this->postJson('/api/v1/ai-connections', [
|
|
'name' => 'Ollama داخلی',
|
|
'provider' => 'ollama',
|
|
'mode' => 'local',
|
|
'baseUrl' => 'http://127.0.0.1:11434/v1',
|
|
'defaultModel' => null,
|
|
'timeoutSeconds' => 90,
|
|
'enabled' => true,
|
|
])->assertCreated()->assertJsonPath('data.isDefault', true);
|
|
|
|
Sanctum::actingAs(User::factory()->create(['organization_id' => $designer->organization_id, 'role' => UserRole::Manager]));
|
|
$this->getJson('/api/v1/ai-connections')->assertForbidden();
|
|
}
|
|
|
|
public function test_connection_urls_are_policy_enforced_and_connections_are_tenant_scoped(): void
|
|
{
|
|
$firstDesigner = User::factory()->create(['role' => UserRole::CourseDesigner]);
|
|
Sanctum::actingAs($firstDesigner);
|
|
|
|
$this->postJson('/api/v1/ai-connections', [
|
|
'name' => 'Metadata target',
|
|
'provider' => 'ollama',
|
|
'mode' => 'local',
|
|
'baseUrl' => 'http://169.254.169.254:11434/v1',
|
|
'defaultModel' => null,
|
|
'timeoutSeconds' => 30,
|
|
'enabled' => true,
|
|
])->assertUnprocessable()->assertJsonValidationErrors('baseUrl');
|
|
|
|
$connectionId = $this->postJson('/api/v1/ai-connections', [
|
|
'name' => 'Tenant local provider',
|
|
'provider' => 'ollama',
|
|
'mode' => 'local',
|
|
'baseUrl' => 'http://127.0.0.1:11434/v1',
|
|
'defaultModel' => null,
|
|
'timeoutSeconds' => 30,
|
|
'enabled' => true,
|
|
])->assertCreated()->json('data.id');
|
|
|
|
Sanctum::actingAs(User::factory()->create(['role' => UserRole::CourseDesigner]));
|
|
$this->postJson("/api/v1/ai-connections/{$connectionId}/test")->assertNotFound();
|
|
}
|
|
|
|
public function test_enabled_and_default_invariant_is_preserved_across_the_connection_lifecycle(): void
|
|
{
|
|
$designer = User::factory()->create(['role' => UserRole::CourseDesigner]);
|
|
Sanctum::actingAs($designer);
|
|
|
|
$disabled = $this->postJson('/api/v1/ai-connections', $this->localConnection('Disabled', false))
|
|
->assertCreated()
|
|
->assertJsonPath('data.enabled', false)
|
|
->assertJsonPath('data.isDefault', false)
|
|
->json('data.id');
|
|
|
|
$first = $this->postJson('/api/v1/ai-connections', $this->localConnection('First enabled'))
|
|
->assertCreated()
|
|
->assertJsonPath('data.isDefault', true)
|
|
->json('data.id');
|
|
$second = $this->postJson('/api/v1/ai-connections', $this->localConnection('Second enabled'))
|
|
->assertCreated()
|
|
->assertJsonPath('data.isDefault', false)
|
|
->json('data.id');
|
|
|
|
$this->postJson("/api/v1/ai-connections/{$second}/default")
|
|
->assertOk()
|
|
->assertJsonPath('data.isDefault', true);
|
|
$this->assertDatabaseHas('ai_provider_connections', ['id' => $first, 'is_default' => false]);
|
|
|
|
$this->patchJson("/api/v1/ai-connections/{$second}", $this->localConnection('Second enabled', false))
|
|
->assertOk()
|
|
->assertJsonPath('data.enabled', false)
|
|
->assertJsonPath('data.isDefault', false);
|
|
$this->assertDatabaseHas('ai_provider_connections', ['id' => $first, 'enabled' => true, 'is_default' => true]);
|
|
|
|
$this->deleteJson("/api/v1/ai-connections/{$first}")->assertNoContent();
|
|
$this->assertSame(0, DB::table('ai_provider_connections')->where('organization_id', $designer->organization_id)->where('is_default', true)->count());
|
|
|
|
$this->assertDatabaseHas('ai_provider_connections', ['id' => $disabled, 'enabled' => false, 'is_default' => false]);
|
|
$this->assertDatabaseHas('ai_provider_connections', ['id' => $second, 'enabled' => false, 'is_default' => false]);
|
|
}
|
|
|
|
public function test_deleting_the_default_promotes_another_enabled_connection(): void
|
|
{
|
|
$designer = User::factory()->create(['role' => UserRole::CourseDesigner]);
|
|
Sanctum::actingAs($designer);
|
|
|
|
$default = $this->postJson('/api/v1/ai-connections', $this->localConnection('Default'))->assertCreated()->json('data.id');
|
|
$replacement = $this->postJson('/api/v1/ai-connections', $this->localConnection('Replacement'))->assertCreated()->json('data.id');
|
|
|
|
$this->deleteJson("/api/v1/ai-connections/{$default}")->assertNoContent();
|
|
|
|
$this->assertDatabaseHas('ai_provider_connections', ['id' => $replacement, 'enabled' => true, 'is_default' => true]);
|
|
$this->assertSame(1, DB::table('ai_provider_connections')->where('organization_id', $designer->organization_id)->where('enabled', true)->where('is_default', true)->count());
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
private function localConnection(string $name, bool $enabled = true): array
|
|
{
|
|
return [
|
|
'name' => $name,
|
|
'provider' => 'ollama',
|
|
'mode' => 'local',
|
|
'baseUrl' => 'http://127.0.0.1:11434/v1',
|
|
'defaultModel' => 'qwen',
|
|
'timeoutSeconds' => 30,
|
|
'enabled' => $enabled,
|
|
];
|
|
}
|
|
}
|