diff --git a/src/app/Console/Commands/AlgoliaSyncIndexSettings.php b/src/app/Console/Commands/AlgoliaSyncIndexSettings.php new file mode 100644 index 00000000..821da2ff --- /dev/null +++ b/src/app/Console/Commands/AlgoliaSyncIndexSettings.php @@ -0,0 +1,64 @@ +option('dry-run')) { + $this->info('[dry-run] would set attributesForFaceting on index: ' . $indexName); + $this->line(json_encode(self::ATTRIBUTES_FOR_FACETING, JSON_PRETTY_PRINT)); + return self::SUCCESS; + } + + $appId = config('algolia.algolia_app_id'); + $apiKey = config('algolia.algolia_write_api_key'); + + if (!$appId || !$apiKey || !$indexName) { + $this->error('Missing ALGOLIA_APP_ID / ALGOLIA_WRITE_API_KEY / ALGOLIA_INDEX'); + return self::FAILURE; + } + + // Resolved lazily (only reached once credentials are confirmed present) so + // dry-run and the missing-credentials path never touch the container binding. + $syncer = new AlgoliaIndexSettingsSyncer($this->laravel->make(SearchClient::class)); + $syncer->syncAttributesForFaceting($indexName, self::ATTRIBUTES_FOR_FACETING); + + $this->info("Done: attributesForFaceting synced on index={$indexName}"); + + return self::SUCCESS; + } +} diff --git a/src/app/Packages/Domains/README.md b/src/app/Packages/Domains/README.md index 541f53cd..daf5047f 100644 --- a/src/app/Packages/Domains/README.md +++ b/src/app/Packages/Domains/README.md @@ -51,4 +51,18 @@ AlgoliaAdapter SearchResult (Ports) ↓ Dto -``` \ No newline at end of file +``` + +--- + +## Algolia Index Settings + +`attributesForFaceting` is managed as code via `App\Console\Commands\AlgoliaSyncIndexSettings::ATTRIBUTES_FOR_FACETING`, +applied with `php artisan algolia:sync-index-settings` (use `--dry-run` to preview without writing). +Every attribute that `AlgoliaWorldHeritageSearchAdapter` filters or optional-filters on must be +listed there, or Algolia rejects the request. + +All currently declared attributes are plain facets (facet counts enabled), none use `filterOnly()`. +`country_names_jp`, `region`, and `study_regions` are not used as filters by the Adapter today, so +they are candidates for `filterOnly()` to shrink the index — but this has not been changed yet, +since whether the frontend relies on their facet counts has not been confirmed. \ No newline at end of file diff --git a/src/app/Packages/Domains/WorldHeritage/Adapter/AlgoliaIndexSettingsSyncer.php b/src/app/Packages/Domains/WorldHeritage/Adapter/AlgoliaIndexSettingsSyncer.php new file mode 100644 index 00000000..0aabf350 --- /dev/null +++ b/src/app/Packages/Domains/WorldHeritage/Adapter/AlgoliaIndexSettingsSyncer.php @@ -0,0 +1,24 @@ +client->setSettings($indexName, [ + 'attributesForFaceting' => $attributesForFaceting, + ]); + + $taskId = $res['taskID'] ?? null; + if ($taskId !== null) { + $this->client->waitForTask($indexName, $taskId); + } + } +} diff --git a/src/app/Packages/Domains/WorldHeritage/Tests/AlgoliaIndexSettingsSyncerTest.php b/src/app/Packages/Domains/WorldHeritage/Tests/AlgoliaIndexSettingsSyncerTest.php new file mode 100644 index 00000000..b9ce2e47 --- /dev/null +++ b/src/app/Packages/Domains/WorldHeritage/Tests/AlgoliaIndexSettingsSyncerTest.php @@ -0,0 +1,43 @@ +shouldReceive('setSettings') + ->once() + ->with('world_heritages', ['attributesForFaceting' => ['category', 'country']]) + ->andReturn(['taskID' => 42]); + $client->shouldReceive('waitForTask')->once()->with('world_heritages', 42); + + $syncer = new AlgoliaIndexSettingsSyncer($client); + $syncer->syncAttributesForFaceting('world_heritages', ['category', 'country']); + } + + #[DoesNotPerformAssertions] + public function test_does_not_wait_when_algolia_returns_no_task_id(): void + { + $client = Mockery::mock(SearchClient::class); + $client->shouldReceive('setSettings')->once()->andReturn([]); + $client->shouldNotReceive('waitForTask'); + + $syncer = new AlgoliaIndexSettingsSyncer($client); + $syncer->syncAttributesForFaceting('world_heritages', ['category']); + } +} diff --git a/src/app/Packages/Domains/WorldHeritage/Tests/AlgoliaSyncIndexSettingsTest.php b/src/app/Packages/Domains/WorldHeritage/Tests/AlgoliaSyncIndexSettingsTest.php new file mode 100644 index 00000000..20c6a9c0 --- /dev/null +++ b/src/app/Packages/Domains/WorldHeritage/Tests/AlgoliaSyncIndexSettingsTest.php @@ -0,0 +1,83 @@ + true]); + + $this->assertSame(0, $exitCode); + + $output = Artisan::output(); + foreach (AlgoliaSyncIndexSettings::ATTRIBUTES_FOR_FACETING as $attribute) { + $this->assertStringContainsString($attribute, $output); + } + } + + public function test_fails_when_write_credentials_are_missing(): void + { + config(['algolia.algolia_write_api_key' => null]); + + $exitCode = Artisan::call('algolia:sync-index-settings'); + + $this->assertSame(1, $exitCode); + $this->assertStringContainsString('Missing', Artisan::output()); + } + + public function test_syncs_attributes_for_faceting_when_credentials_are_present(): void + { + config([ + 'algolia.algolia_app_id' => 'dummy-app', + 'algolia.algolia_write_api_key' => 'dummy-key', + 'algolia.algolia_index' => 'world_heritages', + ]); + + $client = Mockery::mock(SearchClient::class); + $client->shouldReceive('setSettings') + ->once() + ->with('world_heritages', ['attributesForFaceting' => AlgoliaSyncIndexSettings::ATTRIBUTES_FOR_FACETING]) + ->andReturn(['taskID' => 123]); + $client->shouldReceive('waitForTask')->once()->with('world_heritages', 123); + + $this->app->instance(SearchClient::class, $client); + + $exitCode = Artisan::call('algolia:sync-index-settings'); + + $this->assertSame(0, $exitCode); + $this->assertStringContainsString('Done', Artisan::output()); + } + + public function test_attributes_for_faceting_covers_every_attribute_the_adapter_filters_on(): void + { + // Every attribute AlgoliaWorldHeritageSearchAdapter uses in $filters + // or $optionalFilters must be declared here, or Algolia rejects the + // facet filter request. + $usedByAdapter = [ + 'state_party_codes', + 'country', + 'country_name_jp', + 'study_region', + 'category', + 'criteria', + 'is_endangered', + ]; + + foreach ($usedByAdapter as $attribute) { + $this->assertContains($attribute, AlgoliaSyncIndexSettings::ATTRIBUTES_FOR_FACETING); + } + } +} diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php index a12b9fe0..369163c6 100644 --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -31,6 +31,13 @@ public function register(): void return new AlgoliaWorldHeritageSearchAdapter(client: $client, indexName: config('algolia.algolia_index')); }); + + // Write-key client for index administration (e.g. algolia:sync-index-settings). + // Resolved lazily via the container so callers only pay for it, and tests + // can swap it out, when they actually reach a code path that needs it. + $this->app->bind(SearchClient::class, function () { + return SearchClient::create(config('algolia.algolia_app_id'), config('algolia.algolia_write_api_key')); + }); } /**