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 */ 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, ]; } }