feat(tests): add AgenticManager tests
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
9e513af049
commit
27d08bbe43
5 changed files with 1076 additions and 714 deletions
16
TODO.md
16
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
|
- 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
|
||||||
|
|
||||||
|
|
|
||||||
389
tests/Unit/AgenticManagerTest.php
Normal file
389
tests/Unit/AgenticManagerTest.php
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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
|
|
||||||
{
|
use Core\Mod\Agentic\Services\AgenticResponse;
|
||||||
private const API_URL = 'https://api.anthropic.com/v1/messages';
|
use Core\Mod\Agentic\Services\ClaudeService;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
const CLAUDE_API_URL = 'https://api.anthropic.com/v1/messages';
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Service Configuration Tests
|
// Service Configuration Tests
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
public function test_name_returns_claude(): void
|
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
|
|
||||||
{
|
|
||||||
Http::fake([
|
|
||||||
self::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;
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
it('throws exception after exhausting max retries', function () {
|
||||||
// Response Handling Edge Cases
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
public function test_handles_empty_content(): void
|
|
||||||
{
|
|
||||||
Http::fake([
|
Http::fake([
|
||||||
self::API_URL => Http::response([
|
CLAUDE_API_URL => Http::response(['error' => ['message' => 'Server error']], 500),
|
||||||
'model' => 'claude-sonnet-4-20250514',
|
|
||||||
'content' => [],
|
|
||||||
'usage' => ['input_tokens' => 10, 'output_tokens' => 0],
|
|
||||||
], 200),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$service = new ClaudeService('test-api-key');
|
$service = new ClaudeService('test-api-key');
|
||||||
$response = $service->generate('System', 'User');
|
|
||||||
|
|
||||||
$this->assertEquals('', $response->content);
|
expect(fn () => $service->generate('System', 'User'))
|
||||||
}
|
->toThrow(RuntimeException::class);
|
||||||
|
});
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
{
|
use Core\Mod\Agentic\Services\AgenticResponse;
|
||||||
private const API_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
|
use Core\Mod\Agentic\Services\GeminiService;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
const GEMINI_API_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Service Configuration Tests
|
// Service Configuration Tests
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
public function test_name_returns_gemini(): void
|
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
|
|
||||||
{
|
|
||||||
Http::fake([
|
|
||||||
self::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');
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
it('throws exception after exhausting max retries', function () {
|
||||||
// Response Handling Edge Cases
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
public function test_handles_empty_candidates(): void
|
|
||||||
{
|
|
||||||
Http::fake([
|
Http::fake([
|
||||||
self::API_URL.'/*' => Http::response([
|
GEMINI_API_URL.'/*' => Http::response(['error' => ['message' => 'Server error']], 500),
|
||||||
'candidates' => [],
|
|
||||||
'usageMetadata' => ['promptTokenCount' => 10, 'candidatesTokenCount' => 0],
|
|
||||||
], 200),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$service = new GeminiService('test-api-key');
|
$service = new GeminiService('test-api-key');
|
||||||
$response = $service->generate('System', 'User');
|
|
||||||
|
|
||||||
$this->assertEquals('', $response->content);
|
expect(fn () => $service->generate('System', 'User'))
|
||||||
}
|
->toThrow(RuntimeException::class);
|
||||||
|
});
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
{
|
use Core\Mod\Agentic\Services\AgenticResponse;
|
||||||
private const API_URL = 'https://api.openai.com/v1/chat/completions';
|
use Core\Mod\Agentic\Services\OpenAIService;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions';
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Service Configuration Tests
|
// Service Configuration Tests
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
public function test_name_returns_openai(): void
|
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
|
|
||||||
{
|
|
||||||
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
|
it('tracks request duration in milliseconds', function () {
|
||||||
{
|
|
||||||
Http::fake([
|
Http::fake([
|
||||||
self::API_URL => Http::response([
|
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
{
|
|
||||||
Http::fake([
|
|
||||||
self::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;
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
it('throws exception after exhausting max retries', function () {
|
||||||
// Response Handling Edge Cases
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
public function test_handles_empty_choices(): void
|
|
||||||
{
|
|
||||||
Http::fake([
|
Http::fake([
|
||||||
self::API_URL => Http::response([
|
OPENAI_API_URL => Http::response(['error' => ['message' => 'Server error']], 500),
|
||||||
'model' => 'gpt-4o-mini',
|
|
||||||
'choices' => [],
|
|
||||||
'usage' => ['prompt_tokens' => 10, 'completion_tokens' => 0],
|
|
||||||
], 200),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$service = new OpenAIService('test-api-key');
|
$service = new OpenAIService('test-api-key');
|
||||||
$response = $service->generate('System', 'User');
|
|
||||||
|
|
||||||
$this->assertEquals('', $response->content);
|
expect(fn () => $service->generate('System', 'User'))
|
||||||
}
|
->toThrow(RuntimeException::class);
|
||||||
|
});
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue