diff --git a/TODO.md b/TODO.md index 267a2ee..9be662d 100644 --- a/TODO.md +++ b/TODO.md @@ -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 - [x] **TEST-005: Add AI provider service tests** (FIXED 2026-01-29) - - Created `tests/Unit/ClaudeServiceTest.php` - Anthropic Claude API tests - - Created `tests/Unit/GeminiServiceTest.php` - Google Gemini API tests - - Created `tests/Unit/OpenAIServiceTest.php` - OpenAI API tests - - All use mocked HTTP responses, cover generate/stream/retry/error handling + - Created `tests/Unit/ClaudeServiceTest.php` - Anthropic Claude API tests (Pest functional syntax) + - Created `tests/Unit/GeminiServiceTest.php` - Google Gemini API tests (Pest functional syntax) + - Created `tests/Unit/OpenAIServiceTest.php` - OpenAI API tests (Pest functional syntax) + - 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 @@ -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-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-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) @@ -287,7 +291,7 @@ Production-quality task list for the AI agent orchestration package. **Test Coverage Estimate:** ~65% (improved from ~35%) - Models: Well tested (AgentPlan, AgentPhase, AgentSession, AgentApiKey) - 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) - Livewire: Untested diff --git a/tests/Unit/AgenticManagerTest.php b/tests/Unit/AgenticManagerTest.php new file mode 100644 index 0000000..ffee680 --- /dev/null +++ b/tests/Unit/AgenticManagerTest.php @@ -0,0 +1,389 @@ +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); + }); +}); diff --git a/tests/Unit/ClaudeServiceTest.php b/tests/Unit/ClaudeServiceTest.php index 8df0dec..2a1b07c 100644 --- a/tests/Unit/ClaudeServiceTest.php +++ b/tests/Unit/ClaudeServiceTest.php @@ -2,100 +2,89 @@ 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. * * 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'; - // ========================================================================= - // Service Configuration Tests - // ========================================================================= +use Core\Mod\Agentic\Services\AgenticResponse; +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'); - $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'); - $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'); - $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'); - $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(''); - $this->assertFalse($service->isAvailable()); - } + expect($service->isAvailable())->toBeFalse(); + }); - // ========================================================================= - // Generate Tests - // ========================================================================= - - public function test_generate_returns_agentic_response(): void - { + it('sends API key in x-api-key header', function () { Http::fake([ - self::API_URL => Http::response([ - 'id' => 'msg_123', - 'type' => 'message', - 'role' => 'assistant', + CLAUDE_API_URL => Http::response([ 'model' => 'claude-sonnet-4-20250514', - 'content' => [ - ['type' => 'text', 'text' => 'Hello, world!'], - ], - 'stop_reason' => 'end_turn', - 'usage' => [ - 'input_tokens' => 10, - 'output_tokens' => 5, - ], + 'content' => [['type' => 'text', 'text' => 'Response']], + 'usage' => ['input_tokens' => 10, 'output_tokens' => 5], ], 200), ]); - $service = new ClaudeService('test-api-key'); - $response = $service->generate('You are helpful.', 'Say hello'); + $service = new ClaudeService('test-api-key-123'); + $service->generate('System', 'User'); - $this->assertInstanceOf(AgenticResponse::class, $response); - $this->assertEquals('Hello, world!', $response->content); - $this->assertEquals('claude-sonnet-4-20250514', $response->model); - $this->assertEquals(10, $response->inputTokens); - $this->assertEquals(5, $response->outputTokens); - $this->assertEquals('end_turn', $response->stopReason); - } + Http::assertSent(function ($request) { + return $request->hasHeader('x-api-key', 'test-api-key-123') + && $request->hasHeader('anthropic-version', '2023-06-01') + && $request->hasHeader('content-type', 'application/json'); + }); + }); +}); - 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([ - self::API_URL => Http::response([ + CLAUDE_API_URL => Http::response([ 'model' => 'claude-sonnet-4-20250514', 'content' => [['type' => 'text', 'text' => 'Response']], 'usage' => ['input_tokens' => 10, 'output_tokens' => 5], @@ -115,12 +104,11 @@ class ClaudeServiceTest extends TestCase && $body['max_tokens'] === 4096 && $body['temperature'] === 1.0; }); - } + }); - public function test_generate_uses_custom_config(): void - { + it('applies custom configuration overrides', function () { Http::fake([ - self::API_URL => Http::response([ + CLAUDE_API_URL => Http::response([ 'model' => 'claude-opus-4-20250514', 'content' => [['type' => 'text', 'text' => 'Response']], 'usage' => ['input_tokens' => 10, 'output_tokens' => 5], @@ -141,32 +129,60 @@ class ClaudeServiceTest extends TestCase && $body['max_tokens'] === 8192 && $body['temperature'] === 0.5; }); - } + }); - public function test_generate_sends_correct_headers(): void - { + it('sends stream flag for streaming requests', function () { 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', - 'content' => [['type' => 'text', 'text' => 'Response']], - 'usage' => ['input_tokens' => 10, 'output_tokens' => 5], + 'content' => [ + ['type' => 'text', 'text' => 'Hello, world!'], + ], + 'stop_reason' => 'end_turn', + 'usage' => [ + 'input_tokens' => 10, + 'output_tokens' => 5, + ], ], 200), ]); - $service = new ClaudeService('test-api-key-123'); - $service->generate('System', 'User'); + $service = new ClaudeService('test-api-key'); + $response = $service->generate('You are helpful.', 'Say hello'); - Http::assertSent(function ($request) { - return $request->hasHeader('x-api-key', 'test-api-key-123') - && $request->hasHeader('anthropic-version', '2023-06-01') - && $request->hasHeader('content-type', 'application/json'); - }); - } + expect($response) + ->toBeInstanceOf(AgenticResponse::class) + ->and($response->content)->toBe('Hello, world!') + ->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([ - self::API_URL => Http::response([ + CLAUDE_API_URL => Http::response([ 'model' => 'claude-sonnet-4-20250514', 'content' => [['type' => 'text', 'text' => 'Response']], 'usage' => ['input_tokens' => 10, 'output_tokens' => 5], @@ -176,12 +192,12 @@ class ClaudeServiceTest extends TestCase $service = new ClaudeService('test-api-key'); $response = $service->generate('System', 'User'); - $this->assertIsInt($response->durationMs); - $this->assertGreaterThanOrEqual(0, $response->durationMs); - } + expect($response->durationMs) + ->toBeInt() + ->toBeGreaterThanOrEqual(0); + }); - public function test_generate_includes_raw_response(): void - { + it('includes raw API response for debugging', function () { $rawResponse = [ 'id' => 'msg_123', 'model' => 'claude-sonnet-4-20250514', @@ -190,39 +206,103 @@ class ClaudeServiceTest extends TestCase ]; Http::fake([ - self::API_URL => Http::response($rawResponse, 200), + CLAUDE_API_URL => Http::response($rawResponse, 200), ]); $service = new ClaudeService('test-api-key'); $response = $service->generate('System', 'User'); - $this->assertEquals('msg_123', $response->raw['id']); - } + expect($response->raw['id'])->toBe('msg_123'); + }); - // ========================================================================= - // Error Handling Tests - // ========================================================================= + it('returns generator for streaming responses', function () { + $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([ - 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'], ], 401), ]); $service = new ClaudeService('invalid-key'); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Claude API error'); + expect(fn () => $service->generate('System', 'User')) + ->toThrow(RuntimeException::class, 'Claude API error'); + }); - $service->generate('System', 'User'); - } - - public function test_generate_retries_on_rate_limit(): void - { + it('retries automatically on rate limit (429)', function () { Http::fake([ - self::API_URL => Http::sequence() + CLAUDE_API_URL => Http::sequence() ->push(['error' => ['message' => 'Rate limited']], 429) ->push([ 'model' => 'claude-sonnet-4-20250514', @@ -234,13 +314,12 @@ class ClaudeServiceTest extends TestCase $service = new ClaudeService('test-api-key'); $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([ - self::API_URL => Http::sequence() + CLAUDE_API_URL => Http::sequence() ->push(['error' => ['message' => 'Server error']], 500) ->push([ 'model' => 'claude-sonnet-4-20250514', @@ -252,106 +331,17 @@ class ClaudeServiceTest extends TestCase $service = new ClaudeService('test-api-key'); $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([ - 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'); - $this->expectException(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); - } -} + expect(fn () => $service->generate('System', 'User')) + ->toThrow(RuntimeException::class); + }); +}); diff --git a/tests/Unit/GeminiServiceTest.php b/tests/Unit/GeminiServiceTest.php index 6cc4a22..1905f47 100644 --- a/tests/Unit/GeminiServiceTest.php +++ b/tests/Unit/GeminiServiceTest.php @@ -2,100 +2,88 @@ 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. * * 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'; - // ========================================================================= - // Service Configuration Tests - // ========================================================================= +use Core\Mod\Agentic\Services\AgenticResponse; +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'); - $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'); - $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'); - $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'); - $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(''); - $this->assertFalse($service->isAvailable()); - } + expect($service->isAvailable())->toBeFalse(); + }); - // ========================================================================= - // Generate Tests - // ========================================================================= - - public function test_generate_returns_agentic_response(): void - { + it('sends API key in query parameter', function () { Http::fake([ - self::API_URL.'/*' => Http::response([ + GEMINI_API_URL.'/*' => Http::response([ 'candidates' => [ - [ - 'content' => [ - 'parts' => [['text' => 'Hello, world!']], - ], - 'finishReason' => 'STOP', - ], - ], - 'usageMetadata' => [ - 'promptTokenCount' => 10, - 'candidatesTokenCount' => 5, + ['content' => ['parts' => [['text' => 'Response']]]], ], + 'usageMetadata' => ['promptTokenCount' => 10, 'candidatesTokenCount' => 5], ], 200), ]); - $service = new GeminiService('test-api-key'); - $response = $service->generate('You are helpful.', 'Say hello'); + $service = new GeminiService('test-api-key-123'); + $service->generate('System', 'User'); - $this->assertInstanceOf(AgenticResponse::class, $response); - $this->assertEquals('Hello, world!', $response->content); - $this->assertEquals('gemini-2.0-flash', $response->model); - $this->assertEquals(10, $response->inputTokens); - $this->assertEquals(5, $response->outputTokens); - $this->assertEquals('STOP', $response->stopReason); - } + Http::assertSent(function ($request) { + return str_contains($request->url(), 'key=test-api-key-123'); + }); + }); +}); - 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([ - self::API_URL.'/*' => Http::response([ + GEMINI_API_URL.'/*' => Http::response([ 'candidates' => [ ['content' => ['parts' => [['text' => 'Response']]]], ], @@ -114,12 +102,11 @@ class GeminiServiceTest extends TestCase && $body['generationConfig']['maxOutputTokens'] === 4096 && $body['generationConfig']['temperature'] === 1.0; }); - } + }); - public function test_generate_uses_model_in_url(): void - { + it('includes model name in URL', function () { Http::fake([ - self::API_URL.'/*' => Http::response([ + GEMINI_API_URL.'/*' => Http::response([ 'candidates' => [ ['content' => ['parts' => [['text' => 'Response']]]], ], @@ -133,12 +120,11 @@ class GeminiServiceTest extends TestCase Http::assertSent(function ($request) { 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([ - self::API_URL.'/*' => Http::response([ + GEMINI_API_URL.'/*' => Http::response([ 'candidates' => [ ['content' => ['parts' => [['text' => 'Response']]]], ], @@ -160,31 +146,60 @@ class GeminiServiceTest extends TestCase && $body['generationConfig']['maxOutputTokens'] === 8192 && $body['generationConfig']['temperature'] === 0.5; }); - } + }); - public function test_generate_sends_api_key_in_query(): void - { + it('uses streamGenerateContent endpoint for streaming', function () { 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' => [ - ['content' => ['parts' => [['text' => 'Response']]]], + [ + 'content' => [ + 'parts' => [['text' => 'Hello, world!']], + ], + 'finishReason' => 'STOP', + ], + ], + 'usageMetadata' => [ + 'promptTokenCount' => 10, + 'candidatesTokenCount' => 5, ], - 'usageMetadata' => ['promptTokenCount' => 10, 'candidatesTokenCount' => 5], ], 200), ]); - $service = new GeminiService('test-api-key-123'); - $service->generate('System', 'User'); + $service = new GeminiService('test-api-key'); + $response = $service->generate('You are helpful.', 'Say hello'); - Http::assertSent(function ($request) { - return str_contains($request->url(), 'key=test-api-key-123'); - }); - } + expect($response) + ->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([ - self::API_URL.'/*' => Http::response([ + GEMINI_API_URL.'/*' => Http::response([ 'candidates' => [ ['content' => ['parts' => [['text' => 'Response']]]], ], @@ -195,12 +210,12 @@ class GeminiServiceTest extends TestCase $service = new GeminiService('test-api-key'); $response = $service->generate('System', 'User'); - $this->assertIsInt($response->durationMs); - $this->assertGreaterThanOrEqual(0, $response->durationMs); - } + expect($response->durationMs) + ->toBeInt() + ->toBeGreaterThanOrEqual(0); + }); - public function test_generate_includes_raw_response(): void - { + it('includes raw API response for debugging', function () { $rawResponse = [ 'candidates' => [ ['content' => ['parts' => [['text' => 'Response']]]], @@ -209,40 +224,118 @@ class GeminiServiceTest extends TestCase ]; Http::fake([ - self::API_URL.'/*' => Http::response($rawResponse, 200), + GEMINI_API_URL.'/*' => Http::response($rawResponse, 200), ]); $service = new GeminiService('test-api-key'); $response = $service->generate('System', 'User'); - $this->assertArrayHasKey('candidates', $response->raw); - $this->assertArrayHasKey('usageMetadata', $response->raw); - } + expect($response->raw) + ->toHaveKey('candidates') + ->toHaveKey('usageMetadata'); + }); - // ========================================================================= - // Error Handling Tests - // ========================================================================= - - public function test_generate_throws_on_client_error(): void - { + it('returns generator for streaming responses', function () { 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'], ], 401), ]); $service = new GeminiService('invalid-key'); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Gemini API error'); + expect(fn () => $service->generate('System', 'User')) + ->toThrow(RuntimeException::class, 'Gemini API error'); + }); - $service->generate('System', 'User'); - } - - public function test_generate_retries_on_rate_limit(): void - { + it('retries automatically on rate limit (429)', function () { Http::fake([ - self::API_URL.'/*' => Http::sequence() + GEMINI_API_URL.'/*' => Http::sequence() ->push(['error' => ['message' => 'Rate limited']], 429) ->push([ 'candidates' => [ @@ -255,13 +348,12 @@ class GeminiServiceTest extends TestCase $service = new GeminiService('test-api-key'); $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([ - self::API_URL.'/*' => Http::sequence() + GEMINI_API_URL.'/*' => Http::sequence() ->push(['error' => ['message' => 'Server error']], 500) ->push([ 'candidates' => [ @@ -274,119 +366,17 @@ class GeminiServiceTest extends TestCase $service = new GeminiService('test-api-key'); $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([ - 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'); - $this->expectException(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); - } -} + expect(fn () => $service->generate('System', 'User')) + ->toThrow(RuntimeException::class); + }); +}); diff --git a/tests/Unit/OpenAIServiceTest.php b/tests/Unit/OpenAIServiceTest.php index e4293e1..b593df9 100644 --- a/tests/Unit/OpenAIServiceTest.php +++ b/tests/Unit/OpenAIServiceTest.php @@ -2,70 +2,163 @@ 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. * * 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'; - // ========================================================================= - // Service Configuration Tests - // ========================================================================= +use Core\Mod\Agentic\Services\AgenticResponse; +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'); - $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'); - $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'); - $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'); - $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(''); - $this->assertFalse($service->isAvailable()); - } + expect($service->isAvailable())->toBeFalse(); + }); - // ========================================================================= - // Generate Tests - // ========================================================================= - - public function test_generate_returns_agentic_response(): void - { + it('sends API key in Authorization Bearer header', function () { 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', 'object' => 'chat.completion', 'model' => 'gpt-4o-mini', @@ -90,95 +183,18 @@ class OpenAIServiceTest extends TestCase $service = new OpenAIService('test-api-key'); $response = $service->generate('You are helpful.', 'Say hello'); - $this->assertInstanceOf(AgenticResponse::class, $response); - $this->assertEquals('Hello, world!', $response->content); - $this->assertEquals('gpt-4o-mini', $response->model); - $this->assertEquals(10, $response->inputTokens); - $this->assertEquals(5, $response->outputTokens); - $this->assertEquals('stop', $response->stopReason); - } + expect($response) + ->toBeInstanceOf(AgenticResponse::class) + ->and($response->content)->toBe('Hello, world!') + ->and($response->model)->toBe('gpt-4o-mini') + ->and($response->inputTokens)->toBe(10) + ->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([ - 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'); - $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([ + OPENAI_API_URL => Http::response([ 'model' => 'gpt-4o-mini', 'choices' => [ ['message' => ['content' => 'Response'], 'finish_reason' => 'stop'], @@ -190,12 +206,12 @@ class OpenAIServiceTest extends TestCase $service = new OpenAIService('test-api-key'); $response = $service->generate('System', 'User'); - $this->assertIsInt($response->durationMs); - $this->assertGreaterThanOrEqual(0, $response->durationMs); - } + expect($response->durationMs) + ->toBeInt() + ->toBeGreaterThanOrEqual(0); + }); - public function test_generate_includes_raw_response(): void - { + it('includes raw API response for debugging', function () { $rawResponse = [ 'id' => 'chatcmpl-123', 'model' => 'gpt-4o-mini', @@ -206,39 +222,124 @@ class OpenAIServiceTest extends TestCase ]; Http::fake([ - self::API_URL => Http::response($rawResponse, 200), + OPENAI_API_URL => Http::response($rawResponse, 200), ]); $service = new OpenAIService('test-api-key'); $response = $service->generate('System', 'User'); - $this->assertEquals('chatcmpl-123', $response->raw['id']); - } + expect($response->raw['id'])->toBe('chatcmpl-123'); + }); - // ========================================================================= - // Error Handling Tests - // ========================================================================= + it('returns generator for streaming responses', function () { + $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([ - 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'], ], 401), ]); $service = new OpenAIService('invalid-key'); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('OpenAI API error'); + expect(fn () => $service->generate('System', 'User')) + ->toThrow(RuntimeException::class, 'OpenAI API error'); + }); - $service->generate('System', 'User'); - } - - public function test_generate_retries_on_rate_limit(): void - { + it('retries automatically on rate limit (429)', function () { Http::fake([ - self::API_URL => Http::sequence() + OPENAI_API_URL => Http::sequence() ->push(['error' => ['message' => 'Rate limited']], 429) ->push([ 'model' => 'gpt-4o-mini', @@ -252,13 +353,12 @@ class OpenAIServiceTest extends TestCase $service = new OpenAIService('test-api-key'); $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([ - self::API_URL => Http::sequence() + OPENAI_API_URL => Http::sequence() ->push(['error' => ['message' => 'Server error']], 500) ->push([ 'model' => 'gpt-4o-mini', @@ -272,128 +372,17 @@ class OpenAIServiceTest extends TestCase $service = new OpenAIService('test-api-key'); $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([ - 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'); - $this->expectException(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); - } -} + expect(fn () => $service->generate('System', 'User')) + ->toThrow(RuntimeException::class); + }); +});