Skip to content
This repository was archived by the owner on Jul 16, 2025. It is now read-only.

Commit 9d7c0e7

Browse files
committed
feat: Add comprehensive unit tests for Albert API integration
- Add unit tests for EmbeddingsModelClient and GPTModelClient - Update PlatformFactory to support flexible API versions (v1, v2, etc.) - Replace webmozart/assert with plain PHP exception handling - Use JsonMockResponse in tests for better consistency - Fix URL handling to ensure proper slash between version and endpoint - Apply rector and code style fixes
1 parent 97084ab commit 9d7c0e7

File tree

6 files changed

+470
-23
lines changed

6 files changed

+470
-23
lines changed

src/Platform/Bridge/Albert/EmbeddingsModelClient.php

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@
55
namespace PhpLlm\LlmChain\Platform\Bridge\Albert;
66

77
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Embeddings;
8+
use PhpLlm\LlmChain\Platform\Exception\InvalidArgumentException;
89
use PhpLlm\LlmChain\Platform\Model;
910
use PhpLlm\LlmChain\Platform\ModelClientInterface;
1011
use Symfony\Contracts\HttpClient\HttpClientInterface;
1112
use Symfony\Contracts\HttpClient\ResponseInterface;
12-
use Webmozart\Assert\Assert;
1313

