From 2536218cb023a2555898500e9dfb5dd2e541f13d Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sat, 24 Oct 2026 23:29:03 +0900 Subject: [PATCH 1/6] feat: add algolia:sync-index-settings command to manage attributesForFaceting as code --- .../Commands/AlgoliaSyncIndexSettings.php | 67 +++++++++++++++++++ src/app/Packages/Domains/README.md | 16 ++++- 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 src/app/Console/Commands/AlgoliaSyncIndexSettings.php diff --git a/src/app/Console/Commands/AlgoliaSyncIndexSettings.php b/src/app/Console/Commands/AlgoliaSyncIndexSettings.php new file mode 100644 index 00000000..9354cef7 --- /dev/null +++ b/src/app/Console/Commands/AlgoliaSyncIndexSettings.php @@ -0,0 +1,67 @@ +error('Missing ALGOLIA_APP_ID / ALGOLIA_WRITE_API_KEY / ALGOLIA_INDEX'); + return self::FAILURE; + } + + if ($this->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; + } + + $client = SearchClient::create($appId, $apiKey); + + $res = $client->setSettings($indexName, [ + 'attributesForFaceting' => self::ATTRIBUTES_FOR_FACETING, + ]); + + $taskId = $res['taskID'] ?? null; + if ($taskId !== null) { + $client->waitForTask($indexName, $taskId); + } + + $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 From 6dc737f3a91ff212b44ea23cd4381ad4cdf91795 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sat, 24 Oct 2026 23:29:09 +0900 Subject: [PATCH 2/6] test: cover AlgoliaSyncIndexSettings dry-run output and adapter-filter coverage --- .../Tests/AlgoliaSyncIndexSettingsTest.php | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/app/Packages/Domains/WorldHeritage/Tests/AlgoliaSyncIndexSettingsTest.php 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..12fea0d8 --- /dev/null +++ b/src/app/Packages/Domains/WorldHeritage/Tests/AlgoliaSyncIndexSettingsTest.php @@ -0,0 +1,42 @@ + true]); + + $this->assertSame(0, $exitCode); + + $output = Artisan::output(); + foreach (AlgoliaSyncIndexSettings::ATTRIBUTES_FOR_FACETING as $attribute) { + $this->assertStringContainsString($attribute, $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); + } + } +} From f1dc12e35187e7d74102b24007470ffd0c217278 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 25 Oct 2026 14:42:07 +0900 Subject: [PATCH 3/6] fix: make --dry-run work without Algolia credentials configured --- .../Commands/AlgoliaSyncIndexSettings.php | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/app/Console/Commands/AlgoliaSyncIndexSettings.php b/src/app/Console/Commands/AlgoliaSyncIndexSettings.php index 9354cef7..6528f20c 100644 --- a/src/app/Console/Commands/AlgoliaSyncIndexSettings.php +++ b/src/app/Console/Commands/AlgoliaSyncIndexSettings.php @@ -34,21 +34,23 @@ class AlgoliaSyncIndexSettings extends Command public function handle(): int { - $appId = config('algolia.algolia_app_id'); - $apiKey = config('algolia.algolia_write_api_key'); $indexName = config('algolia.algolia_index'); - if (!$appId || !$apiKey || !$indexName) { - $this->error('Missing ALGOLIA_APP_ID / ALGOLIA_WRITE_API_KEY / ALGOLIA_INDEX'); - return self::FAILURE; - } - + // Dry-run never talks to Algolia, so it must not depend on credentials being configured. if ($this->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; + } + $client = SearchClient::create($appId, $apiKey); $res = $client->setSettings($indexName, [ From e2373e32e4c8b3c5936f8d5d5e1d9cbfaf462edc Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 25 Oct 2026 15:11:31 +0900 Subject: [PATCH 4/6] refactor: extract AlgoliaIndexSettingsSyncer with injectable SearchClient --- .../Adapter/AlgoliaIndexSettingsSyncer.php | 24 +++++++++++++++++++ src/app/Providers/AppServiceProvider.php | 7 ++++++ 2 files changed, 31 insertions(+) create mode 100644 src/app/Packages/Domains/WorldHeritage/Adapter/AlgoliaIndexSettingsSyncer.php 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/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')); + }); } /** From bd2849b06fffdb65997bee802f25ab96b12637e5 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 25 Oct 2026 15:11:35 +0900 Subject: [PATCH 5/6] refactor: delegate attributesForFaceting sync to AlgoliaIndexSettingsSyncer --- .../Console/Commands/AlgoliaSyncIndexSettings.php | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/app/Console/Commands/AlgoliaSyncIndexSettings.php b/src/app/Console/Commands/AlgoliaSyncIndexSettings.php index 6528f20c..821da2ff 100644 --- a/src/app/Console/Commands/AlgoliaSyncIndexSettings.php +++ b/src/app/Console/Commands/AlgoliaSyncIndexSettings.php @@ -3,6 +3,7 @@ namespace App\Console\Commands; use Algolia\AlgoliaSearch\Api\SearchClient; +use App\Packages\Domains\WorldHeritage\Adapter\AlgoliaIndexSettingsSyncer; use Illuminate\Console\Command; class AlgoliaSyncIndexSettings extends Command @@ -51,16 +52,10 @@ public function handle(): int return self::FAILURE; } - $client = SearchClient::create($appId, $apiKey); - - $res = $client->setSettings($indexName, [ - 'attributesForFaceting' => self::ATTRIBUTES_FOR_FACETING, - ]); - - $taskId = $res['taskID'] ?? null; - if ($taskId !== null) { - $client->waitForTask($indexName, $taskId); - } + // 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}"); From e236927f84a41caabd6f32f2197eda88636b42ea Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 25 Oct 2026 15:11:39 +0900 Subject: [PATCH 6/6] test: cover AlgoliaIndexSettingsSyncer and the command's credential/happy-path branches --- .../Tests/AlgoliaIndexSettingsSyncerTest.php | 43 +++++++++++++++++++ .../Tests/AlgoliaSyncIndexSettingsTest.php | 41 ++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 src/app/Packages/Domains/WorldHeritage/Tests/AlgoliaIndexSettingsSyncerTest.php 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 index 12fea0d8..20c6a9c0 100644 --- a/src/app/Packages/Domains/WorldHeritage/Tests/AlgoliaSyncIndexSettingsTest.php +++ b/src/app/Packages/Domains/WorldHeritage/Tests/AlgoliaSyncIndexSettingsTest.php @@ -2,12 +2,20 @@ namespace App\Packages\Domains\WorldHeritage\Tests; +use Algolia\AlgoliaSearch\Api\SearchClient; use App\Console\Commands\AlgoliaSyncIndexSettings; use Illuminate\Support\Facades\Artisan; +use Mockery; use Tests\TestCase; class AlgoliaSyncIndexSettingsTest extends TestCase { + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + public function test_dry_run_prints_attributes_for_faceting_without_calling_algolia(): void { $exitCode = Artisan::call('algolia:sync-index-settings', ['--dry-run' => true]); @@ -20,6 +28,39 @@ public function test_dry_run_prints_attributes_for_faceting_without_calling_algo } } + 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