diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..b6db57e --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,52 @@ +name: Pull Request + +on: + pull_request: + branches: [master] + +jobs: + tests: + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + matrix: + node-version: [16.x] + typesense-version: [0.24.0] + steps: + - name: Checkout code + uses: actions/checkout@v2 + with: + submodules: 'recursive' + + - name: Start Typesense + uses: jirevwe/typesense-github-action@v1.0.1 + with: + typesense-version: ${{ matrix.typesense-version }} + typesense-port: 8108 + typesense-api-key: xyz + + - name: Composer Validate + run: composer validate + + - name: Cache Composer Packages + id: composer-cache + uses: actions/cache@v2 + with: + path: vendor + key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php- + + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: | + ~/.npm + key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }} + + - name: Install Dependencies + run: | + composer update --prefer-dist --no-interaction --no-progress + + - name: Run Tests + run: vendor/bin/phpunit tests diff --git a/.gitignore b/.gitignore index 8228c84..7d317e9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .tmp /composer.lock vendor +.phpunit.result.cache diff --git a/README.md b/README.md index d7d39d1..06684bd 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,7 @@ $searchRequests = [ ] ]; -Todo::searchMulti($searchRequests)->paginateRaw(); +Todo::search('')->searchMulti($searchRequests)->paginateRaw(); ``` ### Generate Scoped Search Key diff --git a/composer.json b/composer.json index 7c01414..661b135 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,8 @@ "minimum-stability": "stable", "autoload": { "psr-4": { - "Typesense\\LaravelTypesense\\": "src/" + "Typesense\\LaravelTypesense\\": "src/", + "Typesense\\LaravelTypesense\\Tests\\": "tests/" } }, "extra": { @@ -47,11 +48,16 @@ "illuminate/pagination": "^7.0|^8.0|^9.0|^10.0", "illuminate/queue": "^7.0|^8.0|^9.0|^10.0", "illuminate/support": "^7.0|^8.0|^9.0|^10.0", - "typesense/typesense-php": "^4.0" + "typesense/typesense-php": "^4.0", + "symfony/http-client": "^5.4" }, "config": { "platform": { "php": "8.0" + }, + "allow-plugins": { + "pestphp/pest-plugin": true, + "php-http/discovery": true } }, "suggest": { @@ -59,6 +65,7 @@ }, "require-dev": { "phpunit/phpunit": "^8.0|^9.0", - "mockery/mockery": "^1.3" + "mockery/mockery": "^1.3", + "orchestra/testbench": "^6.17|^7.0|^8.0" } } diff --git a/config/scout.php b/config/scout.php new file mode 100644 index 0000000..94a4adf --- /dev/null +++ b/config/scout.php @@ -0,0 +1,163 @@ + env('SCOUT_DRIVER', 'typesense'), + + /* + |-------------------------------------------------------------------------- + | Index Prefix + |-------------------------------------------------------------------------- + | + | Here you may specify a prefix that will be applied to all search index + | names used by Scout. This prefix may be useful if you have multiple + | "tenants" or applications sharing the same search infrastructure. + | + */ + + 'prefix' => env('SCOUT_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Queue Data Syncing + |-------------------------------------------------------------------------- + | + | This option allows you to control if the operations that sync your data + | with your search engines are queued. When this is set to "true" then + | all automatic data syncing will get queued for better performance. + | + */ + + 'queue' => env('SCOUT_QUEUE', false), + + /* + |-------------------------------------------------------------------------- + | Database Transactions + |-------------------------------------------------------------------------- + | + | This configuration option determines if your data will only be synced + | with your search indexes after every open database transaction has + | been committed, thus preventing any discarded data from syncing. + | + */ + + 'after_commit' => false, + + /* + |-------------------------------------------------------------------------- + | Chunk Sizes + |-------------------------------------------------------------------------- + | + | These options allow you to control the maximum chunk size when you are + | mass importing data into the search engine. This allows you to fine + | tune each of these chunk sizes based on the power of the servers. + | + */ + + 'chunk' => [ + 'searchable' => 500, + 'unsearchable' => 500, + ], + + /* + |-------------------------------------------------------------------------- + | Soft Deletes + |-------------------------------------------------------------------------- + | + | This option allows to control whether to keep soft deleted records in + | the search indexes. Maintaining soft deleted records can be useful + | if your application still needs to search for the records later. + | + */ + + 'soft_delete' => false, + + /* + |-------------------------------------------------------------------------- + | Identify User + |-------------------------------------------------------------------------- + | + | This option allows you to control whether to notify the search engine + | of the user performing the search. This is sometimes useful if the + | engine supports any analytics based on this application's users. + | + | Supported engines: "algolia" + | + */ + + 'identify' => env('SCOUT_IDENTIFY', false), + + /* + |-------------------------------------------------------------------------- + | Algolia Configuration + |-------------------------------------------------------------------------- + | + | Here you may configure your Algolia settings. Algolia is a cloud hosted + | search engine which works great with Scout out of the box. Just plug + | in your application ID and admin API key to get started searching. + | + */ + + 'algolia' => [ + 'id' => env('ALGOLIA_APP_ID', ''), + 'secret' => env('ALGOLIA_SECRET', ''), + ], + + /* + |-------------------------------------------------------------------------- + | MeiliSearch Configuration + |-------------------------------------------------------------------------- + | + | Here you may configure your MeiliSearch settings. MeiliSearch is an open + | source search engine with minimal configuration. Below, you can state + | the host and key information for your own MeiliSearch installation. + | + | See: https://docs.meilisearch.com/guides/advanced_guides/configuration.html + | + */ + + 'meilisearch' => [ + 'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'), + 'key' => env('MEILISEARCH_KEY', null), + 'index-settings' => [ + // 'users' => [ + // 'filterableAttributes'=> ['id', 'name', 'email'], + // ], + ], + ], + + 'typesense' => [ + 'api_key' => 'xyz', + 'nodes' => [ + [ + 'host' => 'localhost', + 'port' => '8108', + 'path' => '', + 'protocol' => 'http', + ], + ], + 'nearest_node' => [ + 'host' => 'localhost', + 'port' => '8108', + 'path' => '', + 'protocol' => 'http', + ], + 'connection_timeout_seconds' => 2, + 'healthcheck_interval_seconds' => 30, + 'num_retries' => 3, + 'retry_interval_seconds' => 1, + ], +]; diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..a7ea2ef --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,23 @@ + + + + + ./tests/Feature + + + + + ./app + ./src + + + + + + + + diff --git a/tests/Feature/MultiSearchTest.php b/tests/Feature/MultiSearchTest.php new file mode 100644 index 0000000..72f5599 --- /dev/null +++ b/tests/Feature/MultiSearchTest.php @@ -0,0 +1,93 @@ +setUpFaker(); + $this->loadLaravelMigrations(); + + SearchableUserModel::create([ + 'name' => 'Laravel Typsense', + 'email' => 'typesense@example.com', + 'password' => bcrypt('password'), + ]); + SearchableUserModel::create([ + 'name' => 'Laravel Typsense', + 'email' => 'fake@example.com', + 'password' => bcrypt('password'), + ]); + SearchableUserModel::create([ + 'name' => 'Laravel Typsense', + 'email' => 'test@example.com', + 'password' => bcrypt('password'), + ]); + } + + public function testSearchByEmail() + { + $searchRequests = [ + [ + 'collection' => 'users', + 'q' => 'Laravel Typsense' + ], + [ + 'collection' => 'users', + 'q' => 'typesense@example.com' + ] + ]; + + $response = SearchableUserModel::search('')->searchMulti($searchRequests)->paginateRaw(); + + $this->assertCount(2, $response->items()['results']); + $this->assertEquals(3, $response->items()['results'][0]['found']); + $this->assertEquals("test@example.com", $response->items()['results'][0]['hits'][0]['document']['email']); + + } + + public function testSearchByName() + { + $searchRequests = [ + [ + 'collection' => 'users', + 'q' => 'Laravel Typsense' + ], + [ + 'collection' => 'users', + 'q' => 'typesense@example.com' + ] + ]; + + $response = SearchableUserModel::search('')->searchMulti($searchRequests)->paginateRaw(); + + $this->assertCount(2, $response->items()['results']); + $this->assertEquals(1, $response->items()['results'][1]['found']); + $this->assertEquals("typesense@example.com", $response->items()['results'][1]['hits'][0]['document']['email']); + } + + public function testSearchByWrongQueryParams() + { + $searchRequests = [ + [ + 'collection' => 'users', + 'q' => 'Wrong Params' + ], + [ + 'collection' => 'users', + 'q' => 'wrong@example.com' + ] + ]; + + $response = SearchableUserModel::search('')->searchMulti($searchRequests)->paginateRaw(); + $this->assertEquals(0, $response->items()['results'][0]['found']); + $this->assertEquals(0, $response->items()['results'][1]['found']); + } +} diff --git a/tests/Feature/PaginateTest.php b/tests/Feature/PaginateTest.php new file mode 100644 index 0000000..c767973 --- /dev/null +++ b/tests/Feature/PaginateTest.php @@ -0,0 +1,45 @@ +setUpFaker(); + $this->loadLaravelMigrations(); + + SearchableUserModel::create([ + 'name' => 'Laravel Typsense', + 'email' => 'typesense@example.com', + 'password' => bcrypt('password'), + ]); + SearchableUserModel::create([ + 'name' => 'Laravel Typsense', + 'email' => 'fake@example.com', + 'password' => bcrypt('password'), + ]); + SearchableUserModel::create([ + 'name' => 'Laravel Typsense', + 'email' => 'test@example.com', + 'password' => bcrypt('password'), + ]); + } + + public function testPaginate() + { + $response = SearchableUserModel::search('Laravel Typsense')->paginate(); + + $this->assertInstanceOf(LengthAwarePaginator::class, $response); + $this->assertInstanceOf(SearchableUserModel::class, $response->items()[0]); + $this->assertEquals(3, $response->total()); + $this->assertEquals(1, $response->lastPage()); + } +} diff --git a/tests/Feature/SearchableTest.php b/tests/Feature/SearchableTest.php new file mode 100644 index 0000000..2f21807 --- /dev/null +++ b/tests/Feature/SearchableTest.php @@ -0,0 +1,50 @@ +setUpFaker(); + $this->loadLaravelMigrations(); + + SearchableUserModel::create([ + 'name' => 'Laravel Typsense', + 'email' => 'typesense@example.com', + 'password' => bcrypt('password'), + ]); + SearchableUserModel::create([ + 'name' => 'Laravel Typsense', + 'email' => 'fake@example.com', + 'password' => bcrypt('password'), + ]); + } + + public function testSearchByEmail() + { + $models = SearchableUserModel::search('typesense@example.com')->get(); + + $this->assertCount(1, $models); + } + + public function testSearchByName() + { + $models = SearchableUserModel::search('Laravel Typsense')->get(); + + $this->assertCount(2, $models); + } + + public function testSearchByWrongQueryParam() + { + $models = SearchableUserModel::search('test@example.com')->get(); + + $this->assertCount(0, $models); + } +} diff --git a/tests/Fixtures/SearchableUserModel.php b/tests/Fixtures/SearchableUserModel.php new file mode 100644 index 0000000..a998893 --- /dev/null +++ b/tests/Fixtures/SearchableUserModel.php @@ -0,0 +1,65 @@ +toArray(), + [ + 'id' => (string)$this->id, + 'created_at' => $this->created_at->timestamp, + ] + ); + } + + public function getCollectionSchema(): array + { + return [ + 'name' => $this->searchableAs(), + 'fields' => [ + [ + 'name' => 'id', + 'type' => 'string', + 'facet' => true, + ], + [ + 'name' => 'name', + 'type' => 'string', + 'facet' => true, + ], + [ + 'name' => 'email', + 'type' => 'string', + 'facet' => true, + ], + [ + 'name' => 'created_at', + 'type' => 'int64', + 'facet' => true, + ], + ], + 'default_sorting_field' => 'created_at', + ]; + } + + public function typesenseQueryBy(): array + { + return [ + 'name', + 'email', + ]; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..da79e29 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,34 @@ +singleton(EngineManager::class, function ($app) { + return new EngineManager($app); + }); + + return [TypesenseServiceProvider::class]; + } + + protected function defineEnvironment($app) + { + $this->mergeConfigFrom($app, __DIR__.'/../config/scout.php', 'scout'); + } + + private function mergeConfigFrom($app, $path, $key) + { + $config = $app['config']->get($key, []); + + $app['config']->set($key, array_merge(require $path, $config)); + } +}