feat(tests): add AgenticManager tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-29 19:23:20 +00:00
parent 9e513af049
commit 27d08bbe43
5 changed files with 1076 additions and 714 deletions

16
TODO.md
View file

@ -75,10 +75,14 @@ Production-quality task list for the AI agent orchestration package.
- 47 test cases organised into 9 describe blocks with proper beforeEach/afterEach setup - 47 test cases organised into 9 describe blocks with proper beforeEach/afterEach setup
- [x] **TEST-005: Add AI provider service tests** (FIXED 2026-01-29) - [x] **TEST-005: Add AI provider service tests** (FIXED 2026-01-29)
- Created `tests/Unit/ClaudeServiceTest.php` - Anthropic Claude API tests - Created `tests/Unit/ClaudeServiceTest.php` - Anthropic Claude API tests (Pest functional syntax)
- Created `tests/Unit/GeminiServiceTest.php` - Google Gemini API tests - Created `tests/Unit/GeminiServiceTest.php` - Google Gemini API tests (Pest functional syntax)
- Created `tests/Unit/OpenAIServiceTest.php` - OpenAI API tests - Created `tests/Unit/OpenAIServiceTest.php` - OpenAI API tests (Pest functional syntax)
- All use mocked HTTP responses, cover generate/stream/retry/error handling - Created `tests/Unit/AgenticManagerTest.php` - Provider coordinator tests (Pest functional syntax)
- All use mocked HTTP responses with describe/it blocks
- Covers: provider configuration, API key management, request handling, response parsing
- Includes: error handling, retry logic (429/500), edge cases, streaming
- AgenticManager tests: provider registration, retrieval, availability, default provider handling
### Missing Database Infrastructure ### Missing Database Infrastructure
@ -274,7 +278,7 @@ Production-quality task list for the AI agent orchestration package.
- [x] TEST-002: AgentApiKeyService tests - 58 tests in AgentApiKeyServiceTest.php (2026-01-29) - [x] TEST-002: AgentApiKeyService tests - 58 tests in AgentApiKeyServiceTest.php (2026-01-29)
- [x] TEST-003: IpRestrictionService tests - 78 tests in IpRestrictionServiceTest.php (2026-01-29) - [x] TEST-003: IpRestrictionService tests - 78 tests in IpRestrictionServiceTest.php (2026-01-29)
- [x] TEST-004: PlanTemplateService tests - 35+ tests in PlanTemplateServiceTest.php (2026-01-29) - [x] TEST-004: PlanTemplateService tests - 35+ tests in PlanTemplateServiceTest.php (2026-01-29)
- [x] TEST-005: AI provider tests - ClaudeServiceTest, GeminiServiceTest, OpenAIServiceTest (2026-01-29) - [x] TEST-005: AI provider tests - ClaudeServiceTest, GeminiServiceTest, OpenAIServiceTest, AgenticManagerTest (2026-01-29)
### Database (Fixed) ### Database (Fixed)
@ -287,7 +291,7 @@ Production-quality task list for the AI agent orchestration package.
**Test Coverage Estimate:** ~65% (improved from ~35%) **Test Coverage Estimate:** ~65% (improved from ~35%)
- Models: Well tested (AgentPlan, AgentPhase, AgentSession, AgentApiKey) - Models: Well tested (AgentPlan, AgentPhase, AgentSession, AgentApiKey)
- Services: AgentApiKeyService, IpRestrictionService, PlanTemplateService now tested - Services: AgentApiKeyService, IpRestrictionService, PlanTemplateService now tested
- AI Providers: ClaudeService, GeminiService, OpenAIService unit tested - AI Providers: ClaudeService, GeminiService, OpenAIService, AgenticManager unit tested
- Commands: Untested (3 commands) - Commands: Untested (3 commands)
- Livewire: Untested - Livewire: Untested

View file

