From 675051bb131a14a488be1b706fe62995c8b64fc9 Mon Sep 17 00:00:00 2001 From: edalzell Date: Wed, 7 Oct 2026 10:32:19 -0700 Subject: [PATCH 1/2] Share the mocked-API test helper via TestCase --- tests/TestCase.php | 22 ++++++++++++++++++++++ tests/Unit/GetOrCreateIndexTest.php | 19 ------------------- tests/Unit/SearchUsingApiTest.php | 13 +------------ 3 files changed, 23 insertions(+), 31 deletions(-) diff --git a/tests/TestCase.php b/tests/TestCase.php index 688f0c6..915d003 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,6 +4,11 @@ use Statamic\Testing\AddonTestCase; use StatamicRadPack\Typesense\ServiceProvider; +use StatamicRadPack\Typesense\Typesense\Index; +use Typesense\ApiCall; +use Typesense\Client; +use Typesense\Collections; +use Typesense\MultiSearch; class TestCase extends AddonTestCase { @@ -59,4 +64,21 @@ protected function resolveApplicationConfiguration($app) ], ]); } + + /** + * Build an Index whose requests go through a mocked HTTP layer, so tests can assert + * on the requests the driver actually makes without a running Typesense server. + */ + protected function indexWithMockedApi(ApiCall $apiCall, string $name = 'test'): Index + { + $client = new Client([ + 'api_key' => 'xyz', + 'nodes' => [['host' => 'localhost', 'port' => '8108', 'path' => '', 'protocol' => 'http']], + ]); + + $client->collections = new Collections($apiCall); + $client->multiSearch = new MultiSearch($apiCall); + + return new Index($client, $name, []); + } } diff --git a/tests/Unit/GetOrCreateIndexTest.php b/tests/Unit/GetOrCreateIndexTest.php index f247aa4..74811bc 100644 --- a/tests/Unit/GetOrCreateIndexTest.php +++ b/tests/Unit/GetOrCreateIndexTest.php @@ -5,30 +5,11 @@ use Mockery; use PHPUnit\Framework\Attributes\Test; use StatamicRadPack\Typesense\Tests\TestCase; -use StatamicRadPack\Typesense\Typesense\Index; use Typesense\ApiCall; -use Typesense\Client; -use Typesense\Collections; use Typesense\Exceptions\ObjectNotFound; class GetOrCreateIndexTest extends TestCase { - /** - * Build an Index whose collection lookups go through a mocked HTTP layer, so we can - * count the requests the driver actually makes. - */ - private function indexWithMockedApi(ApiCall $apiCall, string $name = 'test'): Index - { - $client = new Client([ - 'api_key' => 'xyz', - 'nodes' => [['host' => 'localhost', 'port' => '8108', 'path' => '', 'protocol' => 'http']], - ]); - - $client->collections = new Collections($apiCall); - - return new Index($client, $name, []); - } - #[Test] public function it_only_looks_the_collection_up_once() { diff --git a/tests/Unit/SearchUsingApiTest.php b/tests/Unit/SearchUsingApiTest.php index bdbb4db..31f31d9 100644 --- a/tests/Unit/SearchUsingApiTest.php +++ b/tests/Unit/SearchUsingApiTest.php @@ -7,10 +7,7 @@ use StatamicRadPack\Typesense\Tests\TestCase; use StatamicRadPack\Typesense\Typesense\Index; use Typesense\ApiCall; -use Typesense\Client; -use Typesense\Collections; use Typesense\Exceptions\TypesenseClientError; -use Typesense\MultiSearch; class SearchUsingApiTest extends TestCase { @@ -27,15 +24,7 @@ private function indexWithMultiSearchResult(array $result, string $name = 'test' ->once() ->andReturn(['results' => [$result]]); - $client = new Client([ - 'api_key' => 'xyz', - 'nodes' => [['host' => 'localhost', 'port' => '8108', 'path' => '', 'protocol' => 'http']], - ]); - - $client->collections = new Collections($apiCall); - $client->multiSearch = new MultiSearch($apiCall); - - return new Index($client, $name, []); + return $this->indexWithMockedApi($apiCall, $name); } #[Test] From 64c0e41cffb3d2e8816e657caccd39e37a7d33bd Mon Sep 17 00:00:00 2001 From: edalzell Date: Wed, 7 Oct 2026 10:32:26 -0700 Subject: [PATCH 2/2] Make exists() a read-only check --- src/Typesense/Index.php | 32 ++++++---- tests/Unit/ExistsTest.php | 131 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 14 deletions(-) create mode 100644 tests/Unit/ExistsTest.php diff --git a/src/Typesense/Index.php b/src/Typesense/Index.php index 5563fde..08b4554 100644 --- a/src/Typesense/Index.php +++ b/src/Typesense/Index.php @@ -10,6 +10,7 @@ use Statamic\Search\Result; use Statamic\Support\Arr; use Typesense\Client; +use Typesense\Collection as TypesenseCollection; use Typesense\Exceptions\ObjectNotFound; use Typesense\Exceptions\TypesenseClientError; @@ -45,21 +46,15 @@ public function fields(Searchable $searchable) public function delete($document) { try { - $this->getOrCreateIndex()->documents[$document->getSearchReference()]?->delete(); + $this->collection()->documents[$document->getSearchReference()]?->delete(); } catch (ObjectNotFound $e) { - // do nothing, this just prevents errors bubbling up when the document doesnt exist + // Nothing to do when the document, or the whole collection, isn't there. } } public function exists() { - try { - $this->getOrCreateIndex(); - - return true; - } catch (\Throwable $e) { - return false; - } + return (bool) $this->collection()->exists(); } public function insertDocuments(Documents $documents) @@ -69,7 +64,9 @@ public function insertDocuments(Documents $documents) protected function deleteIndex() { - $collection = $this->getOrCreateIndex(); + if (! ($collection = $this->collection())->exists()) { + return; + } $collection->delete(); @@ -107,7 +104,9 @@ public function searchUsingApi($query, array $options = []): array ->join(',') ?: '*'; } - $this->getOrCreateIndex(); + if (! $this->exists()) { + return ['raw' => [], 'results' => collect()]; + } // Using POST multiSearch to avoid potential request size limitations // (regular search uses GET and is limited in size by Typesense) @@ -145,7 +144,7 @@ public function searchUsingApi($query, array $options = []): array public function getOrCreateIndex() { - $collection = $this->client->getCollections()->{$this->name}; + $collection = $this->collection(); // The client hands back the same Collection instance for a given name and // remembers whether it exists, so this only asks Typesense the first time. @@ -174,10 +173,15 @@ public function getOrCreateIndex() public function getTypesenseSchemaFields(): Collection { return Blink::once('statamic-typesense::schema::'.$this->name(), function () { - return collect(Arr::get($this->getOrCreateIndex()->retrieve(), 'fields', [])); + return $this->exists() ? collect(Arr::get($this->collection()->retrieve(), 'fields', [])) : collect(); }); } + private function collection(): TypesenseCollection + { + return $this->client->getCollections()->{$this->name}; + } + private function getDefaultFields(Searchable $entry): array { return [ @@ -187,7 +191,7 @@ private function getDefaultFields(Searchable $entry): array public function getCount() { - return $this->getOrCreateIndex()->retrieve()['num_documents'] ?? 0; + return $this->exists() ? ($this->collection()->retrieve()['num_documents'] ?? 0) : 0; } public function client() diff --git a/tests/Unit/ExistsTest.php b/tests/Unit/ExistsTest.php new file mode 100644 index 0000000..c37cf9e --- /dev/null +++ b/tests/Unit/ExistsTest.php @@ -0,0 +1,131 @@ +shouldReceive('get') + ->with('/collections/'.$name, []) + ->andThrow(new ObjectNotFound); + + $apiCall->shouldNotReceive('post')->with('/collections', Mockery::any(), Mockery::any(), Mockery::any()); + + return $apiCall; + } + + #[Test] + public function exists_is_false_when_the_collection_is_missing() + { + $index = $this->indexWithMockedApi($this->apiWithMissingCollection()); + + $this->assertFalse($index->exists()); + } + + #[Test] + public function exists_is_true_when_the_collection_is_present() + { + $apiCall = Mockery::mock(ApiCall::class); + + $apiCall->shouldReceive('get') + ->with('/collections/test', []) + ->once() + ->andReturn(['name' => 'test']); + + $this->assertTrue($this->indexWithMockedApi($apiCall)->exists()); + } + + #[Test] + public function deleting_a_document_does_not_create_a_missing_collection() + { + $apiCall = $this->apiWithMissingCollection(); + + $apiCall->shouldReceive('delete') + ->with('/collections/test/documents/entry%3A%3A1', true, []) + ->andThrow(new ObjectNotFound); + + $document = Mockery::mock(); + $document->shouldReceive('getSearchReference')->andReturn('entry::1'); + + $this->indexWithMockedApi($apiCall)->delete($document); + } + + #[Test] + public function searching_a_missing_collection_returns_no_results_without_creating_it() + { + $apiCall = $this->apiWithMissingCollection(); + + $apiCall->shouldNotReceive('post')->with('/multi_search', Mockery::any(), Mockery::any(), Mockery::any()); + + $results = $this->indexWithMockedApi($apiCall)->searchUsingApi('*'); + + $this->assertTrue($results['results']->isEmpty()); + } + + #[Test] + public function update_creates_a_missing_collection_without_trying_to_delete_it() + { + $apiCall = Mockery::mock(ApiCall::class); + + $apiCall->shouldReceive('get') + ->with('/collections/test', []) + ->once() + ->andThrow(new ObjectNotFound); + + $apiCall->shouldNotReceive('delete'); + + $apiCall->shouldReceive('post') + ->with('/collections', Mockery::type('array'), true, []) + ->once() + ->andReturn(['name' => 'test']); + + $this->indexWithMockedApi($apiCall)->update(); + } + + #[Test] + public function count_is_zero_when_the_collection_is_missing() + { + $this->assertSame(0, $this->indexWithMockedApi($this->apiWithMissingCollection())->getCount()); + } + + #[Test] + public function schema_fields_are_empty_when_the_collection_is_missing() + { + $fields = $this->indexWithMockedApi($this->apiWithMissingCollection())->getTypesenseSchemaFields(); + + $this->assertTrue($fields->isEmpty()); + } + + #[Test] + public function inserting_documents_creates_a_missing_collection() + { + $apiCall = Mockery::mock(ApiCall::class); + + $apiCall->shouldReceive('get') + ->with('/collections/test', []) + ->once() + ->andThrow(new ObjectNotFound); + + $apiCall->shouldReceive('post') + ->with('/collections', Mockery::type('array'), true, []) + ->once() + ->andReturn(['name' => 'test']); + + $apiCall->shouldReceive('post') + ->with('/collections/test/documents/import', Mockery::any(), false, ['action' => 'upsert']) + ->once() + ->andReturn('{"success":true}'); + + $this->indexWithMockedApi($apiCall)->insertDocuments(new Documents(['entry::1' => ['id' => 'entry::1']])); + } +}