393 lines
13 KiB
PHP
393 lines
13 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace Core\Mod\Agentic\Tests\Unit;
|
||
|
|
|
||
|
|
use Core\Mod\Agentic\Services\AgenticResponse;
|
||
|
|
use Core\Mod\Agentic\Services\GeminiService;
|
||
|
|
use Illuminate\Support\Facades\Http;
|
||
|
|
use RuntimeException;
|
||
|
|
use Tests\TestCase;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Tests for the GeminiService AI provider.
|
||
|
|
*
|
||
|
|
* Uses mocked HTTP responses to test the service without real API calls.
|
||
|
|
*/
|
||
|
|
class GeminiServiceTest extends TestCase
|
||
|
|
{
|
||
|
|
private const API_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
|
||
|
|
|
||
|
|
// =========================================================================
|
||
|
|
// Service Configuration Tests
|
||
|
|
// =========================================================================
|
||
|
|
|
||
|
|
public function test_name_returns_gemini(): void
|
||
|
|
{
|
||
|
|
$service = new GeminiService('test-api-key');
|
||
|
|
|
||
|
|
$this->assertEquals('gemini', $service->name());
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_default_model_returns_configured_model(): void
|
||
|
|
{
|
||
|
|
$service = new GeminiService('test-api-key', 'gemini-1.5-pro');
|
||
|
|
|
||
|
|
$this->assertEquals('gemini-1.5-pro', $service->defaultModel());
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_default_model_uses_flash_when_not_specified(): void
|
||
|
|
{
|
||
|
|
$service = new GeminiService('test-api-key');
|
||
|
|
|
||
|
|
$this->assertEquals('gemini-2.0-flash', $service->defaultModel());
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_is_available_returns_true_with_api_key(): void
|
||
|
|
{
|
||
|
|
$service = new GeminiService('test-api-key');
|
||
|
|
|
||
|
|
$this->assertTrue($service->isAvailable());
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_is_available_returns_false_without_api_key(): void
|
||
|
|
{
|
||
|
|
$service = new GeminiService('');
|
||
|
|
|
||
|
|
$this->assertFalse($service->isAvailable());
|
||
|
|
}
|
||
|
|
|
||
|
|
// =========================================================================
|
||
|
|
// Generate Tests
|
||
|
|
// =========================================================================
|
||
|
|
|
||
|
|
public function test_generate_returns_agentic_response(): void
|
||
|
|
{
|
||
|
|
Http::fake([
|
||
|
|
self::API_URL.'/*' => Http::response([
|
||
|
|
'candidates' => [
|
||
|
|
[
|
||
|
|
'content' => [
|
||
|
|
'parts' => [['text' => 'Hello, world!']],
|
||
|
|
],
|
||
|
|
'finishReason' => 'STOP',
|
||
|
|
],
|
||
|
|
],
|
||
|
|
'usageMetadata' => [
|
||
|
|
'promptTokenCount' => 10,
|
||
|
|
'candidatesTokenCount' => 5,
|
||
|
|
],
|
||
|
|
], 200),
|
||
|
|
]);
|
||
|
|
|
||
|
|
$service = new GeminiService('test-api-key');
|
||
|
|
$response = $service->generate('You are helpful.', 'Say hello');
|
||
|
|
|
||
|
|
$this->assertInstanceOf(AgenticResponse::class, $response);
|
||
|
|
$this->assertEquals('Hello, world!', $response->content);
|
||
|
|
$this->assertEquals('gemini-2.0-flash', $response->model);
|
||
|
|
$this->assertEquals(10, $response->inputTokens);
|
||
|
|
$this->assertEquals(5, $response->outputTokens);
|
||
|
|
$this->assertEquals('STOP', $response->stopReason);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_generate_sends_correct_request_body(): 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');
|
||
|
|
$service->generate('System prompt', 'User prompt');
|
||
|
|
|
||
|
|
Http::assertSent(function ($request) {
|
||
|
|
$body = $request->data();
|
||
|
|
|
||
|
|
return $body['systemInstruction']['parts'][0]['text'] === 'System prompt'
|
||
|
|
&& $body['contents'][0]['parts'][0]['text'] === 'User prompt'
|
||
|
|
&& $body['generationConfig']['maxOutputTokens'] === 4096
|
||
|
|
&& $body['generationConfig']['temperature'] === 1.0;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_generate_uses_model_in_url(): 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', 'gemini-1.5-pro');
|
||
|
|
$service->generate('System', 'User');
|
||
|
|
|
||
|
|
Http::assertSent(function ($request) {
|
||
|
|
return str_contains($request->url(), 'gemini-1.5-pro:generateContent');
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_generate_uses_custom_config(): 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');
|
||
|
|
$service->generate('System', 'User', [
|
||
|
|
'model' => 'gemini-1.5-pro',
|
||
|
|
'max_tokens' => 8192,
|
||
|
|
'temperature' => 0.5,
|
||
|
|
]);
|
||
|
|
|
||
|
|
Http::assertSent(function ($request) {
|
||
|
|
$body = $request->data();
|
||
|
|
|
||
|
|
return str_contains($request->url(), 'gemini-1.5-pro')
|
||
|
|
&& $body['generationConfig']['maxOutputTokens'] === 8192
|
||
|
|
&& $body['generationConfig']['temperature'] === 0.5;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_generate_sends_api_key_in_query(): 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-123');
|
||
|
|
$service->generate('System', 'User');
|
||
|
|
|
||
|
|
Http::assertSent(function ($request) {
|
||
|
|
return str_contains($request->url(), 'key=test-api-key-123');
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_generate_tracks_duration(): 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->assertIsInt($response->durationMs);
|
||
|
|
$this->assertGreaterThanOrEqual(0, $response->durationMs);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_generate_includes_raw_response(): void
|
||
|
|
{
|
||
|
|
$rawResponse = [
|
||
|
|
'candidates' => [
|
||
|
|
['content' => ['parts' => [['text' => 'Response']]]],
|
||
|
|
],
|
||
|
|
'usageMetadata' => ['promptTokenCount' => 10, 'candidatesTokenCount' => 5],
|
||
|
|
];
|
||
|
|
|
||
|
|
Http::fake([
|
||
|
|
self::API_URL.'/*' => Http::response($rawResponse, 200),
|
||
|
|
]);
|
||
|
|
|
||
|
|
$service = new GeminiService('test-api-key');
|
||
|
|
$response = $service->generate('System', 'User');
|
||
|
|
|
||
|
|
$this->assertArrayHasKey('candidates', $response->raw);
|
||
|
|
$this->assertArrayHasKey('usageMetadata', $response->raw);
|
||
|
|
}
|
||
|
|
|
||
|
|
// =========================================================================
|
||
|
|
// Error Handling Tests
|
||
|
|
// =========================================================================
|
||
|
|
|
||
|
|
public function test_generate_throws_on_client_error(): void
|
||
|
|
{
|
||
|
|
Http::fake([
|
||
|
|
self::API_URL.'/*' => Http::response([
|
||
|
|
'error' => ['message' => 'Invalid API key'],
|
||
|
|
], 401),
|
||
|
|
]);
|
||
|
|
|
||
|
|
$service = new GeminiService('invalid-key');
|
||
|
|
|
||
|
|
$this->expectException(RuntimeException::class);
|
||
|
|
$this->expectExceptionMessage('Gemini API error');
|
||
|
|
|
||
|
|
$service->generate('System', 'User');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_generate_retries_on_rate_limit(): void
|
||
|
|
{
|
||
|
|
Http::fake([
|
||
|
|
self::API_URL.'/*' => Http::sequence()
|
||
|
|
->push(['error' => ['message' => 'Rate limited']], 429)
|
||
|
|
->push([
|
||
|
|
'candidates' => [
|
||
|
|
['content' => ['parts' => [['text' => 'Success after retry']]]],
|
||
|
|
],
|
||
|
|
'usageMetadata' => ['promptTokenCount' => 10, 'candidatesTokenCount' => 5],
|
||
|
|
], 200),
|
||
|
|
]);
|
||
|
|
|
||
|
|
$service = new GeminiService('test-api-key');
|
||
|
|
$response = $service->generate('System', 'User');
|
||
|
|
|
||
|
|
$this->assertEquals('Success after retry', $response->content);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_generate_retries_on_server_error(): void
|
||
|
|
{
|
||
|
|
Http::fake([
|
||
|
|
self::API_URL.'/*' => Http::sequence()
|
||
|
|
->push(['error' => ['message' => 'Server error']], 500)
|
||
|
|
->push([
|
||
|
|
'candidates' => [
|
||
|
|
['content' => ['parts' => [['text' => 'Success after retry']]]],
|
||
|
|
],
|
||
|
|
'usageMetadata' => ['promptTokenCount' => 10, 'candidatesTokenCount' => 5],
|
||
|
|
], 200),
|
||
|
|
]);
|
||
|
|
|
||
|
|
$service = new GeminiService('test-api-key');
|
||
|
|
$response = $service->generate('System', 'User');
|
||
|
|
|
||
|
|
$this->assertEquals('Success after retry', $response->content);
|
||
|
|
}
|
||
|
|
|
||
|
|
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');
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// =========================================================================
|
||
|
|
// Response Handling Edge Cases
|
||
|
|
// =========================================================================
|
||
|
|
|
||
|
|
public function test_handles_empty_candidates(): void
|
||
|
|
{
|
||
|
|
Http::fake([
|
||
|
|
self::API_URL.'/*' => Http::response([
|
||
|
|
'candidates' => [],
|
||
|
|
'usageMetadata' => ['promptTokenCount' => 10, 'candidatesTokenCount' => 0],
|
||
|
|
], 200),
|
||
|
|
]);
|
||
|
|
|
||
|
|
$service = new GeminiService('test-api-key');
|
||
|
|
$response = $service->generate('System', 'User');
|
||
|
|
|
||
|
|
$this->assertEquals('', $response->content);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_handles_missing_usage_metadata(): void
|
||
|
|
{
|
||
|
|
Http::fake([
|
||
|
|
self::API_URL.'/*' => Http::response([
|
||
|
|
'candidates' => [
|
||
|
|
['content' => ['parts' => [['text' => 'Response']]]],
|
||
|
|
],
|
||
|
|
], 200),
|
||
|
|
]);
|
||
|
|
|
||
|
|
$service = new GeminiService('test-api-key');
|
||
|
|
$response = $service->generate('System', 'User');
|
||
|
|
|
||
|
|
$this->assertEquals(0, $response->inputTokens);
|
||
|
|
$this->assertEquals(0, $response->outputTokens);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_handles_missing_finish_reason(): void
|
||
|
|
{
|
||
|
|
Http::fake([
|
||
|
|
self::API_URL.'/*' => Http::response([
|
||
|
|
'candidates' => [
|
||
|
|
['content' => ['parts' => [['text' => 'Response']]]],
|
||
|
|
],
|
||
|
|
'usageMetadata' => ['promptTokenCount' => 10, 'candidatesTokenCount' => 5],
|
||
|
|
], 200),
|
||
|
|
]);
|
||
|
|
|
||
|
|
$service = new GeminiService('test-api-key');
|
||
|
|
$response = $service->generate('System', 'User');
|
||
|
|
|
||
|
|
$this->assertNull($response->stopReason);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_handles_empty_parts(): void
|
||
|
|
{
|
||
|
|
Http::fake([
|
||
|
|
self::API_URL.'/*' => Http::response([
|
||
|
|
'candidates' => [
|
||
|
|
['content' => ['parts' => []]],
|
||
|
|
],
|
||
|
|
'usageMetadata' => ['promptTokenCount' => 10, 'candidatesTokenCount' => 0],
|
||
|
|
], 200),
|
||
|
|
]);
|
||
|
|
|
||
|
|
$service = new GeminiService('test-api-key');
|
||
|
|
$response = $service->generate('System', 'User');
|
||
|
|
|
||
|
|
$this->assertEquals('', $response->content);
|
||
|
|
}
|
||
|
|
}
|