From c8309480ba994a3e76e65ce532a943944049b9fe Mon Sep 17 00:00:00 2001 From: Jordan Partridge Date: Mon, 6 Apr 2026 13:05:50 -0700 Subject: [PATCH 1/3] feat: add EmbeddingClient contract with Ollama and OpenAI providers Introduces a provider-agnostic embedding layer so consumers don't need to roll their own HTTP clients for generating vector embeddings. - EmbeddingClient contract: embed() and embedBatch() - OllamaEmbeddings: Saloon connector calling POST /api/embed - OpenAiEmbeddings: Saloon connector calling POST /v1/embeddings with Bearer auth (works with any OpenAI-compatible API) - NullEmbeddings: stub for testing / disabled embedding - Config-driven provider selection via config/vector.php - VectorServiceProvider binds the correct implementation Closes #24 --- config/vector.php | 18 + src/Contracts/EmbeddingClient.php | 23 ++ src/Embeddings/NullEmbeddings.php | 27 ++ src/Embeddings/OllamaConnector.php | 37 ++ src/Embeddings/OllamaEmbeddings.php | 61 ++++ src/Embeddings/OpenAiConnector.php | 44 +++ src/Embeddings/OpenAiEmbeddings.php | 62 ++++ .../Requests/OllamaEmbedRequest.php | 41 +++ .../Requests/OpenAiEmbedRequest.php | 48 +++ src/VectorServiceProvider.php | 29 ++ .../Feature/EmbeddingServiceProviderTest.php | 56 +++ tests/Unit/EmbeddingsTest.php | 341 ++++++++++++++++++ 12 files changed, 787 insertions(+) create mode 100644 src/Contracts/EmbeddingClient.php create mode 100644 src/Embeddings/NullEmbeddings.php create mode 100644 src/Embeddings/OllamaConnector.php create mode 100644 src/Embeddings/OllamaEmbeddings.php create mode 100644 src/Embeddings/OpenAiConnector.php create mode 100644 src/Embeddings/OpenAiEmbeddings.php create mode 100644 src/Embeddings/Requests/OllamaEmbedRequest.php create mode 100644 src/Embeddings/Requests/OpenAiEmbedRequest.php create mode 100644 tests/Feature/EmbeddingServiceProviderTest.php create mode 100644 tests/Unit/EmbeddingsTest.php diff --git a/config/vector.php b/config/vector.php index 247011a..4f77131 100644 --- a/config/vector.php +++ b/config/vector.php @@ -13,4 +13,22 @@ 'request' => (int) env('QDRANT_REQUEST_TIMEOUT', 30), ], + /* + |-------------------------------------------------------------------------- + | Embedding Configuration + |-------------------------------------------------------------------------- + | + | Provider: ollama, openai, none + | Model: provider-specific model name (e.g. bge-large, text-embedding-3-large) + | + */ + + 'embeddings' => [ + 'provider' => env('EMBEDDING_PROVIDER', 'ollama'), + 'model' => env('EMBEDDING_MODEL', 'bge-large'), + 'url' => env('EMBEDDING_URL'), + 'api_key' => env('EMBEDDING_API_KEY'), + 'dimensions' => env('EMBEDDING_DIMENSIONS') ? (int) env('EMBEDDING_DIMENSIONS') : null, + ], + ]; diff --git a/src/Contracts/EmbeddingClient.php b/src/Contracts/EmbeddingClient.php new file mode 100644 index 0000000..9761f78 --- /dev/null +++ b/src/Contracts/EmbeddingClient.php @@ -0,0 +1,23 @@ + + */ + public function embed(string $text): array; + + /** + * Generate embedding vectors for multiple texts. + * + * @param array $texts + * @return array> + */ + public function embedBatch(array $texts): array; +} diff --git a/src/Embeddings/NullEmbeddings.php b/src/Embeddings/NullEmbeddings.php new file mode 100644 index 0000000..b7cc349 --- /dev/null +++ b/src/Embeddings/NullEmbeddings.php @@ -0,0 +1,27 @@ + + */ + public function embed(string $text): array + { + return []; + } + + /** + * @param array $texts + * @return array> + */ + public function embedBatch(array $texts): array + { + return []; + } +} diff --git a/src/Embeddings/OllamaConnector.php b/src/Embeddings/OllamaConnector.php new file mode 100644 index 0000000..ee8db14 --- /dev/null +++ b/src/Embeddings/OllamaConnector.php @@ -0,0 +1,37 @@ +baseUrl, '/'); + } + + /** + * @return array + */ + protected function defaultHeaders(): array + { + return [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ]; + } +} diff --git a/src/Embeddings/OllamaEmbeddings.php b/src/Embeddings/OllamaEmbeddings.php new file mode 100644 index 0000000..4df7a68 --- /dev/null +++ b/src/Embeddings/OllamaEmbeddings.php @@ -0,0 +1,61 @@ + + */ + public function embed(string $text): array + { + if (trim($text) === '') { + return []; + } + + $result = $this->embedBatch([$text]); + + return $result[0] ?? []; + } + + /** + * @param array $texts + * @return array> + */ + public function embedBatch(array $texts): array + { + $texts = array_values(array_filter($texts, fn (string $t): bool => trim($t) !== '')); + + if ($texts === []) { + return []; + } + + try { + $response = $this->connector->send(new OllamaEmbedRequest($this->model, $texts)); + $response->throw(); + } catch (RequestException) { + return array_fill(0, count($texts), []); + } + + /** @var array> $embeddings */ + $embeddings = $response->json('embeddings') ?? []; + + return array_map( + fn (mixed $embedding): array => is_array($embedding) + ? array_map(fn (mixed $v): float => (float) $v, $embedding) + : [], + $embeddings, + ); + } +} diff --git a/src/Embeddings/OpenAiConnector.php b/src/Embeddings/OpenAiConnector.php new file mode 100644 index 0000000..3372fa0 --- /dev/null +++ b/src/Embeddings/OpenAiConnector.php @@ -0,0 +1,44 @@ +baseUrl, '/'); + } + + /** + * @return array + */ + protected function defaultHeaders(): array + { + return [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ]; + } + + protected function defaultAuth(): TokenAuthenticator + { + return new TokenAuthenticator($this->apiKey); + } +} diff --git a/src/Embeddings/OpenAiEmbeddings.php b/src/Embeddings/OpenAiEmbeddings.php new file mode 100644 index 0000000..04881fc --- /dev/null +++ b/src/Embeddings/OpenAiEmbeddings.php @@ -0,0 +1,62 @@ + + */ + public function embed(string $text): array + { + if (trim($text) === '') { + return []; + } + + $result = $this->embedBatch([$text]); + + return $result[0] ?? []; + } + + /** + * @param array $texts + * @return array> + */ + public function embedBatch(array $texts): array + { + $texts = array_values(array_filter($texts, fn (string $t): bool => trim($t) !== '')); + + if ($texts === []) { + return []; + } + + try { + $response = $this->connector->send(new OpenAiEmbedRequest($this->model, $texts, $this->dimensions)); + $response->throw(); + } catch (RequestException) { + return array_fill(0, count($texts), []); + } + + /** @var array}> $data */ + $data = $response->json('data') ?? []; + + return array_map( + fn (mixed $item): array => is_array($item) && isset($item['embedding']) && is_array($item['embedding']) + ? array_map(fn (mixed $v): float => (float) $v, $item['embedding']) + : [], + $data, + ); + } +} diff --git a/src/Embeddings/Requests/OllamaEmbedRequest.php b/src/Embeddings/Requests/OllamaEmbedRequest.php new file mode 100644 index 0000000..6d22762 --- /dev/null +++ b/src/Embeddings/Requests/OllamaEmbedRequest.php @@ -0,0 +1,41 @@ + $texts + */ + public function __construct( + protected readonly string $model, + protected readonly array $texts, + ) {} + + public function resolveEndpoint(): string + { + return '/api/embed'; + } + + /** + * @return array + */ + protected function defaultBody(): array + { + return [ + 'model' => $this->model, + 'input' => $this->texts, + ]; + } +} diff --git a/src/Embeddings/Requests/OpenAiEmbedRequest.php b/src/Embeddings/Requests/OpenAiEmbedRequest.php new file mode 100644 index 0000000..e0697c3 --- /dev/null +++ b/src/Embeddings/Requests/OpenAiEmbedRequest.php @@ -0,0 +1,48 @@ + $texts + */ + public function __construct( + protected readonly string $model, + protected readonly array $texts, + protected readonly ?int $dimensions = null, + ) {} + + public function resolveEndpoint(): string + { + return '/v1/embeddings'; + } + + /** + * @return array + */ + protected function defaultBody(): array + { + $body = [ + 'model' => $this->model, + 'input' => $this->texts, + ]; + + if ($this->dimensions !== null) { + $body['dimensions'] = $this->dimensions; + } + + return $body; + } +} diff --git a/src/VectorServiceProvider.php b/src/VectorServiceProvider.php index 146901c..bf1e13e 100644 --- a/src/VectorServiceProvider.php +++ b/src/VectorServiceProvider.php @@ -5,7 +5,13 @@ namespace TheShit\Vector; use Illuminate\Support\ServiceProvider; +use TheShit\Vector\Contracts\EmbeddingClient; use TheShit\Vector\Contracts\VectorClient; +use TheShit\Vector\Embeddings\NullEmbeddings; +use TheShit\Vector\Embeddings\OllamaConnector; +use TheShit\Vector\Embeddings\OllamaEmbeddings; +use TheShit\Vector\Embeddings\OpenAiConnector; +use TheShit\Vector\Embeddings\OpenAiEmbeddings; class VectorServiceProvider extends ServiceProvider { @@ -25,6 +31,29 @@ public function register(): void }); $this->app->alias(Qdrant::class, VectorClient::class); + + $this->app->singleton(EmbeddingClient::class, function (): EmbeddingClient { + $provider = config('vector.embeddings.provider', 'ollama'); + $model = config('vector.embeddings.model', 'bge-large'); + + return match ($provider) { + 'ollama' => new OllamaEmbeddings( + new OllamaConnector( + config('vector.embeddings.url', 'http://localhost:11434'), + ), + $model, + ), + 'openai' => new OpenAiEmbeddings( + new OpenAiConnector( + config('vector.embeddings.url', 'https://api.openai.com'), + config('vector.embeddings.api_key', ''), + ), + $model, + config('vector.embeddings.dimensions') ? (int) config('vector.embeddings.dimensions') : null, + ), + default => new NullEmbeddings, + }; + }); } public function boot(): void diff --git a/tests/Feature/EmbeddingServiceProviderTest.php b/tests/Feature/EmbeddingServiceProviderTest.php new file mode 100644 index 0000000..5738af6 --- /dev/null +++ b/tests/Feature/EmbeddingServiceProviderTest.php @@ -0,0 +1,56 @@ + 'ollama', + 'vector.embeddings.url' => 'http://localhost:11434', + ]); + app()->forgetInstance(EmbeddingClient::class); + + $client = app(EmbeddingClient::class); + + expect($client)->toBeInstanceOf(OllamaEmbeddings::class); + }); + + it('resolves OpenAiEmbeddings when provider is openai', function (): void { + config([ + 'vector.embeddings.provider' => 'openai', + 'vector.embeddings.url' => 'https://api.openai.com', + 'vector.embeddings.api_key' => 'sk-test', + ]); + app()->forgetInstance(EmbeddingClient::class); + + $client = app(EmbeddingClient::class); + + expect($client)->toBeInstanceOf(OpenAiEmbeddings::class); + }); + + it('resolves NullEmbeddings when provider is none', function (): void { + config(['vector.embeddings.provider' => 'none']); + app()->forgetInstance(EmbeddingClient::class); + + $client = app(EmbeddingClient::class); + + expect($client)->toBeInstanceOf(NullEmbeddings::class); + }); + + it('resolves NullEmbeddings for unknown provider', function (): void { + config(['vector.embeddings.provider' => 'unknown-provider']); + app()->forgetInstance(EmbeddingClient::class); + + $client = app(EmbeddingClient::class); + + expect($client)->toBeInstanceOf(NullEmbeddings::class); + }); +}); diff --git a/tests/Unit/EmbeddingsTest.php b/tests/Unit/EmbeddingsTest.php new file mode 100644 index 0000000..ffe0e84 --- /dev/null +++ b/tests/Unit/EmbeddingsTest.php @@ -0,0 +1,341 @@ +resolveBaseUrl())->toBe('http://localhost:11434'); + }); + + it('trims trailing slash', function (): void { + $connector = new OllamaConnector('http://localhost:11434/'); + + expect($connector->resolveBaseUrl())->toBe('http://localhost:11434'); + }); +}); + +describe('OpenAiConnector', function (): void { + it('resolves base url', function (): void { + $connector = new OpenAiConnector('https://api.openai.com', 'sk-test'); + + expect($connector->resolveBaseUrl())->toBe('https://api.openai.com'); + }); + + it('sets bearer auth from api key', function (): void { + $connector = new OpenAiConnector('https://api.openai.com', 'sk-test-key'); + $auth = invade($connector)->defaultAuth(); + + expect($auth)->toBeInstanceOf(TokenAuthenticator::class); + }); +}); + +describe('OllamaEmbedRequest', function (): void { + it('resolves endpoint', function (): void { + $request = new OllamaEmbedRequest('bge-large', ['hello']); + + expect($request->resolveEndpoint())->toBe('/api/embed'); + }); + + it('includes model and input in body', function (): void { + $request = new OllamaEmbedRequest('bge-large', ['hello', 'world']); + $body = invade($request)->defaultBody(); + + expect($body)->toBe([ + 'model' => 'bge-large', + 'input' => ['hello', 'world'], + ]); + }); +}); + +describe('OpenAiEmbedRequest', function (): void { + it('resolves endpoint', function (): void { + $request = new OpenAiEmbedRequest('text-embedding-3-large', ['hello']); + + expect($request->resolveEndpoint())->toBe('/v1/embeddings'); + }); + + it('includes model and input in body', function (): void { + $request = new OpenAiEmbedRequest('text-embedding-3-large', ['hello']); + $body = invade($request)->defaultBody(); + + expect($body)->toBe([ + 'model' => 'text-embedding-3-large', + 'input' => ['hello'], + ]); + }); + + it('includes dimensions when specified', function (): void { + $request = new OpenAiEmbedRequest('text-embedding-3-large', ['hello'], 1024); + $body = invade($request)->defaultBody(); + + expect($body)->toBe([ + 'model' => 'text-embedding-3-large', + 'input' => ['hello'], + 'dimensions' => 1024, + ]); + }); + + it('omits dimensions when null', function (): void { + $request = new OpenAiEmbedRequest('text-embedding-3-large', ['hello']); + $body = invade($request)->defaultBody(); + + expect($body)->not->toHaveKey('dimensions'); + }); +}); + +describe('OllamaEmbeddings', function (): void { + it('embeds single text', function (): void { + $connector = new OllamaConnector('http://localhost:11434'); + $connector->withMockClient(new MockClient([ + OllamaEmbedRequest::class => MockResponse::make([ + 'embeddings' => [[0.1, 0.2, 0.3]], + ]), + ])); + + $client = new OllamaEmbeddings($connector, 'bge-large'); + $result = $client->embed('hello world'); + + expect($result)->toBe([0.1, 0.2, 0.3]); + }); + + it('returns empty array for empty text', function (): void { + $connector = new OllamaConnector('http://localhost:11434'); + $client = new OllamaEmbeddings($connector); + + expect($client->embed(''))->toBe([]) + ->and($client->embed(' '))->toBe([]); + }); + + it('embeds batch of texts', function (): void { + $connector = new OllamaConnector('http://localhost:11434'); + $connector->withMockClient(new MockClient([ + OllamaEmbedRequest::class => MockResponse::make([ + 'embeddings' => [[0.1, 0.2], [0.3, 0.4]], + ]), + ])); + + $client = new OllamaEmbeddings($connector, 'bge-large'); + $result = $client->embedBatch(['hello', 'world']); + + expect($result)->toBe([[0.1, 0.2], [0.3, 0.4]]); + }); + + it('filters empty strings from batch', function (): void { + $connector = new OllamaConnector('http://localhost:11434'); + $connector->withMockClient(new MockClient([ + OllamaEmbedRequest::class => MockResponse::make([ + 'embeddings' => [[0.1, 0.2]], + ]), + ])); + + $client = new OllamaEmbeddings($connector, 'bge-large'); + $result = $client->embedBatch(['hello', '', ' ']); + + expect($result)->toBe([[0.1, 0.2]]); + }); + + it('returns empty arrays on batch with only empty strings', function (): void { + $connector = new OllamaConnector('http://localhost:11434'); + $client = new OllamaEmbeddings($connector); + + expect($client->embedBatch(['', ' ']))->toBe([]); + }); + + it('returns empty arrays on request failure', function (): void { + $connector = new OllamaConnector('http://localhost:11434'); + $connector->withMockClient(new MockClient([ + OllamaEmbedRequest::class => MockResponse::make([], 500), + ])); + + $client = new OllamaEmbeddings($connector, 'bge-large'); + $result = $client->embed('hello'); + + expect($result)->toBe([]); + }); + + it('handles malformed embedding response', function (): void { + $connector = new OllamaConnector('http://localhost:11434'); + $connector->withMockClient(new MockClient([ + OllamaEmbedRequest::class => MockResponse::make([ + 'embeddings' => ['not-an-array'], + ]), + ])); + + $client = new OllamaEmbeddings($connector, 'bge-large'); + $result = $client->embed('hello'); + + expect($result)->toBe([]); + }); + + it('handles missing embeddings key', function (): void { + $connector = new OllamaConnector('http://localhost:11434'); + $connector->withMockClient(new MockClient([ + OllamaEmbedRequest::class => MockResponse::make(['model' => 'bge-large']), + ])); + + $client = new OllamaEmbeddings($connector, 'bge-large'); + $result = $client->embed('hello'); + + expect($result)->toBe([]); + }); + + it('implements EmbeddingClient contract', function (): void { + $connector = new OllamaConnector('http://localhost:11434'); + $client = new OllamaEmbeddings($connector); + + expect($client)->toBeInstanceOf(EmbeddingClient::class); + }); +}); + +describe('OpenAiEmbeddings', function (): void { + it('embeds single text', function (): void { + $connector = new OpenAiConnector('https://api.openai.com', 'sk-test'); + $connector->withMockClient(new MockClient([ + OpenAiEmbedRequest::class => MockResponse::make([ + 'data' => [['embedding' => [0.5, 0.6, 0.7]]], + ]), + ])); + + $client = new OpenAiEmbeddings($connector, 'text-embedding-3-large'); + $result = $client->embed('hello world'); + + expect($result)->toBe([0.5, 0.6, 0.7]); + }); + + it('returns empty array for empty text', function (): void { + $connector = new OpenAiConnector('https://api.openai.com', 'sk-test'); + $client = new OpenAiEmbeddings($connector); + + expect($client->embed(''))->toBe([]) + ->and($client->embed(' '))->toBe([]); + }); + + it('embeds batch of texts', function (): void { + $connector = new OpenAiConnector('https://api.openai.com', 'sk-test'); + $connector->withMockClient(new MockClient([ + OpenAiEmbedRequest::class => MockResponse::make([ + 'data' => [ + ['embedding' => [0.1, 0.2]], + ['embedding' => [0.3, 0.4]], + ], + ]), + ])); + + $client = new OpenAiEmbeddings($connector, 'text-embedding-3-large'); + $result = $client->embedBatch(['hello', 'world']); + + expect($result)->toBe([[0.1, 0.2], [0.3, 0.4]]); + }); + + it('filters empty strings from batch', function (): void { + $connector = new OpenAiConnector('https://api.openai.com', 'sk-test'); + $connector->withMockClient(new MockClient([ + OpenAiEmbedRequest::class => MockResponse::make([ + 'data' => [['embedding' => [0.1, 0.2]]], + ]), + ])); + + $client = new OpenAiEmbeddings($connector, 'text-embedding-3-large'); + $result = $client->embedBatch(['hello', '', ' ']); + + expect($result)->toBe([[0.1, 0.2]]); + }); + + it('returns empty on batch with only empty strings', function (): void { + $connector = new OpenAiConnector('https://api.openai.com', 'sk-test'); + $client = new OpenAiEmbeddings($connector); + + expect($client->embedBatch(['', ' ']))->toBe([]); + }); + + it('returns empty arrays on request failure', function (): void { + $connector = new OpenAiConnector('https://api.openai.com', 'sk-test'); + $connector->withMockClient(new MockClient([ + OpenAiEmbedRequest::class => MockResponse::make([], 500), + ])); + + $client = new OpenAiEmbeddings($connector, 'text-embedding-3-large'); + $result = $client->embed('hello'); + + expect($result)->toBe([]); + }); + + it('handles malformed data response', function (): void { + $connector = new OpenAiConnector('https://api.openai.com', 'sk-test'); + $connector->withMockClient(new MockClient([ + OpenAiEmbedRequest::class => MockResponse::make([ + 'data' => [['no_embedding_key' => true]], + ]), + ])); + + $client = new OpenAiEmbeddings($connector, 'text-embedding-3-large'); + $result = $client->embed('hello'); + + expect($result)->toBe([]); + }); + + it('handles missing data key', function (): void { + $connector = new OpenAiConnector('https://api.openai.com', 'sk-test'); + $connector->withMockClient(new MockClient([ + OpenAiEmbedRequest::class => MockResponse::make(['model' => 'text-embedding-3-large']), + ])); + + $client = new OpenAiEmbeddings($connector, 'text-embedding-3-large'); + $result = $client->embed('hello'); + + expect($result)->toBe([]); + }); + + it('passes dimensions to request', function (): void { + $connector = new OpenAiConnector('https://api.openai.com', 'sk-test'); + $connector->withMockClient(new MockClient([ + OpenAiEmbedRequest::class => MockResponse::make([ + 'data' => [['embedding' => [0.1]]], + ]), + ])); + + $client = new OpenAiEmbeddings($connector, 'text-embedding-3-large', 1024); + $result = $client->embed('hello'); + + expect($result)->toBe([0.1]); + }); + + it('implements EmbeddingClient contract', function (): void { + $connector = new OpenAiConnector('https://api.openai.com', 'sk-test'); + $client = new OpenAiEmbeddings($connector); + + expect($client)->toBeInstanceOf(EmbeddingClient::class); + }); +}); + +describe('NullEmbeddings', function (): void { + it('returns empty array for embed', function (): void { + $client = new NullEmbeddings; + + expect($client->embed('hello'))->toBe([]); + }); + + it('returns empty array for embedBatch', function (): void { + $client = new NullEmbeddings; + + expect($client->embedBatch(['hello', 'world']))->toBe([]); + }); + + it('implements EmbeddingClient contract', function (): void { + expect(new NullEmbeddings)->toBeInstanceOf(EmbeddingClient::class); + }); +}); From 90f0427f3c9b6f76c92aea5bcf70124bc48f3ee4 Mon Sep 17 00:00:00 2001 From: Jordan Partridge Date: Mon, 6 Apr 2026 13:07:50 -0700 Subject: [PATCH 2/3] style: remove redundant float casts (rector) --- src/Embeddings/OllamaEmbeddings.php | 2 +- src/Embeddings/OpenAiEmbeddings.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Embeddings/OllamaEmbeddings.php b/src/Embeddings/OllamaEmbeddings.php index 4df7a68..8d19338 100644 --- a/src/Embeddings/OllamaEmbeddings.php +++ b/src/Embeddings/OllamaEmbeddings.php @@ -53,7 +53,7 @@ public function embedBatch(array $texts): array return array_map( fn (mixed $embedding): array => is_array($embedding) - ? array_map(fn (mixed $v): float => (float) $v, $embedding) + ? array_map(fn (mixed $v): float => $v, $embedding) : [], $embeddings, ); diff --git a/src/Embeddings/OpenAiEmbeddings.php b/src/Embeddings/OpenAiEmbeddings.php index 04881fc..655e089 100644 --- a/src/Embeddings/OpenAiEmbeddings.php +++ b/src/Embeddings/OpenAiEmbeddings.php @@ -54,7 +54,7 @@ public function embedBatch(array $texts): array return array_map( fn (mixed $item): array => is_array($item) && isset($item['embedding']) && is_array($item['embedding']) - ? array_map(fn (mixed $v): float => (float) $v, $item['embedding']) + ? array_map(fn (mixed $v): float => $v, $item['embedding']) : [], $data, ); From ab827074923e194833ba73eb935e7e8d0d1f7df9 Mon Sep 17 00:00:00 2001 From: Jordan Partridge Date: Mon, 6 Apr 2026 13:12:12 -0700 Subject: [PATCH 3/3] test: add listCollections coverage to hit 100% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover Qdrant::listCollections() and ListCollectionsRequest — the only two gaps preventing the --min=100 coverage gate from passing. --- tests/Feature/ListCollectionsTest.php | 65 +++++++++++++++++++++++++++ tests/Unit/RequestTest.php | 9 ++++ 2 files changed, 74 insertions(+) create mode 100644 tests/Feature/ListCollectionsTest.php diff --git a/tests/Feature/ListCollectionsTest.php b/tests/Feature/ListCollectionsTest.php new file mode 100644 index 0000000..231abf2 --- /dev/null +++ b/tests/Feature/ListCollectionsTest.php @@ -0,0 +1,65 @@ +withMockClient($mock); + + return new Qdrant($connector); +} + +describe('Qdrant::listCollections', function (): void { + it('returns collection names', function (): void { + $mock = new MockClient([ + ListCollectionsRequest::class => MockResponse::make([ + 'result' => [ + 'collections' => [ + ['name' => 'knowledge_default'], + ['name' => 'code'], + ], + ], + ]), + ]); + + $result = makeListQdrant($mock)->listCollections(); + + expect($result)->toBe(['knowledge_default', 'code']); + }); + + it('returns empty array when no collections', function (): void { + $mock = new MockClient([ + ListCollectionsRequest::class => MockResponse::make([ + 'result' => [ + 'collections' => [], + ], + ]), + ]); + + $result = makeListQdrant($mock)->listCollections(); + + expect($result)->toBe([]); + }); + + it('handles null collections gracefully', function (): void { + $mock = new MockClient([ + ListCollectionsRequest::class => MockResponse::make([ + 'result' => [], + ]), + ]); + + $result = makeListQdrant($mock)->listCollections(); + + expect($result)->toBe([]); + }); +}); diff --git a/tests/Unit/RequestTest.php b/tests/Unit/RequestTest.php index 3b83757..3e668f7 100644 --- a/tests/Unit/RequestTest.php +++ b/tests/Unit/RequestTest.php @@ -6,6 +6,7 @@ use TheShit\Vector\Requests\Collections\CreateCollectionRequest; use TheShit\Vector\Requests\Collections\DeleteCollectionRequest; use TheShit\Vector\Requests\Collections\GetCollectionRequest; +use TheShit\Vector\Requests\Collections\ListCollectionsRequest; use TheShit\Vector\Requests\Points\CountPointsRequest; use TheShit\Vector\Requests\Points\CreatePayloadIndexRequest; use TheShit\Vector\Requests\Points\DeletePayloadIndexRequest; @@ -470,3 +471,11 @@ ]); }); }); + +describe('ListCollectionsRequest', function (): void { + it('resolves endpoint', function (): void { + $request = new ListCollectionsRequest; + + expect($request->resolveEndpoint())->toBe('/collections'); + }); +});