1414
final readonly class EmbeddingsModelClient implements ModelClientInterface
1515
{
@@ -19,8 +19,12 @@ public function __construct(
1919
private string $apiKey,
2020
private string $baseUrl,
2121
) {
22-
Assert::stringNotEmpty($apiKey, 'The API key must not be empty.');
23-
Assert::stringNotEmpty($baseUrl, 'The base URL must not be empty.');
22+
if ('' === $apiKey) {
23+
throw new InvalidArgumentException('The API key must not be empty.');
24+
}
25+
if ('' === $baseUrl) {
26+
throw new InvalidArgumentException('The base URL must not be empty.');
27+
}
2428
}
2529

2630
public function supports(Model $model): bool
@@ -30,9 +34,9 @@ public function supports(Model $model): bool
3034

3135
public function request(Model $model, array|string $payload, array $options = []): ResponseInterface
3236
{
33-
return $this->httpClient->request('POST', $this->baseUrl.'embeddings', [
37+
return $this->httpClient->request('POST', rtrim($this->baseUrl, '/').'/embeddings', [
3438
'auth_bearer' => $this->apiKey,
35-
'json' => array_merge($options, $payload),
39+
'json' => \is_array($payload) ? array_merge($payload, $options) : $payload,
3640
]);
3741
}
3842
}

src/Platform/Bridge/Albert/GPTModelClient.php

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
namespace PhpLlm\LlmChain\Platform\Bridge\Albert;
66

77
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\GPT;
8+
use PhpLlm\LlmChain\Platform\Exception\InvalidArgumentException;
89
use PhpLlm\LlmChain\Platform\Model;
910
use PhpLlm\LlmChain\Platform\ModelClientInterface;
1011
use Symfony\Component\HttpClient\EventSourceHttpClient;
1112
use Symfony\Contracts\HttpClient\HttpClientInterface;
1213
use Symfony\Contracts\HttpClient\ResponseInterface;
13-
use Webmozart\Assert\Assert;
1414

1515
final readonly class GPTModelClient implements ModelClientInterface
1616
{
@@ -23,8 +23,12 @@ public function __construct(
2323
private string $baseUrl,
2424
) {
2525
$this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient);
26-
Assert::stringNotEmpty($apiKey, 'The API key must not be empty.');
27-
Assert::stringNotEmpty($baseUrl, 'The base URL must not be empty.');
26+
if ('' === $apiKey) {
27+
throw new InvalidArgumentException('The API key must not be empty.');
28+
}
29+
if ('' === $baseUrl) {
30+
throw new InvalidArgumentException('The base URL must not be empty.');
31+
}
2832
}
2933

3034
public function supports(Model $model): bool
@@ -34,9 +38,9 @@ public function supports(Model $model): bool
3438

3539
public function request(Model $model, array|string $payload, array $options = []): ResponseInterface
3640
{
37-
return $this->httpClient->request('POST', $this->baseUrl.'chat/completions', [
41+
return $this->httpClient->request('POST', rtrim($this->baseUrl, '/').'/chat/completions', [
3842
'auth_bearer' => $this->apiKey,
39-
'json' => array_merge($options, $payload),
43+
'json' => \is_array($payload) ? array_merge($payload, $options) : $payload,
4044
]);
4145
}
4246
}

src/Platform/Bridge/Albert/PlatformFactory.php

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Embeddings\ResponseConverter as EmbeddingsResponseConverter;
88
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\GPT\ResponseConverter as GPTResponseConverter;
99
use PhpLlm\LlmChain\Platform\Contract;
10+
use PhpLlm\LlmChain\Platform\Exception\InvalidArgumentException;
1011
use PhpLlm\LlmChain\Platform\Platform;
1112
use Symfony\Component\HttpClient\EventSourceHttpClient;
1213
use Symfony\Contracts\HttpClient\HttpClientInterface;
13-
use Webmozart\Assert\Assert;
1414

1515
final class PlatformFactory
1616
{
@@ -19,17 +19,20 @@ public static function create(
1919
string $albertUrl,
2020
?HttpClientInterface $httpClient = null,
2121
): Platform {
22-
Assert::startsWith($albertUrl, 'https://', 'The Albert URL must start with "https://".');
22+
if (!str_starts_with($albertUrl, 'https://')) {
23+
throw new InvalidArgumentException('The Albert URL must start with "https://".');
24+
}
2325

2426
$httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient);
2527

2628
// The base URL should include the full path to the API endpoint
27-
// Albert API expects the URL to end with /v1/
29+
// Check if the URL already contains a version pattern (e.g., /v1, /v2, etc.)
2830
$baseUrl = rtrim($albertUrl, '/');
29-
if (!str_ends_with($baseUrl, '/v1')) {
31+
if (!preg_match('/\/v\d+$/', $baseUrl)) {
32+
// Default to v1 if no version is specified
3033
$baseUrl .= '/v1';
3134
}
32-
$baseUrl .= '/';
35+
// Don't add trailing slash here - let the model clients handle it
3336

3437
// Create Albert-specific model clients with custom base URL
3538
$gptClient = new GPTModelClient($httpClient, $apiKey, $baseUrl);
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Tests\Platform\Bridge\Albert;
6+
7+
use PhpLlm\LlmChain\Platform\Bridge\Albert\EmbeddingsModelClient;
8+
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Embeddings;
9+
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\GPT;
10+
use PhpLlm\LlmChain\Platform\Exception\InvalidArgumentException;
11+
use PHPUnit\Framework\Attributes\CoversClass;
12+
use PHPUnit\Framework\Attributes\DataProvider;
13+
use PHPUnit\Framework\Attributes\Small;
14+
use PHPUnit\Framework\Attributes\Test;
15+
use PHPUnit\Framework\TestCase;
16+
use Symfony\Component\HttpClient\MockHttpClient;
17+
use Symfony\Component\HttpClient\Response\JsonMockResponse;
18+
19+
#[CoversClass(EmbeddingsModelClient::class)]
20+
#[Small]
21+
final class EmbeddingsModelClientTest extends TestCase
22+
{
23+
#[Test]
24+
public function constructorThrowsExceptionForEmptyApiKey(): void
25+
{
26+
$this->expectException(InvalidArgumentException::class);
27+
$this->expectExceptionMessage('The API key must not be empty.');
28+
29+
new EmbeddingsModelClient(
30+
new MockHttpClient(),
31+
'',
32+
'https://albert.example.com/'
33+
);
34+
}
35+
36+
#[Test]
37+
public function constructorThrowsExceptionForEmptyBaseUrl(): void
38+
{
39+
$this->expectException(InvalidArgumentException::class);
40+
$this->expectExceptionMessage('The base URL must not be empty.');
41+
42+
new EmbeddingsModelClient(
43+
new MockHttpClient(),
44+
'test-api-key',
45+
''
46+
);
47+
}
48+
49+
#[Test]
50+
public function supportsEmbeddingsModel(): void
51+
{
52+
$client = new EmbeddingsModelClient(
53+
new MockHttpClient(),
54+
'test-api-key',
55+
'https://albert.example.com/'
56+
);
57+
58+
$embeddingsModel = new Embeddings('text-embedding-ada-002');
59+
self::assertTrue($client->supports($embeddingsModel));
60+
}
61+
62+
#[Test]
63+
public function doesNotSupportNonEmbeddingsModel(): void
64+
{
65+
$client = new EmbeddingsModelClient(
66+
new MockHttpClient(),
67+
'test-api-key',
68+
'https://albert.example.com/'
69+
);
70+
71+
$gptModel = new GPT('gpt-3.5-turbo');
72+
self::assertFalse($client->supports($gptModel));
73+
}
74+
75+
#[Test]
76+
#[DataProvider('requestDataProvider')]
77+
public function requestSendsCorrectHttpRequest(array|string $payload, array $options, array|string $expectedJson): void
78+
{
79+
$capturedRequest = null;
80+
$httpClient = new MockHttpClient(function ($method, $url, $options) use (&$capturedRequest) {
81+
$capturedRequest = ['method' => $method, 'url' => $url, 'options' => $options];
82+
83+
return new JsonMockResponse(['data' => []]);
84+
});
85+
86+
$client = new EmbeddingsModelClient(
87+
$httpClient,
88+
'test-api-key',
89+
'https://albert.example.com/v1/'
90+
);
91+
92+
$model = new Embeddings('text-embedding-ada-002');
93+
$response = $client->request($model, $payload, $options);
94+
95+
self::assertNotNull($capturedRequest);
96+
self::assertSame('POST', $capturedRequest['method']);
97+
self::assertSame('https://albert.example.com/v1/embeddings', $capturedRequest['url']);
98+
self::assertArrayHasKey('normalized_headers', $capturedRequest['options']);
99+
self::assertArrayHasKey('authorization', $capturedRequest['options']['normalized_headers']);
100+
self::assertStringContainsString('Bearer test-api-key', $capturedRequest['options']['normalized_headers']['authorization'][0]);
101+
102+
// Check JSON body - it might be in 'body' after processing
103+
if (isset($capturedRequest['options']['body'])) {
104+
$actualJson = json_decode($capturedRequest['options']['body'], true);
105+
self::assertEquals($expectedJson, $actualJson);
106+
} else {
107+
self::assertSame($expectedJson, $capturedRequest['options']['json']);
108+
}
109+
}
110+
111+
public static function requestDataProvider(): \Iterator
112+
{
113+
yield 'with array payload and no options' => [
114+
'payload' => ['input' => 'test text', 'model' => 'text-embedding-ada-002'],
115+
'options' => [],
116+
'expectedJson' => ['input' => 'test text', 'model' => 'text-embedding-ada-002'],
117+
];
118+
119+
yield 'with string payload and no options' => [
120+
'payload' => 'test text',
121+
'options' => [],
122+
'expectedJson' => 'test text',
123+
];
124+
125+
yield 'with array payload and options' => [
126+
'payload' => ['input' => 'test text', 'model' => 'text-embedding-ada-002'],
127+
'options' => ['dimensions' => 1536],
128+
'expectedJson' => ['dimensions' => 1536, 'input' => 'test text', 'model' => 'text-embedding-ada-002'],
129+
];
130+
131+
yield 'options override payload values' => [
132+
'payload' => ['input' => 'test text', 'model' => 'text-embedding-ada-002'],
133+
'options' => ['model' => 'text-embedding-3-small'],
134+
'expectedJson' => ['model' => 'text-embedding-3-small', 'input' => 'test text'],
135+
];
136+
}
137+
138+
#[Test]
139+
public function requestHandlesBaseUrlWithoutTrailingSlash(): void
140+
{
141+
$capturedUrl = null;
142+
$httpClient = new MockHttpClient(function ($method, $url) use (&$capturedUrl) {
143+
$capturedUrl = $url;
144+
145+
return new JsonMockResponse(['data' => []]);
146+
});
147+
148+
$client = new EmbeddingsModelClient(
149+
$httpClient,
150+
'test-api-key',
151+
'https://albert.example.com/v1'
152+
);
153+
154+
$model = new Embeddings('text-embedding-ada-002');
155+
$client->request($model, ['input' => 'test']);
156+
157+
self::assertSame('https://albert.example.com/v1/embeddings', $capturedUrl);
158+
}
159+
160+
#[Test]
161+
public function requestHandlesBaseUrlWithTrailingSlash(): void
162+
{
163+
$capturedUrl = null;
164+
$httpClient = new MockHttpClient(function ($method, $url) use (&$capturedUrl) {
165+
$capturedUrl = $url;
166+
167+
return new JsonMockResponse(['data' => []]);
168+
});
169+
170+
$client = new EmbeddingsModelClient(
171+
$httpClient,
172+
'test-api-key',
173+
'https://albert.example.com/v1/'
174+
);
175+
176+
$model = new Embeddings('text-embedding-ada-002');
177+
$client->request($model, ['input' => 'test']);
178+
179+
self::assertSame('https://albert.example.com/v1/embeddings', $capturedUrl);
180+
}
181+
}

0 commit comments

Comments
 (0)