@ -0,0 +1,389 @@
<?php
declare(strict_types=1);
/**
* Tests for the AgenticManager AI provider coordinator.
*
* Covers provider registration, retrieval, availability checks, and default provider handling.
* Uses mocked configuration to test the manager without real API keys.
*/
use Core\Mod\Agentic\Services\AgenticManager;
use Core\Mod\Agentic\Services\AgenticProviderInterface;
use Core\Mod\Agentic\Services\ClaudeService;
use Core\Mod\Agentic\Services\GeminiService;
use Core\Mod\Agentic\Services\OpenAIService;
use Illuminate\Support\Facades\Config;
use InvalidArgumentException;
// =========================================================================
// Provider Registration Tests
// =========================================================================
describe('provider registration', function () {
it('registers all three providers on construction', function () {
Config::set('services.anthropic.api_key', 'test-claude-key');
Config::set('services.google.ai_api_key', 'test-gemini-key');
Config::set('services.openai.api_key', 'test-openai-key');
$manager = new AgenticManager();
expect($manager->claude())->toBeInstanceOf(ClaudeService::class)
->and($manager->gemini())->toBeInstanceOf(GeminiService::class)
->and($manager->openai())->toBeInstanceOf(OpenAIService::class);
});
it('uses configured model for Claude provider', function () {
Config::set('services.anthropic.api_key', 'test-key');
Config::set('services.anthropic.model', 'claude-opus-4-20250514');
$manager = new AgenticManager();
expect($manager->claude()->defaultModel())->toBe('claude-opus-4-20250514');
});
it('uses configured model for Gemini provider', function () {
Config::set('services.google.ai_api_key', 'test-key');
Config::set('services.google.ai_model', 'gemini-1.5-pro');
$manager = new AgenticManager();
expect($manager->gemini()->defaultModel())->toBe('gemini-1.5-pro');
});
it('uses configured model for OpenAI provider', function () {
Config::set('services.openai.api_key', 'test-key');
Config::set('services.openai.model', 'gpt-4o');
$manager = new AgenticManager();
expect($manager->openai()->defaultModel())->toBe('gpt-4o');
});
it('uses default model when not configured for Claude', function () {
Config::set('services.anthropic.api_key', 'test-key');
Config::set('services.anthropic.model', null);
$manager = new AgenticManager();
expect($manager->claude()->defaultModel())->toBe('claude-sonnet-4-20250514');
});
it('uses default model when not configured for Gemini', function () {
Config::set('services.google.ai_api_key', 'test-key');
Config::set('services.google.ai_model', null);
$manager = new AgenticManager();
expect($manager->gemini()->defaultModel())->toBe('gemini-2.0-flash');
});
it('uses default model when not configured for OpenAI', function () {
Config::set('services.openai.api_key', 'test-key');
Config::set('services.openai.model', null);
$manager = new AgenticManager();
expect($manager->openai()->defaultModel())->toBe('gpt-4o-mini');
});
});
// =========================================================================
// Provider Retrieval Tests
// =========================================================================
describe('provider retrieval', function () {
beforeEach(function () {
Config::set('services.anthropic.api_key', 'test-claude-key');
Config::set('services.google.ai_api_key', 'test-gemini-key');
Config::set('services.openai.api_key', 'test-openai-key');
});
it('retrieves provider by name using provider() method', function () {
$manager = new AgenticManager();
expect($manager->provider('claude'))->toBeInstanceOf(ClaudeService::class)
->and($manager->provider('gemini'))->toBeInstanceOf(GeminiService::class)
->and($manager->provider('openai'))->toBeInstanceOf(OpenAIService::class);
});
it('returns default provider when null passed to provider()', function () {
$manager = new AgenticManager();
// Default is 'claude'
expect($manager->provider(null))->toBeInstanceOf(ClaudeService::class);
});
it('returns default provider when no argument passed to provider()', function () {
$manager = new AgenticManager();
expect($manager->provider())->toBeInstanceOf(ClaudeService::class);
});
it('throws exception for unknown provider name', function () {
$manager = new AgenticManager();
expect(fn () => $manager->provider('unknown'))
->toThrow(InvalidArgumentException::class, 'Unknown AI provider: unknown');
});
it('returns provider implementing AgenticProviderInterface', function () {
$manager = new AgenticManager();
expect($manager->provider('claude'))->toBeInstanceOf(AgenticProviderInterface::class);
});
});
// =========================================================================
// Default Provider Tests
// =========================================================================
describe('default provider', function () {
beforeEach(function () {
Config::set('services.anthropic.api_key', 'test-claude-key');
Config::set('services.google.ai_api_key', 'test-gemini-key');
Config::set('services.openai.api_key', 'test-openai-key');
});
it('uses claude as default provider initially', function () {
$manager = new AgenticManager();
expect($manager->provider()->name())->toBe('claude');
});
it('allows changing default provider to gemini', function () {
$manager = new AgenticManager();
$manager->setDefault('gemini');
expect($manager->provider()->name())->toBe('gemini');
});
it('allows changing default provider to openai', function () {
$manager = new AgenticManager();
$manager->setDefault('openai');
expect($manager->provider()->name())->toBe('openai');
});
it('throws exception when setting unknown default provider', function () {
$manager = new AgenticManager();
expect(fn () => $manager->setDefault('unknown'))
->toThrow(InvalidArgumentException::class, 'Unknown AI provider: unknown');
});
it('allows switching default provider multiple times', function () {
$manager = new AgenticManager();
$manager->setDefault('gemini');
expect($manager->provider()->name())->toBe('gemini');
$manager->setDefault('openai');
expect($manager->provider()->name())->toBe('openai');
$manager->setDefault('claude');
expect($manager->provider()->name())->toBe('claude');
});
});
// =========================================================================
// Provider Availability Tests
// =========================================================================
describe('provider availability', function () {
it('reports provider as available when API key is set', function () {
Config::set('services.anthropic.api_key', 'test-key');
$manager = new AgenticManager();
expect($manager->isAvailable('claude'))->toBeTrue();
});
it('reports provider as unavailable when API key is empty', function () {
Config::set('services.anthropic.api_key', '');
Config::set('services.google.ai_api_key', '');
Config::set('services.openai.api_key', '');
$manager = new AgenticManager();
expect($manager->isAvailable('claude'))->toBeFalse()
->and($manager->isAvailable('gemini'))->toBeFalse()
->and($manager->isAvailable('openai'))->toBeFalse();
});
it('reports provider as unavailable when API key is null', function () {
Config::set('services.anthropic.api_key', null);
$manager = new AgenticManager();
expect($manager->isAvailable('claude'))->toBeFalse();
});
it('returns false for unknown provider name', function () {
$manager = new AgenticManager();
expect($manager->isAvailable('unknown'))->toBeFalse();
});
it('checks availability independently for each provider', function () {
Config::set('services.anthropic.api_key', 'test-key');
Config::set('services.google.ai_api_key', '');
Config::set('services.openai.api_key', 'test-key');
$manager = new AgenticManager();
expect($manager->isAvailable('claude'))->toBeTrue()
->and($manager->isAvailable('gemini'))->toBeFalse()
->and($manager->isAvailable('openai'))->toBeTrue();
});
});
// =========================================================================
// Available Providers Tests
// =========================================================================
describe('available providers list', function () {
it('returns all providers when all have API keys', function () {
Config::set('services.anthropic.api_key', 'test-claude-key');
Config::set('services.google.ai_api_key', 'test-gemini-key');
Config::set('services.openai.api_key', 'test-openai-key');
$manager = new AgenticManager();
$available = $manager->availableProviders();
expect($available)->toHaveCount(3)
->and(array_keys($available))->toBe(['claude', 'gemini', 'openai']);
});
it('returns empty array when no providers have API keys', function () {
Config::set('services.anthropic.api_key', '');
Config::set('services.google.ai_api_key', '');
Config::set('services.openai.api_key', '');
$manager = new AgenticManager();
expect($manager->availableProviders())->toBeEmpty();
});
it('returns only providers with valid API keys', function () {
Config::set('services.anthropic.api_key', 'test-key');
Config::set('services.google.ai_api_key', '');
Config::set('services.openai.api_key', 'test-key');
$manager = new AgenticManager();
$available = $manager->availableProviders();
expect($available)->toHaveCount(2)
->and(array_keys($available))->toBe(['claude', 'openai']);
});
it('returns providers implementing AgenticProviderInterface', function () {
Config::set('services.anthropic.api_key', 'test-key');
$manager = new AgenticManager();
$available = $manager->availableProviders();
foreach ($available as $provider) {
expect($provider)->toBeInstanceOf(AgenticProviderInterface::class);
}
});
});
// =========================================================================
// Direct Provider Access Tests
// =========================================================================
describe('direct provider access methods', function () {
beforeEach(function () {
Config::set('services.anthropic.api_key', 'test-key');
Config::set('services.google.ai_api_key', 'test-key');
Config::set('services.openai.api_key', 'test-key');
});
it('returns ClaudeService from claude() method', function () {
$manager = new AgenticManager();
expect($manager->claude())
->toBeInstanceOf(ClaudeService::class)
->and($manager->claude()->name())->toBe('claude');
});
it('returns GeminiService from gemini() method', function () {
$manager = new AgenticManager();
expect($manager->gemini())
->toBeInstanceOf(GeminiService::class)
->and($manager->gemini()->name())->toBe('gemini');
});
it('returns OpenAIService from openai() method', function () {
$manager = new AgenticManager();
expect($manager->openai())
->toBeInstanceOf(OpenAIService::class)
->and($manager->openai()->name())->toBe('openai');
});
it('returns same instance on repeated calls', function () {
$manager = new AgenticManager();
$claude1 = $manager->claude();
$claude2 = $manager->claude();
expect($claude1)->toBe($claude2);
});
});
// =========================================================================
// Edge Case Tests
// =========================================================================
describe('edge cases', function () {
it('handles missing configuration gracefully', function () {
Config::set('services.anthropic.api_key', null);
Config::set('services.anthropic.model', null);
Config::set('services.google.ai_api_key', null);
Config::set('services.google.ai_model', null);
Config::set('services.openai.api_key', null);
Config::set('services.openai.model', null);
$manager = new AgenticManager();
// Should still construct without throwing
expect($manager->claude())->toBeInstanceOf(ClaudeService::class)
->and($manager->gemini())->toBeInstanceOf(GeminiService::class)
->and($manager->openai())->toBeInstanceOf(OpenAIService::class);
// But all should be unavailable
expect($manager->availableProviders())->toBeEmpty();
});
it('provider retrieval is case-sensitive', function () {
Config::set('services.anthropic.api_key', 'test-key');
$manager = new AgenticManager();
expect(fn () => $manager->provider('Claude'))
->toThrow(InvalidArgumentException::class);
});
it('isAvailable handles case sensitivity', function () {
Config::set('services.anthropic.api_key', 'test-key');
$manager = new AgenticManager();
expect($manager->isAvailable('claude'))->toBeTrue()
->and($manager->isAvailable('Claude'))->toBeFalse()
->and($manager->isAvailable('CLAUDE'))->toBeFalse();
});
it('setDefault handles case sensitivity', function () {
$manager = new AgenticManager();
expect(fn () => $manager->setDefault('Gemini'))
->toThrow(InvalidArgumentException::class);
});
});

View file

