diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e8ed0dc..ae89bee 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,6 +14,8 @@ jobs: strategy: matrix: php-versions: ['7.2', '7.3', '7.4', '8.0'] + env: + MEILISEARCH_KEY: masterKey name: tests (php ${{ matrix.php-versions }}) runs-on: ubuntu-latest steps: @@ -25,7 +27,9 @@ jobs: - name: Validate composer.json and composer.lock run: composer validate - name: Install dependencies - run: composer install --prefer-dist --no-progress --no-suggest + run: composer update --prefer-dist --no-progress --no-interaction + - name: MeiliSearch setup with Docker + run: docker run -d -p 7700:7700 getmeili/meilisearch:latest ./meilisearch --master-key=${{ env.MEILISEARCH_KEY }} --no-analytics=true - name: Run tests run: composer test @@ -41,7 +45,7 @@ jobs: - name: Validate composer.json and composer.lock run: composer validate - name: Install dependencies - run: composer install --prefer-dist --no-progress --no-suggest + run: composer update --prefer-dist --no-progress --no-interaction - name: Run linter env: PHP_CS_FIXER_IGNORE_ENV: 1 diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 9627064..ff9a3a9 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -11,8 +11,11 @@ processIsolation="false" stopOnFailure="false"> - - ./tests/ + + ./tests/Unit + + + ./tests/Feature @@ -20,4 +23,10 @@ ./src + + + diff --git a/src/Engines/MeilisearchEngine.php b/src/Engines/MeilisearchEngine.php index 8532755..15d7919 100644 --- a/src/Engines/MeilisearchEngine.php +++ b/src/Engines/MeilisearchEngine.php @@ -142,7 +142,7 @@ protected function performSearch(Builder $builder, array $searchParams = []) protected function filters(Builder $builder) { return collect($builder->wheres)->map(function ($value, $key) { - return $key.'='.'"'.$value.'"'; + return sprintf('%s="%s"', $key, $value); })->values()->implode(' AND '); } diff --git a/tests/Feature/FeatureTestCase.php b/tests/Feature/FeatureTestCase.php new file mode 100644 index 0000000..39107a5 --- /dev/null +++ b/tests/Feature/FeatureTestCase.php @@ -0,0 +1,42 @@ +increments('id'); + $table->string('title'); + $table->timestamps(); + }); + + $this->cleanUp(); + } + + public function tearDown(): void + { + $this->cleanUp(); + + parent::tearDown(); + } + + protected function cleanUp(): void + { + collect(resolve(Client::class)->getAllIndexes())->each(function (Indexes $index) { + // Starts with prefix + if (substr($index->getUid(), 0, strlen($this->getPrefix())) === $this->getPrefix()) { + $index->delete(); + } + }); + } +} diff --git a/tests/Feature/MeilisearchConsoleCommandTest.php b/tests/Feature/MeilisearchConsoleCommandTest.php new file mode 100644 index 0000000..ce9f1a5 --- /dev/null +++ b/tests/Feature/MeilisearchConsoleCommandTest.php @@ -0,0 +1,50 @@ +expectExceptionMessage('Not enough arguments (missing: "name").'); + $this->artisan('scout:index') + ->execute(); + } + + /** @test */ + public function indexCanBeCreatedAndDeleted() + { + $indexUid = $this->getPrefixedIndexUid('testindex'); + + $this->artisan('scout:index', [ + 'name' => $indexUid, + ]) + ->expectsOutput('Index "'.$indexUid.'" created.') + ->assertExitCode(0) + ->run(); + + $indexResponse = resolve(Client::class)->index($indexUid)->fetchRawInfo(); + + $this->assertIsArray($indexResponse); + $this->assertSame($indexUid, $indexResponse['uid']); + + $this->artisan('scout:index', [ + 'name' => $indexUid, + '--delete' => true, + ]) + ->expectsOutput('Index "'.$indexUid.'" deleted.') + ->assertExitCode(0) + ->run(); + + try { + resolve(Client::class)->index($indexUid)->fetchRawInfo(); + $this->fail('Exception should be thrown that index doesn\'t exist!'); + } catch (HTTPRequestException $exception) { + $this->assertTrue(true); + } + } +} diff --git a/tests/Feature/MeilisearchTest.php b/tests/Feature/MeilisearchTest.php new file mode 100644 index 0000000..381c7e9 --- /dev/null +++ b/tests/Feature/MeilisearchTest.php @@ -0,0 +1,156 @@ +assertInstanceOf(Client::class, resolve(Client::class)); + $this->assertInstanceOf(EngineManager::class, resolve(EngineManager::class)); + $this->assertInstanceOf(MeilisearchEngine::class, resolve(EngineManager::class)->engine('meilisearch')); + } + + /** @test */ + public function clientCanTalkToMeilisearch() + { + /** @var Client $engine */ + $engine = resolve(Client::class); + + $this->assertNull($engine->health()); + $versionResponse = $engine->version(); + $this->assertIsArray($versionResponse); + $this->assertArrayHasKey('commitSha', $versionResponse); + $this->assertArrayHasKey('buildDate', $versionResponse); + $this->assertArrayHasKey('pkgVersion', $versionResponse); + } + + /** @test */ + public function searchReturnsModels() + { + $model = $this->createSearchableModel('foo'); + $this->createSearchableModel('bar'); + + $this->assertDatabaseCount('searchable_models', 2); + + $searchResponse = $this->waitForPendingUpdates($model, function () { + return SearchableModel::search('bar')->raw(); + }); + + $this->assertIsArray($searchResponse); + $this->assertArrayHasKey('hits', $searchResponse); + $this->assertArrayHasKey('query', $searchResponse); + $this->assertTrue(1 === count($searchResponse['hits'])); + } + + /** @test */ + public function searchReturnsCorrectModelAfterUpdate() + { + $fooModel = $this->createSearchableModel('foo'); + $this->createSearchableModel('bar'); + + $this->assertDatabaseCount('searchable_models', 2); + + $searchResponse = $this->waitForPendingUpdates($fooModel, function () { + return SearchableModel::search('foo')->raw(); + }); + + $this->assertIsArray($searchResponse); + $this->assertArrayHasKey('hits', $searchResponse); + $this->assertArrayHasKey('query', $searchResponse); + $this->assertTrue(1 === count($searchResponse['hits'])); + $this->assertTrue('foo' === $searchResponse['hits'][0]['title']); + + $fooModel->update(['title' => 'lorem']); + + $searchResponse = $this->waitForPendingUpdates($fooModel, function () { + return SearchableModel::search('lorem')->raw(); + }); + + $this->assertIsArray($searchResponse); + $this->assertArrayHasKey('hits', $searchResponse); + $this->assertArrayHasKey('query', $searchResponse); + $this->assertTrue(1 === count($searchResponse['hits'])); + $this->assertTrue('lorem' === $searchResponse['hits'][0]['title']); + } + + /** @test */ + public function customSearchReturnsResults() + { + $models = $this->createMultipleSearchableModels(10); + + $this->assertDatabaseCount('searchable_models', 10); + + $searchResponse = $this->waitForPendingUpdates($models->first(), function () { + return SearchableModel::search('', function ($meilisearch, $query, $options) { + $options['limit'] = 2; + + return $meilisearch->search($query, $options); + })->raw(); + }); + + $this->assertIsArray($searchResponse); + $this->assertArrayHasKey('hits', $searchResponse); + $this->assertArrayHasKey('query', $searchResponse); + $this->assertTrue(2 === $searchResponse['limit']); + $this->assertTrue(2 === count($searchResponse['hits'])); + } + + /** + * Fixes race condition and waits some time for the indexation to complete. + * + * @param Model $model + * @param callable $callback + * + * @return mixed + */ + protected function waitForPendingUpdates($model, $callback) + { + $index = resolve(Client::class)->index($model->searchableAs()); + $pendingUpdates = $index->getAllUpdateStatus(); + + foreach ($pendingUpdates as $pendingUpdate) { + if ('processed' !== $pendingUpdate['status']) { + $index->waitForPendingUpdate($pendingUpdate['updateId']); + } + } + + return $callback(); + } + + protected function createMultipleSearchableModels(int $times = 1) + { + $models = collect(); + + for ($i = 1; $i <= $times; ++$i) { + $models->add($this->createSearchableModel()); + } + + return $models; + } + + protected function createSearchableModel(?string $title = null) + { + return SearchableModel::create([ + 'title' => $title ?? $this->faker->sentence, + ]); + } +} + +class SearchableModel extends BaseSearchableModel +{ + public function searchableAs() + { + return config('scout.prefix').$this->getTable(); + } +} diff --git a/tests/Fixtures/SearchableModel.php b/tests/Fixtures/SearchableModel.php index 8f06525..3d2f5e6 100644 --- a/tests/Fixtures/SearchableModel.php +++ b/tests/Fixtures/SearchableModel.php @@ -2,17 +2,19 @@ namespace Meilisearch\Scout\Tests\Fixtures; +use Illuminate\Database\Eloquent\Concerns\HasTimestamps; use Illuminate\Database\Eloquent\Model; use Laravel\Scout\Searchable; class SearchableModel extends Model { use Searchable; + use HasTimestamps; /** * The attributes that are mass assignable. */ - protected $fillable = ['id']; + protected $fillable = ['id', 'title']; public function searchableAs() { diff --git a/tests/TestCase.php b/tests/TestCase.php index 14e8b36..b11e4cf 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -14,4 +14,30 @@ protected function getPackageProviders($app) MeilisearchServiceProvider::class, ]; } + + protected function getEnvironmentSetUp($app) + { + if (env('DB_CONNECTION')) { + config()->set('database.default', env('DB_CONNECTION')); + } else { + config()->set('database.default', 'testing'); + config()->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + } + config()->set('scout.driver', 'meilisearch'); + config()->set('scout.prefix', $this->getPrefix()); + } + + protected function getPrefixedIndexUid(string $indexUid) + { + return sprintf('%s_%s', $this->getPrefix(), $indexUid); + } + + protected function getPrefix() + { + return 'meilisearch-laravel-scout_testing'; + } } diff --git a/tests/MeilisearchConsoleCommandTest.php b/tests/Unit/MeilisearchConsoleCommandTest.php similarity index 73% rename from tests/MeilisearchConsoleCommandTest.php rename to tests/Unit/MeilisearchConsoleCommandTest.php index 67066ff..233aef4 100644 --- a/tests/MeilisearchConsoleCommandTest.php +++ b/tests/Unit/MeilisearchConsoleCommandTest.php @@ -1,29 +1,22 @@ expectExceptionMessage('Not enough arguments (missing: "name").'); - $this->artisan('scout:index') - ->execute(); - } - /** @test */ public function commandCreatesIndex() { $client = $this->mock(Client::class); - $client->expects('createIndex')->with($indexName = 'testindex', [])->andReturn(m::mock(Indexes::class)); + $client->expects('createIndex')->with($indexUid = 'testindex', [])->andReturn(m::mock(Indexes::class)); $engineManager = $this->mock(EngineManager::class); $engineManager->shouldReceive('engine')->with('meilisearch')->andReturn(new MeilisearchEngine( @@ -31,9 +24,9 @@ public function commandCreatesIndex() )); $this->artisan('scout:index', [ - 'name' => $indexName, + 'name' => $indexUid, ]) - ->expectsOutput('Index "'.$indexName.'" created.') + ->expectsOutput('Index "'.$indexUid.'" created.') ->assertExitCode(0) ->run(); } @@ -44,7 +37,7 @@ public function keyParameterSetsPrimaryKeyOption() $client = $this->mock(Client::class); $client ->expects('createIndex') - ->with($indexName = 'testindex', ['primaryKey' => $testPrimaryKey = 'foobar']) + ->with($indexUid = 'testindex', ['primaryKey' => $testPrimaryKey = 'foobar']) ->andReturn(m::mock(Indexes::class)); $engineManager = $this->mock(EngineManager::class); @@ -53,10 +46,10 @@ public function keyParameterSetsPrimaryKeyOption() )); $this->artisan('scout:index', [ - 'name' => $indexName, + 'name' => $indexUid, '--key' => $testPrimaryKey, ]) - ->expectsOutput('Index "'.$indexName.'" created.') + ->expectsOutput('Index "'.$indexUid.'" created.') ->assertExitCode(0) ->run(); } @@ -65,7 +58,7 @@ public function keyParameterSetsPrimaryKeyOption() public function deleteParameterDeletesIndex() { $client = $this->mock(Client::class); - $client->expects('deleteIndex')->with($indexName = 'testindex')->andReturn([]); + $client->expects('deleteIndex')->with($indexUid = 'testindex')->andReturn([]); $engineManager = $this->mock(EngineManager::class); $engineManager->shouldReceive('engine')->with('meilisearch')->andReturn(new MeilisearchEngine( @@ -73,10 +66,10 @@ public function deleteParameterDeletesIndex() )); $this->artisan('scout:index', [ - 'name' => $indexName, + 'name' => $indexUid, '--delete' => true, ]) - ->expectsOutput('Index "'.$indexName.'" deleted.') + ->expectsOutput('Index "'.$indexUid.'" deleted.') ->assertExitCode(0) ->run(); } diff --git a/tests/MeilisearchEngineTest.php b/tests/Unit/MeilisearchEngineTest.php similarity index 83% rename from tests/MeilisearchEngineTest.php rename to tests/Unit/MeilisearchEngineTest.php index 841a90b..7c570db 100644 --- a/tests/MeilisearchEngineTest.php +++ b/tests/Unit/MeilisearchEngineTest.php @@ -1,28 +1,20 @@ assertInstanceOf(Client::class, resolve(Client::class)); - $this->assertInstanceOf(EngineManager::class, resolve(EngineManager::class)); - $this->assertInstanceOf(MeilisearchEngine::class, resolve(EngineManager::class)->engine('meilisearch')); - } - /** @test */ public function updateAddsObjectsToIndex() { @@ -266,6 +258,62 @@ public function updateEmptySearchableArrayFromSoftDeletedModelDoesNotAddObjectsT $engine = new MeilisearchEngine($client, true); $engine->update(Collection::make([new SoftDeleteEmptySearchableModel()])); } + + /** @test */ + public function engineForwardsCallsToMeilisearchClient() + { + $client = m::mock(Client::class); + $client->shouldReceive('testMethodOnClient')->once(); + + $engine = new MeilisearchEngine($client); + $engine->testMethodOnClient(); + } + + /** @test */ + public function updatingEmptyEloquentCollectionDoesNothing() + { + $client = m::mock(Client::class); + $engine = new MeilisearchEngine($client); + $engine->update(new Collection()); + $this->assertTrue(true); + } + + /** @test */ + public function performingSearchWithoutCallbackWorks() + { + $client = m::mock(Client::class); + $client->shouldReceive('index')->once()->andReturn($index = m::mock(Indexes::class)); + $index->shouldReceive('rawSearch')->once()->andReturn([]); + + $engine = new MeilisearchEngine($client); + $builder = new Builder(new SearchableModel(), ''); + $engine->search($builder); + } + + /** @test */ + public function whereConditionsAreApplied() + { + $builder = new Builder(new SearchableModel(), ''); + $builder->where('foo', 'bar'); + $builder->where('key', 'value'); + $client = m::mock(Client::class); + $client->shouldReceive('index')->once()->andReturn($index = m::mock(Indexes::class)); + $index->shouldReceive('rawSearch')->once()->with($builder->query, array_filter([ + 'filters' => 'foo="bar" AND key="value"', + 'limit' => $builder->limit, + ]))->andReturn([]); + + $engine = new MeilisearchEngine($client); + $engine->search($builder); + } + + /** @test */ + public function engineReturnsHitsEntryFromSearchResponse() + { + $this->assertTrue(3 === resolve(MeilisearchEngine::class)->getTotalCount([ + 'nbHits' => 3, + ])); + } } class CustomKeySearchableModel extends SearchableModel