@ -2,100 +2,89 @@
declare(strict_types=1); declare(strict_types=1);
namespace Core\Mod\Agentic\Tests\Unit;
use Core\Mod\Agentic\Services\AgenticResponse;
use Core\Mod\Agentic\Services\ClaudeService;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Tests\TestCase;
/** /**
* Tests for the ClaudeService AI provider. * Tests for the ClaudeService AI provider.
* *
* Uses mocked HTTP responses to test the service without real API calls. * Uses mocked HTTP responses to test the service without real API calls.
* Covers provider configuration, API key management, request handling, and responses.
*/ */
class ClaudeServiceTest extends TestCase
{
private const API_URL = 'https://api.anthropic.com/v1/messages';
// ========================================================================= use Core\Mod\Agentic\Services\AgenticResponse;
// Service Configuration Tests use Core\Mod\Agentic\Services\ClaudeService;
// ========================================================================= use Illuminate\Support\Facades\Http;
use RuntimeException;
public function test_name_returns_claude(): void const CLAUDE_API_URL = 'https://api.anthropic.com/v1/messages';
{
// =========================================================================
// Service Configuration Tests
// =========================================================================
describe('provider configuration', function () {
it('returns claude as the provider name', function () {
$service = new ClaudeService('test-api-key'); $service = new ClaudeService('test-api-key');
$this->assertEquals('claude', $service->name()); expect($service->name())->toBe('claude');
} });
public function test_default_model_returns_configured_model(): void it('returns configured model as default model', function () {
{
$service = new ClaudeService('test-api-key', 'claude-opus-4-20250514'); $service = new ClaudeService('test-api-key', 'claude-opus-4-20250514');
$this->assertEquals('claude-opus-4-20250514', $service->defaultModel()); expect($service->defaultModel())->toBe('claude-opus-4-20250514');
} });
public function test_default_model_uses_sonnet_when_not_specified(): void it('uses sonnet as default model when not specified', function () {
{
$service = new ClaudeService('test-api-key'); $service = new ClaudeService('test-api-key');
$this->assertEquals('claude-sonnet-4-20250514', $service->defaultModel()); expect($service->defaultModel())->toBe('claude-sonnet-4-20250514');
} });
});
public function test_is_available_returns_true_with_api_key(): void // =========================================================================
{ // API Key Management Tests
// =========================================================================
describe('API key management', function () {
it('reports available when API key is provided', function () {
$service = new ClaudeService('test-api-key'); $service = new ClaudeService('test-api-key');
$this->assertTrue($service->isAvailable()); expect($service->isAvailable())->toBeTrue();
} });
public function test_is_available_returns_false_without_api_key(): void it('reports unavailable when API key is empty', function () {
{
$service = new ClaudeService(''); $service = new ClaudeService('');
$this->assertFalse($service->isAvailable()); expect($service->isAvailable())->toBeFalse();
} });
// ========================================================================= it('sends API key in x-api-key header', function () {
// Generate Tests
// =========================================================================
public function test_generate_returns_agentic_response(): void
{
Http::fake([ Http::fake([
self::API_URL => Http::response([ CLAUDE_API_URL => Http::response([
'id' => 'msg_123',
'type' => 'message',
'role' => 'assistant',
'model' => 'claude-sonnet-4-20250514', 'model' => 'claude-sonnet-4-20250514',
'content' => [ 'content' => [['type' => 'text', 'text' => 'Response']],
['type' => 'text', 'text' => 'Hello, world!'], 'usage' => ['input_tokens' => 10, 'output_tokens' => 5],
],
'stop_reason' => 'end_turn',
'usage' => [
'input_tokens' => 10,
'output_tokens' => 5,
],
], 200), ], 200),
]); ]);
$service = new ClaudeService('test-api-key'); $service = new ClaudeService('test-api-key-123');
$response = $service->generate('You are helpful.', 'Say hello'); $service->generate('System', 'User');
$this->assertInstanceOf(AgenticResponse::class, $response); Http::assertSent(function ($request) {
$this->assertEquals('Hello, world!', $response->content); return $request->hasHeader('x-api-key', 'test-api-key-123')
$this->assertEquals('claude-sonnet-4-20250514', $response->model); && $request->hasHeader('anthropic-version', '2023-06-01')
$this->assertEquals(10, $response->inputTokens); && $request->hasHeader('content-type', 'application/json');
$this->assertEquals(5, $response->outputTokens); });
$this->assertEquals('end_turn', $response->stopReason); });
} });
public function test_generate_sends_correct_request_body(): void // =========================================================================
{ // Request Handling Tests
// =========================================================================
describe('request handling', function () {
it('sends correct request body structure', function () {
Http::fake([ Http::fake([
self::API_URL => Http::response([ CLAUDE_API_URL => Http::response([
'model' => 'claude-sonnet-4-20250514', 'model' => 'claude-sonnet-4-20250514',
'content' => [['type' => 'text', 'text' => 'Response']], 'content' => [['type' => 'text', 'text' => 'Response']],
'usage' => ['input_tokens' => 10, 'output_tokens' => 5], 'usage' => ['input_tokens' => 10, 'output_tokens' => 5],
@ -115,12 +104,11 @@ class ClaudeServiceTest extends TestCase
&& $body['max_tokens'] === 4096 && $body['max_tokens'] === 4096
&& $body['temperature'] === 1.0; && $body['temperature'] === 1.0;
}); });
} });
public function test_generate_uses_custom_config(): void it('applies custom configuration overrides', function () {
{
Http::fake([ Http::fake([
self::API_URL => Http::response([ CLAUDE_API_URL => Http::response([
'model' => 'claude-opus-4-20250514', 'model' => 'claude-opus-4-20250514',
'content' => [['type' => 'text', 'text' => 'Response']], 'content' => [['type' => 'text', 'text' => 'Response']],
'usage' => ['input_tokens' => 10, 'output_tokens' => 5], 'usage' => ['input_tokens' => 10, 'output_tokens' => 5],
@ -141,32 +129,60 @@ class ClaudeServiceTest extends TestCase
&& $body['max_tokens'] === 8192 && $body['max_tokens'] === 8192
&& $body['temperature'] === 0.5; && $body['temperature'] === 0.5;
}); });
} });
public function test_generate_sends_correct_headers(): void it('sends stream flag for streaming requests', function () {
{
Http::fake([ Http::fake([
self::API_URL => Http::response([ CLAUDE_API_URL => Http::response('', 200),
]);
$service = new ClaudeService('test-api-key');
iterator_to_array($service->stream('System', 'User'));
Http::assertSent(function ($request) {
return $request->data()['stream'] === true;
});
});
});
// =========================================================================
// Response Handling Tests
// =========================================================================
describe('response handling', function () {
it('returns AgenticResponse with parsed content', function () {
Http::fake([
CLAUDE_API_URL => Http::response([
'id' => 'msg_123',
'type' => 'message',
'role' => 'assistant',
'model' => 'claude-sonnet-4-20250514', 'model' => 'claude-sonnet-4-20250514',
'content' => [['type' => 'text', 'text' => 'Response']], 'content' => [
'usage' => ['input_tokens' => 10, 'output_tokens' => 5], ['type' => 'text', 'text' => 'Hello, world!'],
],
'stop_reason' => 'end_turn',
'usage' => [
'input_tokens' => 10,
'output_tokens' => 5,
],
], 200), ], 200),
]); ]);
$service = new ClaudeService('test-api-key-123'); $service = new ClaudeService('test-api-key');
$service->generate('System', 'User'); $response = $service->generate('You are helpful.', 'Say hello');
Http::assertSent(function ($request) { expect($response)
return $request->hasHeader('x-api-key', 'test-api-key-123') ->toBeInstanceOf(AgenticResponse::class)
&& $request->hasHeader('anthropic-version', '2023-06-01') ->and($response->content)->toBe('Hello, world!')
&& $request->hasHeader('content-type', 'application/json'); ->and($response->model)->toBe('claude-sonnet-4-20250514')
}); ->and($response->inputTokens)->toBe(10)
} ->and($response->outputTokens)->toBe(5)
->and($response->stopReason)->toBe('end_turn');
});
public function test_generate_tracks_duration(): void it('tracks request duration in milliseconds', function () {
{
Http::fake([ Http::fake([
self::API_URL => Http::response([ CLAUDE_API_URL => Http::response([
'model' => 'claude-sonnet-4-20250514', 'model' => 'claude-sonnet-4-20250514',
'content' => [['type' => 'text', 'text' => 'Response']], 'content' => [['type' => 'text', 'text' => 'Response']],
'usage' => ['input_tokens' => 10, 'output_tokens' => 5], 'usage' => ['input_tokens' => 10, 'output_tokens' => 5],
@ -176,12 +192,12 @@ class ClaudeServiceTest extends TestCase
$service = new ClaudeService('test-api-key'); $service = new ClaudeService('test-api-key');
$response = $service->generate('System', 'User'); $response = $service->generate('System', 'User');
$this->assertIsInt($response->durationMs); expect($response->durationMs)
$this->assertGreaterThanOrEqual(0, $response->durationMs); ->toBeInt()
} ->toBeGreaterThanOrEqual(0);
});
public function test_generate_includes_raw_response(): void it('includes raw API response for debugging', function () {
{
$rawResponse = [ $rawResponse = [
'id' => 'msg_123', 'id' => 'msg_123',
'model' => 'claude-sonnet-4-20250514', 'model' => 'claude-sonnet-4-20250514',
@ -190,39 +206,103 @@ class ClaudeServiceTest extends TestCase
]; ];
Http::fake([ Http::fake([
self::API_URL => Http::response($rawResponse, 200), CLAUDE_API_URL => Http::response($rawResponse, 200),
]); ]);
$service = new ClaudeService('test-api-key'); $service = new ClaudeService('test-api-key');
$response = $service->generate('System', 'User'); $response = $service->generate('System', 'User');
$this->assertEquals('msg_123', $response->raw['id']); expect($response->raw['id'])->toBe('msg_123');
} });
// ========================================================================= it('returns generator for streaming responses', function () {
// Error Handling Tests $stream = "data: {\"type\": \"content_block_delta\", \"delta\": {\"text\": \"Hello\"}}\n\n";
// ========================================================================= $stream .= "data: {\"type\": \"content_block_delta\", \"delta\": {\"text\": \" world\"}}\n\n";
$stream .= "data: [DONE]\n\n";
public function test_generate_throws_on_client_error(): void
{
Http::fake([ Http::fake([
self::API_URL => Http::response([ CLAUDE_API_URL => Http::response($stream, 200, ['Content-Type' => 'text/event-stream']),
]);
$service = new ClaudeService('test-api-key');
$generator = $service->stream('System', 'User');
expect($generator)->toBeInstanceOf(Generator::class);
});
});
// =========================================================================
// Edge Case Tests
// =========================================================================
describe('edge cases', function () {
it('handles empty content array gracefully', function () {
Http::fake([
CLAUDE_API_URL => Http::response([
'model' => 'claude-sonnet-4-20250514',
'content' => [],
'usage' => ['input_tokens' => 10, 'output_tokens' => 0],
], 200),
]);
$service = new ClaudeService('test-api-key');
$response = $service->generate('System', 'User');
expect($response->content)->toBe('');
});
it('handles missing usage data gracefully', function () {
Http::fake([
CLAUDE_API_URL => Http::response([
'model' => 'claude-sonnet-4-20250514',
'content' => [['type' => 'text', 'text' => 'Response']],
], 200),
]);
$service = new ClaudeService('test-api-key');
$response = $service->generate('System', 'User');
expect($response->inputTokens)->toBe(0)
->and($response->outputTokens)->toBe(0);
});
it('handles missing stop reason gracefully', function () {
Http::fake([
CLAUDE_API_URL => Http::response([
'model' => 'claude-sonnet-4-20250514',
'content' => [['type' => 'text', 'text' => 'Response']],
'usage' => ['input_tokens' => 10, 'output_tokens' => 5],
], 200),
]);
$service = new ClaudeService('test-api-key');
$response = $service->generate('System', 'User');
expect($response->stopReason)->toBeNull();
});
});
// =========================================================================
// Error Handling and Retry Tests
// =========================================================================
describe('error handling', function () {
it('throws exception on client authentication error', function () {
Http::fake([
CLAUDE_API_URL => Http::response([
'error' => ['message' => 'Invalid API key'], 'error' => ['message' => 'Invalid API key'],
], 401), ], 401),
]); ]);
$service = new ClaudeService('invalid-key'); $service = new ClaudeService('invalid-key');
$this->expectException(RuntimeException::class); expect(fn () => $service->generate('System', 'User'))
$this->expectExceptionMessage('Claude API error'); ->toThrow(RuntimeException::class, 'Claude API error');
});
$service->generate('System', 'User'); it('retries automatically on rate limit (429)', function () {
}
public function test_generate_retries_on_rate_limit(): void
{
Http::fake([ Http::fake([
self::API_URL => Http::sequence() CLAUDE_API_URL => Http::sequence()
->push(['error' => ['message' => 'Rate limited']], 429) ->push(['error' => ['message' => 'Rate limited']], 429)
->push([ ->push([
'model' => 'claude-sonnet-4-20250514', 'model' => 'claude-sonnet-4-20250514',
@ -234,13 +314,12 @@ class ClaudeServiceTest extends TestCase
$service = new ClaudeService('test-api-key'); $service = new ClaudeService('test-api-key');
$response = $service->generate('System', 'User'); $response = $service->generate('System', 'User');
$this->assertEquals('Success after retry', $response->content); expect($response->content)->toBe('Success after retry');
} });
public function test_generate_retries_on_server_error(): void it('retries automatically on server error (500)', function () {
{
Http::fake([ Http::fake([
self::API_URL => Http::sequence() CLAUDE_API_URL => Http::sequence()
->push(['error' => ['message' => 'Server error']], 500) ->push(['error' => ['message' => 'Server error']], 500)
->push([ ->push([
'model' => 'claude-sonnet-4-20250514', 'model' => 'claude-sonnet-4-20250514',
@ -252,106 +331,17 @@ class ClaudeServiceTest extends TestCase
$service = new ClaudeService('test-api-key'); $service = new ClaudeService('test-api-key');
$response = $service->generate('System', 'User'); $response = $service->generate('System', 'User');
$this->assertEquals('Success after retry', $response->content); expect($response->content)->toBe('Success after retry');
} });
public function test_generate_throws_after_max_retries(): void it('throws exception after exhausting max retries', function () {
{
Http::fake([ Http::fake([
self::API_URL => Http::response(['error' => ['message' => 'Server error']], 500), CLAUDE_API_URL => Http::response(['error' => ['message' => 'Server error']], 500),
]); ]);
$service = new ClaudeService('test-api-key'); $service = new ClaudeService('test-api-key');
$this->expectException(RuntimeException::class); expect(fn () => $service->generate('System', 'User'))
->toThrow(RuntimeException::class);
$service->generate('System', 'User'); });
} });
// =========================================================================
// Stream Tests
// =========================================================================
public function test_stream_is_generator(): void
{
// Create a simple SSE stream response
$stream = "data: {\"type\": \"content_block_delta\", \"delta\": {\"text\": \"Hello\"}}\n\n";
$stream .= "data: {\"type\": \"content_block_delta\", \"delta\": {\"text\": \" world\"}}\n\n";
$stream .= "data: [DONE]\n\n";
Http::fake([
self::API_URL => Http::response($stream, 200, ['Content-Type' => 'text/event-stream']),
]);
$service = new ClaudeService('test-api-key');
$generator = $service->stream('System', 'User');
$this->assertInstanceOf(\Generator::class, $generator);
}
public function test_stream_sends_stream_flag(): void
{
Http::fake([
self::API_URL => Http::response('', 200),
]);
$service = new ClaudeService('test-api-key');
iterator_to_array($service->stream('System', 'User'));
Http::assertSent(function ($request) {
return $request->data()['stream'] === true;
});
}
// =========================================================================
// Response Handling Edge Cases
// =========================================================================
public function test_handles_empty_content(): void
{
Http::fake([
self::API_URL => Http::response([
'model' => 'claude-sonnet-4-20250514',
'content' => [],
'usage' => ['input_tokens' => 10, 'output_tokens' => 0],
], 200),
]);
$service = new ClaudeService('test-api-key');
$response = $service->generate('System', 'User');
$this->assertEquals('', $response->content);
}
public function test_handles_missing_usage_data(): void
{
Http::fake([
self::API_URL => Http::response([
'model' => 'claude-sonnet-4-20250514',
'content' => [['type' => 'text', 'text' => 'Response']],
], 200),
]);
$service = new ClaudeService('test-api-key');
$response = $service->generate('System', 'User');
$this->assertEquals(0, $response->inputTokens);
$this->assertEquals(0, $response->outputTokens);
}
public function test_handles_missing_stop_reason(): void
{
Http::fake([
self::API_URL => Http::response([
'model' => 'claude-sonnet-4-20250514',
'content' => [['type' => 'text', 'text' => 'Response']],
'usage' => ['input_tokens' => 10, 'output_tokens' => 5],
], 200),
]);
$service = new ClaudeService('test-api-key');
$response = $service->generate('System', 'User');
$this->assertNull($response->stopReason);
}
}

View file

@ -2,100 +2,88 @@
declare(strict_types=1); declare(strict_types=1);
namespace Core\Mod\Agentic\Tests\Unit;
use Core\Mod\Agentic\Services\AgenticResponse;
use Core\Mod\Agentic\Services\GeminiService;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Tests\TestCase;
/** /**
* Tests for the GeminiService AI provider. * Tests for the GeminiService AI provider.
* *
* Uses mocked HTTP responses to test the service without real API calls. * Uses mocked HTTP responses to test the service without real API calls.
* Covers provider configuration, API key management, request handling, and responses.
*/ */
class GeminiServiceTest extends TestCase
{
private const API_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
// ========================================================================= use Core\Mod\Agentic\Services\AgenticResponse;
// Service Configuration Tests use Core\Mod\Agentic\Services\GeminiService;
// ========================================================================= use Illuminate\Support\Facades\Http;
use RuntimeException;
public function test_name_returns_gemini(): void const GEMINI_API_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
{
// =========================================================================
// Service Configuration Tests
// =========================================================================
describe('provider configuration', function () {
it('returns gemini as the provider name', function () {
$service = new GeminiService('test-api-key'); $service = new GeminiService('test-api-key');
$this->assertEquals('gemini', $service->name()); expect($service->name())->toBe('gemini');
} });
public function test_default_model_returns_configured_model(): void it('returns configured model as default model', function () {
{
$service = new GeminiService('test-api-key', 'gemini-1.5-pro'); $service = new GeminiService('test-api-key', 'gemini-1.5-pro');
$this->assertEquals('gemini-1.5-pro', $service->defaultModel()); expect($service->defaultModel())->toBe('gemini-1.5-pro');
} });
public function test_default_model_uses_flash_when_not_specified(): void it('uses flash as default model when not specified', function () {
{
$service = new GeminiService('test-api-key'); $service = new GeminiService('test-api-key');
$this->assertEquals('gemini-2.0-flash', $service->defaultModel()); expect($service->defaultModel())->toBe('gemini-2.0-flash');
} });
});
public function test_is_available_returns_true_with_api_key(): void // =========================================================================
{ // API Key Management Tests
// =========================================================================
describe('API key management', function () {
it('reports available when API key is provided', function () {
$service = new GeminiService('test-api-key'); $service = new GeminiService('test-api-key');
$this->assertTrue($service->isAvailable()); expect($service->isAvailable())->toBeTrue();
} });
public function test_is_available_returns_false_without_api_key(): void it('reports unavailable when API key is empty', function () {
{
$service = new GeminiService(''); $service = new GeminiService('');
$this->assertFalse($service->isAvailable()); expect($service->isAvailable())->toBeFalse();
} });
// ========================================================================= it('sends API key in query parameter', function () {
// Generate Tests
// =========================================================================
public function test_generate_returns_agentic_response(): void
{
Http::fake([ Http::fake([
self::API_URL.'/*' => Http::response([ GEMINI_API_URL.'/*' => Http::response([
'candidates' => [ 'candidates' => [
[ ['content' => ['parts' => [['text' => 'Response']]]],
'content' => [
'parts' => [['text' => 'Hello, world!']],
],
'finishReason' => 'STOP',
],
],
'usageMetadata' => [
'promptTokenCount' => 10,
'candidatesTokenCount' => 5,
], ],
'usageMetadata' => ['promptTokenCount' => 10, 'candidatesTokenCount' => 5],
], 200), ], 200),
]); ]);
$service = new GeminiService('test-api-key'); $service = new GeminiService('test-api-key-123');
$response = $service->generate('You are helpful.', 'Say hello'); $service->generate('System', 'User');
$this->assertInstanceOf(AgenticResponse::class, $response); Http::assertSent(function ($request) {
$this->assertEquals('Hello, world!', $response->content); return str_contains($request->url(), 'key=test-api-key-123');
$this->assertEquals('gemini-2.0-flash', $response->model); });
$this->assertEquals(10, $response->inputTokens); });
$this->assertEquals(5, $response->outputTokens); });
$this->assertEquals('STOP', $response->stopReason);
}
public function test_generate_sends_correct_request_body(): void // =========================================================================
{ // Request Handling Tests
// =========================================================================
describe('request handling', function () {
it('sends correct request body structure', function () {
Http::fake([ Http::fake([
self::API_URL.'/*' => Http::response([ GEMINI_API_URL.'/*' => Http::response([
'candidates' => [ 'candidates' => [
['content' => ['parts' => [['text' => 'Response']]]], ['content' => ['parts' => [['text' => 'Response']]]],
], ],
@ -114,12 +102,11 @@ class GeminiServiceTest extends TestCase
&& $body['generationConfig']['maxOutputTokens'] === 4096 && $body['generationConfig']['maxOutputTokens'] === 4096
&& $body['generationConfig']['temperature'] === 1.0; && $body['generationConfig']['temperature'] === 1.0;
}); });
} });
public function test_generate_uses_model_in_url(): void it('includes model name in URL', function () {
{
Http::fake([ Http::fake([
self::API_URL.'/*' => Http::response([ GEMINI_API_URL.'/*' => Http::response([
'candidates' => [ 'candidates' => [
['content' => ['parts' => [['text' => 'Response']]]], ['content' => ['parts' => [['text' => 'Response']]]],
], ],
@ -133,12 +120,11 @@ class GeminiServiceTest extends TestCase
Http::assertSent(function ($request) { Http::assertSent(function ($request) {
return str_contains($request->url(), 'gemini-1.5-pro:generateContent'); return str_contains($request->url(), 'gemini-1.5-pro:generateContent');
}); });
} });
public function test_generate_uses_custom_config(): void it('applies custom configuration overrides', function () {
{
Http::fake([ Http::fake([
self::API_URL.'/*' => Http::response([ GEMINI_API_URL.'/*' => Http::response([
'candidates' => [ 'candidates' => [
['content' => ['parts' => [['text' => 'Response']]]], ['content' => ['parts' => [['text' => 'Response']]]],
], ],
@ -160,31 +146,60 @@ class GeminiServiceTest extends TestCase
&& $body['generationConfig']['maxOutputTokens'] === 8192 && $body['generationConfig']['maxOutputTokens'] === 8192
&& $body['generationConfig']['temperature'] === 0.5; && $body['generationConfig']['temperature'] === 0.5;
}); });
} });
public function test_generate_sends_api_key_in_query(): void it('uses streamGenerateContent endpoint for streaming', function () {
{
Http::fake([ Http::fake([
self::API_URL.'/*' => Http::response([ GEMINI_API_URL.'/*' => Http::response('', 200),
]);
$service = new GeminiService('test-api-key');
iterator_to_array($service->stream('System', 'User'));
Http::assertSent(function ($request) {
return str_contains($request->url(), ':streamGenerateContent');
});
});
});
// =========================================================================
// Response Handling Tests
// =========================================================================
describe('response handling', function () {
it('returns AgenticResponse with parsed content', function () {
Http::fake([
GEMINI_API_URL.'/*' => Http::response([
'candidates' => [ 'candidates' => [
['content' => ['parts' => [['text' => 'Response']]]], [
'content' => [
'parts' => [['text' => 'Hello, world!']],
],
'finishReason' => 'STOP',
],
],
'usageMetadata' => [
'promptTokenCount' => 10,
'candidatesTokenCount' => 5,
], ],
'usageMetadata' => ['promptTokenCount' => 10, 'candidatesTokenCount' => 5],
], 200), ], 200),
]); ]);
$service = new GeminiService('test-api-key-123'); $service = new GeminiService('test-api-key');
$service->generate('System', 'User'); $response = $service->generate('You are helpful.', 'Say hello');
Http::assertSent(function ($request) { expect($response)
return str_contains($request->url(), 'key=test-api-key-123'); ->toBeInstanceOf(AgenticResponse::class)
}); ->and($response->content)->toBe('Hello, world!')
} ->and($response->model)->toBe('gemini-2.0-flash')
->and($response->inputTokens)->toBe(10)
->and($response->outputTokens)->toBe(5)
->and($response->stopReason)->toBe('STOP');
});
public function test_generate_tracks_duration(): void it('tracks request duration in milliseconds', function () {
{
Http::fake([ Http::fake([
self::API_URL.'/*' => Http::response([ GEMINI_API_URL.'/*' => Http::response([
'candidates' => [ 'candidates' => [
['content' => ['parts' => [['text' => 'Response']]]], ['content' => ['parts' => [['text' => 'Response']]]],
], ],
@ -195,12 +210,12 @@ class GeminiServiceTest extends TestCase
$service = new GeminiService('test-api-key'); $service = new GeminiService('test-api-key');
$response = $service->generate('System', 'User'); $response = $service->generate('System', 'User');
$this->assertIsInt($response->durationMs); expect($response->durationMs)
$this->assertGreaterThanOrEqual(0, $response->durationMs); ->toBeInt()
} ->toBeGreaterThanOrEqual(0);
});
public function test_generate_includes_raw_response(): void it('includes raw API response for debugging', function () {
{
$rawResponse = [ $rawResponse = [
'candidates' => [ 'candidates' => [
['content' => ['parts' => [['text' => 'Response']]]], ['content' => ['parts' => [['text' => 'Response']]]],
@ -209,40 +224,118 @@ class GeminiServiceTest extends TestCase
]; ];
Http::fake([ Http::fake([
self::API_URL.'/*' => Http::response($rawResponse, 200), GEMINI_API_URL.'/*' => Http::response($rawResponse, 200),
]); ]);
$service = new GeminiService('test-api-key'); $service = new GeminiService('test-api-key');
$response = $service->generate('System', 'User'); $response = $service->generate('System', 'User');
$this->assertArrayHasKey('candidates', $response->raw); expect($response->raw)
$this->assertArrayHasKey('usageMetadata', $response->raw); ->toHaveKey('candidates')
} ->toHaveKey('usageMetadata');
});
// ========================================================================= it('returns generator for streaming responses', function () {
// Error Handling Tests
// =========================================================================
public function test_generate_throws_on_client_error(): void
{
Http::fake([ Http::fake([
self::API_URL.'/*' => Http::response([ GEMINI_API_URL.'/*' => Http::response('', 200),
]);
$service = new GeminiService('test-api-key');
$generator = $service->stream('System', 'User');
expect($generator)->toBeInstanceOf(Generator::class);
});
});
// =========================================================================
// Edge Case Tests
// =========================================================================
describe('edge cases', function () {
it('handles empty candidates array gracefully', function () {
Http::fake([
GEMINI_API_URL.'/*' => Http::response([
'candidates' => [],
'usageMetadata' => ['promptTokenCount' => 10, 'candidatesTokenCount' => 0],
], 200),
]);
$service = new GeminiService('test-api-key');
$response = $service->generate('System', 'User');
expect($response->content)->toBe('');
});
it('handles missing usage metadata gracefully', function () {
Http::fake([
GEMINI_API_URL.'/*' => Http::response([
'candidates' => [
['content' => ['parts' => [['text' => 'Response']]]],
],
], 200),
]);
$service = new GeminiService('test-api-key');
$response = $service->generate('System', 'User');
expect($response->inputTokens)->toBe(0)
->and($response->outputTokens)->toBe(0);
});
it('handles missing finish reason gracefully', function () {
Http::fake([
GEMINI_API_URL.'/*' => Http::response([
'candidates' => [
['content' => ['parts' => [['text' => 'Response']]]],
],
'usageMetadata' => ['promptTokenCount' => 10, 'candidatesTokenCount' => 5],
], 200),
]);
$service = new GeminiService('test-api-key');
$response = $service->generate('System', 'User');
expect($response->stopReason)->toBeNull();
});
it('handles empty parts array gracefully', function () {
Http::fake([
GEMINI_API_URL.'/*' => Http::response([
'candidates' => [
['content' => ['parts' => []]],
],
'usageMetadata' => ['promptTokenCount' => 10, 'candidatesTokenCount' => 0],
], 200),
]);
$service = new GeminiService('test-api-key');
$response = $service->generate('System', 'User');
expect($response->content)->toBe('');
});
});
// =========================================================================
// Error Handling and Retry Tests
// =========================================================================
describe('error handling', function () {
it('throws exception on client authentication error', function () {
Http::fake([
GEMINI_API_URL.'/*' => Http::response([
'error' => ['message' => 'Invalid API key'], 'error' => ['message' => 'Invalid API key'],
], 401), ], 401),
]); ]);
$service = new GeminiService('invalid-key'); $service = new GeminiService('invalid-key');
$this->expectException(RuntimeException::class); expect(fn () => $service->generate('System', 'User'))
$this->expectExceptionMessage('Gemini API error'); ->toThrow(RuntimeException::class, 'Gemini API error');
});
$service->generate('System', 'User'); it('retries automatically on rate limit (429)', function () {
}
public function test_generate_retries_on_rate_limit(): void
{
Http::fake([ Http::fake([
self::API_URL.'/*' => Http::sequence() GEMINI_API_URL.'/*' => Http::sequence()
->push(['error' => ['message' => 'Rate limited']], 429) ->push(['error' => ['message' => 'Rate limited']], 429)
->push([ ->push([
'candidates' => [ 'candidates' => [
@ -255,13 +348,12 @@ class GeminiServiceTest extends TestCase
$service = new GeminiService('test-api-key'); $service = new GeminiService('test-api-key');
$response = $service->generate('System', 'User'); $response = $service->generate('System', 'User');
$this->assertEquals('Success after retry', $response->content); expect($response->content)->toBe('Success after retry');
} });
public function test_generate_retries_on_server_error(): void it('retries automatically on server error (500)', function () {
{
Http::fake([ Http::fake([
self::API_URL.'/*' => Http::sequence() GEMINI_API_URL.'/*' => Http::sequence()
->push(['error' => ['message' => 'Server error']], 500) ->push(['error' => ['message' => 'Server error']], 500)
->push([ ->push([
'candidates' => [ 'candidates' => [
@ -274,119 +366,17 @@ class GeminiServiceTest extends TestCase
$service = new GeminiService('test-api-key'); $service = new GeminiService('test-api-key');
$response = $service->generate('System', 'User'); $response = $service->generate('System', 'User');
$this->assertEquals('Success after retry', $response->content); expect($response->content)->toBe('Success after retry');
} });
public function test_generate_throws_after_max_retries(): void it('throws exception after exhausting max retries', function () {
{
Http::fake([ Http::fake([
self::API_URL.'/*' => Http::response(['error' => ['message' => 'Server error']], 500), GEMINI_API_URL.'/*' => Http::response(['error' => ['message' => 'Server error']], 500),
]); ]);
$service = new GeminiService('test-api-key'); $service = new GeminiService('test-api-key');
$this->expectException(RuntimeException::class); expect(fn () => $service->generate('System', 'User'))
->toThrow(RuntimeException::class);
$service->generate('System', 'User'); });
} });
// =========================================================================
// Stream Tests
// =========================================================================
public function test_stream_is_generator(): void
{
Http::fake([
self::API_URL.'/*' => Http::response('', 200),
]);
$service = new GeminiService('test-api-key');
$generator = $service->stream('System', 'User');
$this->assertInstanceOf(\Generator::class, $generator);
}
public function test_stream_uses_stream_endpoint(): void
{
Http::fake([
self::API_URL.'/*' => Http::response('', 200),
]);
$service = new GeminiService('test-api-key');
iterator_to_array($service->stream('System', 'User'));
Http::assertSent(function ($request) {
return str_contains($request->url(), ':streamGenerateContent');
});
}
// =========================================================================
// Response Handling Edge Cases
// =========================================================================
public function test_handles_empty_candidates(): void
{
Http::fake([
self::API_URL.'/*' => Http::response([
'candidates' => [],
'usageMetadata' => ['promptTokenCount' => 10, 'candidatesTokenCount' => 0],
], 200),
]);
$service = new GeminiService('test-api-key');
$response = $service->generate('System', 'User');
$this->assertEquals('', $response->content);
}
public function test_handles_missing_usage_metadata(): void
{
Http::fake([
self::API_URL.'/*' => Http::response([
'candidates' => [
['content' => ['parts' => [['text' => 'Response']]]],
],
], 200),
]);
$service = new GeminiService('test-api-key');
$response = $service->generate('System', 'User');
$this->assertEquals(0, $response->inputTokens);
$this->assertEquals(0, $response->outputTokens);
}
public function test_handles_missing_finish_reason(): void
{
Http::fake([
self::API_URL.'/*' => Http::response([
'candidates' => [
['content' => ['parts' => [['text' => 'Response']]]],
],
'usageMetadata' => ['promptTokenCount' => 10, 'candidatesTokenCount' => 5],
], 200),
]);
$service = new GeminiService('test-api-key');
$response = $service->generate('System', 'User');
$this->assertNull($response->stopReason);
}
public function test_handles_empty_parts(): void
{
Http::fake([
self::API_URL.'/*' => Http::response([
'candidates' => [
['content' => ['parts' => []]],
],
'usageMetadata' => ['promptTokenCount' => 10, 'candidatesTokenCount' => 0],
], 200),
]);
$service = new GeminiService('test-api-key');
$response = $service->generate('System', 'User');
$this->assertEquals('', $response->content);
}
}

View file

@ -2,70 +2,163 @@
declare(strict_types=1); declare(strict_types=1);
namespace Core\Mod\Agentic\Tests\Unit;
use Core\Mod\Agentic\Services\AgenticResponse;
use Core\Mod\Agentic\Services\OpenAIService;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Tests\TestCase;
/** /**
* Tests for the OpenAIService AI provider. * Tests for the OpenAIService AI provider.
* *
* Uses mocked HTTP responses to test the service without real API calls. * Uses mocked HTTP responses to test the service without real API calls.
* Covers provider configuration, API key management, request handling, and responses.
*/ */
class OpenAIServiceTest extends TestCase
{
private const API_URL = 'https://api.openai.com/v1/chat/completions';
// ========================================================================= use Core\Mod\Agentic\Services\AgenticResponse;
// Service Configuration Tests use Core\Mod\Agentic\Services\OpenAIService;
// ========================================================================= use Illuminate\Support\Facades\Http;
use RuntimeException;
public function test_name_returns_openai(): void const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions';
{
// =========================================================================
// Service Configuration Tests
// =========================================================================
describe('provider configuration', function () {
it('returns openai as the provider name', function () {
$service = new OpenAIService('test-api-key'); $service = new OpenAIService('test-api-key');
$this->assertEquals('openai', $service->name()); expect($service->name())->toBe('openai');
} });
public function test_default_model_returns_configured_model(): void it('returns configured model as default model', function () {
{
$service = new OpenAIService('test-api-key', 'gpt-4o'); $service = new OpenAIService('test-api-key', 'gpt-4o');
$this->assertEquals('gpt-4o', $service->defaultModel()); expect($service->defaultModel())->toBe('gpt-4o');
} });
public function test_default_model_uses_gpt4o_mini_when_not_specified(): void it('uses gpt-4o-mini as default model when not specified', function () {
{
$service = new OpenAIService('test-api-key'); $service = new OpenAIService('test-api-key');
$this->assertEquals('gpt-4o-mini', $service->defaultModel()); expect($service->defaultModel())->toBe('gpt-4o-mini');
} });
});
public function test_is_available_returns_true_with_api_key(): void // =========================================================================
{ // API Key Management Tests
// =========================================================================
describe('API key management', function () {
it('reports available when API key is provided', function () {
$service = new OpenAIService('test-api-key'); $service = new OpenAIService('test-api-key');
$this->assertTrue($service->isAvailable()); expect($service->isAvailable())->toBeTrue();
} });
public function test_is_available_returns_false_without_api_key(): void it('reports unavailable when API key is empty', function () {
{
$service = new OpenAIService(''); $service = new OpenAIService('');
$this->assertFalse($service->isAvailable()); expect($service->isAvailable())->toBeFalse();
} });
// ========================================================================= it('sends API key in Authorization Bearer header', function () {
// Generate Tests
// =========================================================================
public function test_generate_returns_agentic_response(): void
{
Http::fake([ Http::fake([
self::API_URL => Http::response([ OPENAI_API_URL => Http::response([
'model' => 'gpt-4o-mini',
'choices' => [
['message' => ['content' => 'Response'], 'finish_reason' => 'stop'],
],
'usage' => ['prompt_tokens' => 10, 'completion_tokens' => 5],
], 200),
]);
$service = new OpenAIService('test-api-key-123');
$service->generate('System', 'User');
Http::assertSent(function ($request) {
return $request->hasHeader('Authorization', 'Bearer test-api-key-123')
&& $request->hasHeader('Content-Type', 'application/json');
});
});
});
// =========================================================================
// Request Handling Tests
// =========================================================================
describe('request handling', function () {
it('sends correct request body structure', function () {
Http::fake([
OPENAI_API_URL => Http::response([
'model' => 'gpt-4o-mini',
'choices' => [
['message' => ['content' => 'Response'], 'finish_reason' => 'stop'],
],
'usage' => ['prompt_tokens' => 10, 'completion_tokens' => 5],
], 200),
]);
$service = new OpenAIService('test-api-key');
$service->generate('System prompt', 'User prompt');
Http::assertSent(function ($request) {
$body = $request->data();
return $body['messages'][0]['role'] === 'system'
&& $body['messages'][0]['content'] === 'System prompt'
&& $body['messages'][1]['role'] === 'user'
&& $body['messages'][1]['content'] === 'User prompt'
&& $body['model'] === 'gpt-4o-mini'
&& $body['max_tokens'] === 4096
&& $body['temperature'] === 1.0;
});
});
it('applies custom configuration overrides', function () {
Http::fake([
OPENAI_API_URL => Http::response([
'model' => 'gpt-4o',
'choices' => [
['message' => ['content' => 'Response'], 'finish_reason' => 'stop'],
],
'usage' => ['prompt_tokens' => 10, 'completion_tokens' => 5],
], 200),
]);
$service = new OpenAIService('test-api-key');
$service->generate('System', 'User', [
'model' => 'gpt-4o',
'max_tokens' => 8192,
'temperature' => 0.5,
]);
Http::assertSent(function ($request) {
$body = $request->data();
return $body['model'] === 'gpt-4o'
&& $body['max_tokens'] === 8192
&& $body['temperature'] === 0.5;
});
});
it('sends stream flag for streaming requests', function () {
Http::fake([
OPENAI_API_URL => Http::response('', 200),
]);
$service = new OpenAIService('test-api-key');
iterator_to_array($service->stream('System', 'User'));
Http::assertSent(function ($request) {
return $request->data()['stream'] === true;
});
});
});
// =========================================================================
// Response Handling Tests
// =========================================================================
describe('response handling', function () {
it('returns AgenticResponse with parsed content', function () {
Http::fake([
OPENAI_API_URL => Http::response([
'id' => 'chatcmpl-123', 'id' => 'chatcmpl-123',
'object' => 'chat.completion', 'object' => 'chat.completion',
'model' => 'gpt-4o-mini', 'model' => 'gpt-4o-mini',
@ -90,95 +183,18 @@ class OpenAIServiceTest extends TestCase
$service = new OpenAIService('test-api-key'); $service = new OpenAIService('test-api-key');
$response = $service->generate('You are helpful.', 'Say hello'); $response = $service->generate('You are helpful.', 'Say hello');
$this->assertInstanceOf(AgenticResponse::class, $response); expect($response)
$this->assertEquals('Hello, world!', $response->content); ->toBeInstanceOf(AgenticResponse::class)
$this->assertEquals('gpt-4o-mini', $response->model); ->and($response->content)->toBe('Hello, world!')
$this->assertEquals(10, $response->inputTokens); ->and($response->model)->toBe('gpt-4o-mini')
$this->assertEquals(5, $response->outputTokens); ->and($response->inputTokens)->toBe(10)
$this->assertEquals('stop', $response->stopReason); ->and($response->outputTokens)->toBe(5)
} ->and($response->stopReason)->toBe('stop');
});
public function test_generate_sends_correct_request_body(): void it('tracks request duration in milliseconds', function () {
{
Http::fake([ Http::fake([
self::API_URL => Http::response([ OPENAI_API_URL => Http::response([
'model' => 'gpt-4o-mini',
'choices' => [
['message' => ['content' => 'Response'], 'finish_reason' => 'stop'],
],
'usage' => ['prompt_tokens' => 10, 'completion_tokens' => 5],
], 200),
]);
$service = new OpenAIService('test-api-key');
$service->generate('System prompt', 'User prompt');
Http::assertSent(function ($request) {
$body = $request->data();
return $body['messages'][0]['role'] === 'system'
&& $body['messages'][0]['content'] === 'System prompt'
&& $body['messages'][1]['role'] === 'user'
&& $body['messages'][1]['content'] === 'User prompt'
&& $body['model'] === 'gpt-4o-mini'
&& $body['max_tokens'] === 4096
&& $body['temperature'] === 1.0;
});
}
public function test_generate_uses_custom_config(): void
{
Http::fake([
self::API_URL => Http::response([
'model' => 'gpt-4o',
'choices' => [
['message' => ['content' => 'Response'], 'finish_reason' => 'stop'],
],
'usage' => ['prompt_tokens' => 10, 'completion_tokens' => 5],
], 200),
]);
$service = new OpenAIService('test-api-key');
$service->generate('System', 'User', [
'model' => 'gpt-4o',
'max_tokens' => 8192,
'temperature' => 0.5,
]);
Http::assertSent(function ($request) {
$body = $request->data();
return $body['model'] === 'gpt-4o'
&& $body['max_tokens'] === 8192
&& $body['temperature'] === 0.5;
});
}
public function test_generate_sends_correct_headers(): void
{
Http::fake([
self::API_URL => Http::response([
'model' => 'gpt-4o-mini',
'choices' => [
['message' => ['content' => 'Response'], 'finish_reason' => 'stop'],
],
'usage' => ['prompt_tokens' => 10, 'completion_tokens' => 5],
], 200),
]);
$service = new OpenAIService('test-api-key-123');
$service->generate('System', 'User');
Http::assertSent(function ($request) {
return $request->hasHeader('Authorization', 'Bearer test-api-key-123')
&& $request->hasHeader('Content-Type', 'application/json');
});
}
public function test_generate_tracks_duration(): void
{
Http::fake([
self::API_URL => Http::response([
'model' => 'gpt-4o-mini', 'model' => 'gpt-4o-mini',
'choices' => [ 'choices' => [
['message' => ['content' => 'Response'], 'finish_reason' => 'stop'], ['message' => ['content' => 'Response'], 'finish_reason' => 'stop'],
@ -190,12 +206,12 @@ class OpenAIServiceTest extends TestCase
$service = new OpenAIService('test-api-key'); $service = new OpenAIService('test-api-key');
$response = $service->generate('System', 'User'); $response = $service->generate('System', 'User');
$this->assertIsInt($response->durationMs); expect($response->durationMs)
$this->assertGreaterThanOrEqual(0, $response->durationMs); ->toBeInt()
} ->toBeGreaterThanOrEqual(0);
});
public function test_generate_includes_raw_response(): void it('includes raw API response for debugging', function () {
{
$rawResponse = [ $rawResponse = [
'id' => 'chatcmpl-123', 'id' => 'chatcmpl-123',
'model' => 'gpt-4o-mini', 'model' => 'gpt-4o-mini',
@ -206,39 +222,124 @@ class OpenAIServiceTest extends TestCase
]; ];
Http::fake([ Http::fake([
self::API_URL => Http::response($rawResponse, 200), OPENAI_API_URL => Http::response($rawResponse, 200),
]); ]);
$service = new OpenAIService('test-api-key'); $service = new OpenAIService('test-api-key');
$response = $service->generate('System', 'User'); $response = $service->generate('System', 'User');
$this->assertEquals('chatcmpl-123', $response->raw['id']); expect($response->raw['id'])->toBe('chatcmpl-123');
} });
// ========================================================================= it('returns generator for streaming responses', function () {
// Error Handling Tests $stream = "data: {\"choices\": [{\"delta\": {\"content\": \"Hello\"}}]}\n\n";
// ========================================================================= $stream .= "data: {\"choices\": [{\"delta\": {\"content\": \" world\"}}]}\n\n";
$stream .= "data: [DONE]\n\n";
public function test_generate_throws_on_client_error(): void
{
Http::fake([ Http::fake([
self::API_URL => Http::response([ OPENAI_API_URL => Http::response($stream, 200, ['Content-Type' => 'text/event-stream']),
]);
$service = new OpenAIService('test-api-key');
$generator = $service->stream('System', 'User');
expect($generator)->toBeInstanceOf(Generator::class);
});
});
// =========================================================================
// Edge Case Tests
// =========================================================================
describe('edge cases', function () {
it('handles empty choices array gracefully', function () {
Http::fake([
OPENAI_API_URL => Http::response([
'model' => 'gpt-4o-mini',
'choices' => [],
'usage' => ['prompt_tokens' => 10, 'completion_tokens' => 0],
], 200),
]);
$service = new OpenAIService('test-api-key');
$response = $service->generate('System', 'User');
expect($response->content)->toBe('');
});
it('handles missing usage data gracefully', function () {
Http::fake([
OPENAI_API_URL => Http::response([
'model' => 'gpt-4o-mini',
'choices' => [
['message' => ['content' => 'Response'], 'finish_reason' => 'stop'],
],
], 200),
]);
$service = new OpenAIService('test-api-key');
$response = $service->generate('System', 'User');
expect($response->inputTokens)->toBe(0)
->and($response->outputTokens)->toBe(0);
});
it('handles missing finish reason gracefully', function () {
Http::fake([
OPENAI_API_URL => Http::response([
'model' => 'gpt-4o-mini',
'choices' => [
['message' => ['content' => 'Response']],
],
'usage' => ['prompt_tokens' => 10, 'completion_tokens' => 5],
], 200),
]);
$service = new OpenAIService('test-api-key');
$response = $service->generate('System', 'User');
expect($response->stopReason)->toBeNull();
});
it('handles null content gracefully', function () {
Http::fake([
OPENAI_API_URL => Http::response([
'model' => 'gpt-4o-mini',
'choices' => [
['message' => ['content' => null], 'finish_reason' => 'stop'],
],
'usage' => ['prompt_tokens' => 10, 'completion_tokens' => 0],
], 200),
]);
$service = new OpenAIService('test-api-key');
$response = $service->generate('System', 'User');
expect($response->content)->toBe('');
});
});
// =========================================================================
// Error Handling and Retry Tests
// =========================================================================
describe('error handling', function () {
it('throws exception on client authentication error', function () {
Http::fake([
OPENAI_API_URL => Http::response([
'error' => ['message' => 'Invalid API key'], 'error' => ['message' => 'Invalid API key'],
], 401), ], 401),
]); ]);
$service = new OpenAIService('invalid-key'); $service = new OpenAIService('invalid-key');
$this->expectException(RuntimeException::class); expect(fn () => $service->generate('System', 'User'))
$this->expectExceptionMessage('OpenAI API error'); ->toThrow(RuntimeException::class, 'OpenAI API error');
});
$service->generate('System', 'User'); it('retries automatically on rate limit (429)', function () {
}
public function test_generate_retries_on_rate_limit(): void
{
Http::fake([ Http::fake([
self::API_URL => Http::sequence() OPENAI_API_URL => Http::sequence()
->push(['error' => ['message' => 'Rate limited']], 429) ->push(['error' => ['message' => 'Rate limited']], 429)
->push([ ->push([
'model' => 'gpt-4o-mini', 'model' => 'gpt-4o-mini',
@ -252,13 +353,12 @@ class OpenAIServiceTest extends TestCase
$service = new OpenAIService('test-api-key'); $service = new OpenAIService('test-api-key');
$response = $service->generate('System', 'User'); $response = $service->generate('System', 'User');
$this->assertEquals('Success after retry', $response->content); expect($response->content)->toBe('Success after retry');
} });
public function test_generate_retries_on_server_error(): void it('retries automatically on server error (500)', function () {
{
Http::fake([ Http::fake([
self::API_URL => Http::sequence() OPENAI_API_URL => Http::sequence()
->push(['error' => ['message' => 'Server error']], 500) ->push(['error' => ['message' => 'Server error']], 500)
->push([ ->push([
'model' => 'gpt-4o-mini', 'model' => 'gpt-4o-mini',
@ -272,128 +372,17 @@ class OpenAIServiceTest extends TestCase
$service = new OpenAIService('test-api-key'); $service = new OpenAIService('test-api-key');
$response = $service->generate('System', 'User'); $response = $service->generate('System', 'User');
$this->assertEquals('Success after retry', $response->content); expect($response->content)->toBe('Success after retry');
} });
public function test_generate_throws_after_max_retries(): void it('throws exception after exhausting max retries', function () {
{
Http::fake([ Http::fake([
self::API_URL => Http::response(['error' => ['message' => 'Server error']], 500), OPENAI_API_URL => Http::response(['error' => ['message' => 'Server error']], 500),
]); ]);
$service = new OpenAIService('test-api-key'); $service = new OpenAIService('test-api-key');
$this->expectException(RuntimeException::class); expect(fn () => $service->generate('System', 'User'))
->toThrow(RuntimeException::class);
$service->generate('System', 'User'); });
} });
// =========================================================================
// Stream Tests
// =========================================================================
public function test_stream_is_generator(): void
{
// Create a simple SSE stream response
$stream = "data: {\"choices\": [{\"delta\": {\"content\": \"Hello\"}}]}\n\n";
$stream .= "data: {\"choices\": [{\"delta\": {\"content\": \" world\"}}]}\n\n";
$stream .= "data: [DONE]\n\n";
Http::fake([
self::API_URL => Http::response($stream, 200, ['Content-Type' => 'text/event-stream']),
]);
$service = new OpenAIService('test-api-key');
$generator = $service->stream('System', 'User');
$this->assertInstanceOf(\Generator::class, $generator);
}
public function test_stream_sends_stream_flag(): void
{
Http::fake([
self::API_URL => Http::response('', 200),
]);
$service = new OpenAIService('test-api-key');
iterator_to_array($service->stream('System', 'User'));
Http::assertSent(function ($request) {
return $request->data()['stream'] === true;
});
}
// =========================================================================
// Response Handling Edge Cases
// =========================================================================
public function test_handles_empty_choices(): void
{
Http::fake([
self::API_URL => Http::response([
'model' => 'gpt-4o-mini',
'choices' => [],
'usage' => ['prompt_tokens' => 10, 'completion_tokens' => 0],
], 200),
]);
$service = new OpenAIService('test-api-key');
$response = $service->generate('System', 'User');
$this->assertEquals('', $response->content);
}
public function test_handles_missing_usage_data(): void
{
Http::fake([
self::API_URL => Http::response([
'model' => 'gpt-4o-mini',
'choices' => [
['message' => ['content' => 'Response'], 'finish_reason' => 'stop'],
],
], 200),
]);
$service = new OpenAIService('test-api-key');
$response = $service->generate('System', 'User');
$this->assertEquals(0, $response->inputTokens);
$this->assertEquals(0, $response->outputTokens);
}
public function test_handles_missing_finish_reason(): void
{
Http::fake([
self::API_URL => Http::response([
'model' => 'gpt-4o-mini',
'choices' => [
['message' => ['content' => 'Response']],
],
'usage' => ['prompt_tokens' => 10, 'completion_tokens' => 5],
], 200),
]);
$service = new OpenAIService('test-api-key');
$response = $service->generate('System', 'User');
$this->assertNull($response->stopReason);
}
public function test_handles_null_content(): void
{
Http::fake([
self::API_URL => Http::response([
'model' => 'gpt-4o-mini',
'choices' => [
['message' => ['content' => null], 'finish_reason' => 'stop'],
],
'usage' => ['prompt_tokens' => 10, 'completion_tokens' => 0],
], 200),
]);
$service = new OpenAIService('test-api-key');
$response = $service->generate('System', 'User');
$this->assertEquals('', $response->content);
}
}