From c40a13cfac397cf24c64d25b08d16a932b36872c Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 6 Sep 2026 18:55:37 +0900 Subject: [PATCH 01/12] fix: keep keyword search intact when a country filter is also present (#579) --- .../AlgoliaWorldHeritageSearchAdapter.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/app/Packages/Domains/WorldHeritage/Adapter/AlgoliaWorldHeritageSearchAdapter.php b/src/app/Packages/Domains/WorldHeritage/Adapter/AlgoliaWorldHeritageSearchAdapter.php index f4aab0a..b265bad 100644 --- a/src/app/Packages/Domains/WorldHeritage/Adapter/AlgoliaWorldHeritageSearchAdapter.php +++ b/src/app/Packages/Domains/WorldHeritage/Adapter/AlgoliaWorldHeritageSearchAdapter.php @@ -32,28 +32,28 @@ public function search( /** * Country filtering strategy: - * - If ISO3 is explicitly provided, use strict ISO3 filtering and disable full-text search. + * - If ISO3 is explicitly provided, filter by state_party_codes:. * - Otherwise, attempt to treat the provided countryName as one of: * - English country name (e.g. "Japan" / "japan") * - Japanese name (e.g. "日本") * - ISO3 (e.g. "JPN") * * In that case we build an OR-filter across relevant fields. + * + * The country filter never blanks out the keyword: like every other filter + * (region/category/year/criteria/is_endangered), it narrows the keyword search + * via AND rather than replacing it. A keyword that happens to also be a country + * name (e.g. "Japan") already matches the same records via full-text search on + * the indexed country/country_name_jp/state_party_codes attributes, so keeping + * it is harmless; a keyword combined with an explicit country param (e.g. + * keyword="temple" & country_iso3=JPN) must narrow by both. */ $queryString = $query->keyword ?? ''; if ($this->hasValue($query->countryIso3)) { - // Requirement: ISO3 -> query='' and filter by state_party_codes: $filters[] = 'state_party_codes:' . $this->escapeToken($query->countryIso3); - $queryString = ''; } elseif ($this->hasValue($query->countryName)) { $filters[] = $this->buildCountryOrFilter($query->countryName); - /** - * IMPORTANT: - * Do not blank out query here. - * If the input was not a real country (e.g. "japan" typo / random word), - * we still want full-text search to work rather than returning "top hits". - */ } /** From 55f52ad17a4407b89eab855581568ae5a257ed69 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 6 Sep 2026 18:55:37 +0900 Subject: [PATCH 02/12] test: cover combined keyword and country filter in AlgoliaWorldHeritageSearchAdapterTest (#579) --- .../AlgoliaWorldHeritageSearchAdapterTest.php | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/src/app/Packages/Domains/WorldHeritage/Tests/AlgoliaWorldHeritageSearchAdapterTest.php b/src/app/Packages/Domains/WorldHeritage/Tests/AlgoliaWorldHeritageSearchAdapterTest.php index 9ce743b..f9ddd01 100644 --- a/src/app/Packages/Domains/WorldHeritage/Tests/AlgoliaWorldHeritageSearchAdapterTest.php +++ b/src/app/Packages/Domains/WorldHeritage/Tests/AlgoliaWorldHeritageSearchAdapterTest.php @@ -45,8 +45,9 @@ public function test_search_builds_algolia_params_with_filters_and_paging(): voi ); // Arrange: Algolia expects 0-based page indexing, so currentPage=2 becomes page=1. + // The keyword must stay intact even though a country filter is also present. $expectedParams = [ - 'query' => '', + 'query' => 'galapagos', 'page' => 1, 'hitsPerPage' => 30, 'filters' => @@ -121,4 +122,48 @@ public function test_search_by_country_name_without_iso3_does_not_filter_on_stat $this->assertSame([], $result->ids); } + + public function test_search_combines_keyword_with_country_iso3_filter_instead_of_dropping_keyword(): void + { + $indexName = 'world_heritage'; + $adapter = new AlgoliaWorldHeritageSearchAdapter($this->client, $indexName); + + $q = new AlgoliaSearchListQuery( + keyword: 'temple', + countryName: null, + countryIso3: 'JPN', + region: null, + category: null, + yearFrom: null, + yearTo: null, + criteria: [], + isEndangered: null, + currentPage: 1, + perPage: 30, + ); + + $expectedParams = [ + 'query' => 'temple', + 'page' => 0, + 'hitsPerPage' => 30, + 'filters' => 'state_party_codes:JPN', + 'attributesToRetrieve' => ['objectID', 'id'], + ]; + + $this->client + ->shouldReceive('searchSingleIndex') + ->once() + ->with($indexName, $expectedParams) + ->andReturn([ + 'hits' => [['id' => 913], ['id' => 1277]], + 'nbHits' => 2, + 'nbPages' => 1, + 'page' => 0, + 'hitsPerPage' => 30, + ]); + + $result = $adapter->search($q, currentPage: 1, perPage: 30); + + $this->assertSame([913, 1277], $result->ids); + } } From 7bb042f2080331e62ec5af8d48c2585ea52b801f Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sat, 24 Oct 2026 18:54:34 +0900 Subject: [PATCH 03/12] fix: move unresolved countryName filter from filters to optionalFilters The countryName branch (reached only when countryIso3 did not resolve) added its OR-filter to the mandatory $filters array, so an input that doesn't match a real country (e.g. "Jap") excluded every record instead of falling back to full-text search, even though the surrounding comment said the opposite was intended. Move it to $optionalFilters, which boosts matching records instead of excluding non-matching ones, and extend the empty-result guardrail to also treat a non-empty $optionalFilters as real search criteria. --- .../AlgoliaWorldHeritageSearchAdapter.php | 39 +++++++++++++------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/src/app/Packages/Domains/WorldHeritage/Adapter/AlgoliaWorldHeritageSearchAdapter.php b/src/app/Packages/Domains/WorldHeritage/Adapter/AlgoliaWorldHeritageSearchAdapter.php index b265bad..6053bba 100644 --- a/src/app/Packages/Domains/WorldHeritage/Adapter/AlgoliaWorldHeritageSearchAdapter.php +++ b/src/app/Packages/Domains/WorldHeritage/Adapter/AlgoliaWorldHeritageSearchAdapter.php @@ -30,30 +30,40 @@ public function search( */ $filters = []; + /** + * Optional filters: unlike $filters, these never exclude a record — + * they only boost the ranking of records that match. Used when a + * country has not been confirmed via ISO3, so an input that fails to + * resolve to a real country (e.g. "Jap") still falls back to plain + * full-text search instead of returning 0 results. + */ + $optionalFilters = []; + /** * Country filtering strategy: * - If ISO3 is explicitly provided, filter by state_party_codes:. + * This is a confirmed country, so it narrows results like any other filter. * - Otherwise, attempt to treat the provided countryName as one of: * - English country name (e.g. "Japan" / "japan") * - Japanese name (e.g. "日本") * - ISO3 (e.g. "JPN") * - * In that case we build an OR-filter across relevant fields. + * This is unconfirmed, so it goes into $optionalFilters: matching + * records rank higher, but a non-matching countryName does not + * reduce the full-text search results to zero. * - * The country filter never blanks out the keyword: like every other filter - * (region/category/year/criteria/is_endangered), it narrows the keyword search - * via AND rather than replacing it. A keyword that happens to also be a country - * name (e.g. "Japan") already matches the same records via full-text search on - * the indexed country/country_name_jp/state_party_codes attributes, so keeping - * it is harmless; a keyword combined with an explicit country param (e.g. - * keyword="temple" & country_iso3=JPN) must narrow by both. + * Either way, the country signal never blanks out the keyword: like + * every other filter (region/category/year/criteria/is_endangered), + * it narrows/boosts alongside the keyword search rather than + * replacing it. A keyword combined with an explicit country param + * (e.g. keyword="temple" & country_iso3=JPN) must narrow by both. */ $queryString = $query->keyword ?? ''; if ($this->hasValue($query->countryIso3)) { $filters[] = 'state_party_codes:' . $this->escapeToken($query->countryIso3); } elseif ($this->hasValue($query->countryName)) { - $filters[] = $this->buildCountryOrFilter($query->countryName); + $optionalFilters[] = $this->buildCountryOrFilter($query->countryName); } /** @@ -102,13 +112,14 @@ public function search( /** * Guardrail: - * Never execute Algolia with query='' AND no filters, + * Never execute Algolia with query='' AND no filters AND no optional filters, * otherwise you will get "top results" unrelated to the user input. */ $hasAnyFilter = $filters !== []; + $hasOptionalFilter = $optionalFilters !== []; $hasQueryText = $this->hasValue($queryString); - if (!$hasAnyFilter && !$hasQueryText) { + if (!$hasAnyFilter && !$hasOptionalFilter && !$hasQueryText) { // Prefer returning empty result rather than misleading "top hits". return new HeritageSearchResult( ids: [], @@ -127,6 +138,7 @@ public function search( 'page' => $firstPage, 'hitsPerPage' => $perPage, 'filters' => $hasAnyFilter ? implode(' AND ', $filters) : null, + 'optionalFilters' => $hasOptionalFilter ? $optionalFilters : null, /** * Only the id is ever read from the response (see below): * the full record is re-fetched from the DB via findByIdsPreserveOrder. @@ -161,11 +173,14 @@ public function search( } /** - * Build an OR-filter that can match different country input shapes: + * Build an OR-expression that can match different country input shapes: * - English: country (exact match) * - Japanese: country_name_jp (exact match) * - ISO3-like: state_party_codes: * + * Used as an entry in $optionalFilters (not $filters): a non-match + * does not exclude a record, it just skips the ranking boost. + * * Example output: * (country:"Japan" OR country_name_jp:"日本" OR state_party_codes:JPN) */ From 66311ae6916c8e39fb73274a2f5e437117681cf5 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sat, 24 Oct 2026 18:54:39 +0900 Subject: [PATCH 04/12] test: cover countryName optionalFilters behavior in AlgoliaWorldHeritageSearchAdapterTest - country-name-only search now expects optionalFilters, not filters - lowercase country name still builds the optional OR-filter - an unresolvable country term (e.g. a 3-letter non-country word) no longer short-circuits to an empty result via the adapter's own guardrail --- .../AlgoliaWorldHeritageSearchAdapterTest.php | 105 +++++++++++++++++- 1 file changed, 99 insertions(+), 6 deletions(-) diff --git a/src/app/Packages/Domains/WorldHeritage/Tests/AlgoliaWorldHeritageSearchAdapterTest.php b/src/app/Packages/Domains/WorldHeritage/Tests/AlgoliaWorldHeritageSearchAdapterTest.php index f9ddd01..5d500db 100644 --- a/src/app/Packages/Domains/WorldHeritage/Tests/AlgoliaWorldHeritageSearchAdapterTest.php +++ b/src/app/Packages/Domains/WorldHeritage/Tests/AlgoliaWorldHeritageSearchAdapterTest.php @@ -79,7 +79,7 @@ public function test_search_builds_algolia_params_with_filters_and_paging(): voi $this->assertSame(1, $result->lastPage); } - public function test_search_by_country_name_without_iso3_does_not_filter_on_state_party(): void + public function test_search_by_country_name_without_iso3_uses_optional_filter_not_filter(): void { $indexName = 'world_heritage'; $adapter = new AlgoliaWorldHeritageSearchAdapter($this->client, $indexName); @@ -102,7 +102,7 @@ public function test_search_by_country_name_without_iso3_does_not_filter_on_stat 'query' => '', 'page' => 0, 'hitsPerPage' => 30, - 'filters' => '(country:"Ecuador" OR country_name_jp:"Ecuador")', + 'optionalFilters' => ['(country:"Ecuador" OR country_name_jp:"Ecuador")'], 'attributesToRetrieve' => ['objectID', 'id'], ]; @@ -111,16 +111,109 @@ public function test_search_by_country_name_without_iso3_does_not_filter_on_stat ->once() ->with($indexName, $expectedParams) ->andReturn([ - 'hits' => [], - 'nbHits' => 0, - 'nbPages' => 0, + 'hits' => [['id' => 1]], + 'nbHits' => 1, + 'nbPages' => 1, + 'page' => 0, + 'hitsPerPage' => 30, + ]); + + $result = $adapter->search($q, currentPage: 1, perPage: 30); + + $this->assertSame([1], $result->ids); + } + + public function test_search_by_lowercase_country_name_boosts_ranking_via_optional_filter(): void + { + $indexName = 'world_heritage'; + $adapter = new AlgoliaWorldHeritageSearchAdapter($this->client, $indexName); + + $q = new AlgoliaSearchListQuery( + keyword: null, + countryName: 'japan', + countryIso3: null, + region: null, + category: null, + yearFrom: null, + yearTo: null, + criteria: [], + isEndangered: null, + currentPage: 1, + perPage: 30, + ); + + $expectedParams = [ + 'query' => '', + 'page' => 0, + 'hitsPerPage' => 30, + 'optionalFilters' => ['(country:"japan" OR country_name_jp:"japan")'], + 'attributesToRetrieve' => ['objectID', 'id'], + ]; + + $this->client + ->shouldReceive('searchSingleIndex') + ->once() + ->with($indexName, $expectedParams) + ->andReturn([ + 'hits' => [['id' => 42]], + 'nbHits' => 1, + 'nbPages' => 1, + 'page' => 0, + 'hitsPerPage' => 30, + ]); + + $result = $adapter->search($q, currentPage: 1, perPage: 30); + + $this->assertSame([42], $result->ids); + } + + public function test_search_by_country_name_that_does_not_resolve_still_runs_full_text_search(): void + { + $indexName = 'world_heritage'; + $adapter = new AlgoliaWorldHeritageSearchAdapter($this->client, $indexName); + + // "zzz" does not match any real country (and isn't a plausible ISO3 + // either), but should not force a 0-result short-circuit the way a + // mandatory $filters entry would. + $q = new AlgoliaSearchListQuery( + keyword: null, + countryName: 'zzz', + countryIso3: null, + region: null, + category: null, + yearFrom: null, + yearTo: null, + criteria: [], + isEndangered: null, + currentPage: 1, + perPage: 30, + ); + + $expectedParams = [ + 'query' => '', + 'page' => 0, + 'hitsPerPage' => 30, + 'optionalFilters' => ['(country:"zzz" OR country_name_jp:"zzz" OR state_party_codes:ZZZ)'], + 'attributesToRetrieve' => ['objectID', 'id'], + ]; + + $this->client + ->shouldReceive('searchSingleIndex') + ->once() + ->with($indexName, $expectedParams) + ->andReturn([ + 'hits' => [['id' => 7], ['id' => 8]], + 'nbHits' => 2, + 'nbPages' => 1, 'page' => 0, 'hitsPerPage' => 30, ]); $result = $adapter->search($q, currentPage: 1, perPage: 30); - $this->assertSame([], $result->ids); + // The point of optionalFilters: a non-matching country term does not + // force an empty result via the adapter's own guardrail. + $this->assertSame([7, 8], $result->ids); } public function test_search_combines_keyword_with_country_iso3_filter_instead_of_dropping_keyword(): void From 2536218cb023a2555898500e9dfb5dd2e541f13d Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sat, 24 Oct 2026 23:29:03 +0900 Subject: [PATCH 05/12] 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 0000000..9354cef --- /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 541f53c..daf5047 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 06/12] 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 0000000..12fea0d --- /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 07/12] 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 9354cef..6528f20 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 08/12] 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 0000000..0aabf35 --- /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 a12b9fe..369163c 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 09/12] 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 6528f20..821da2f 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 10/12] 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 0000000..b9ce2e4 --- /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 12fea0d..20c6a9c 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 From 57d2d86f44f944a9dd4bb2cef2de36a0bed1eada Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 25 Oct 2026 15:40:24 +0900 Subject: [PATCH 11/12] fix: log and skip non-numeric Algolia hit ids instead of silently dropping them --- .../AlgoliaWorldHeritageSearchAdapter.php | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/app/Packages/Domains/WorldHeritage/Adapter/AlgoliaWorldHeritageSearchAdapter.php b/src/app/Packages/Domains/WorldHeritage/Adapter/AlgoliaWorldHeritageSearchAdapter.php index 6053bba..388d44e 100644 --- a/src/app/Packages/Domains/WorldHeritage/Adapter/AlgoliaWorldHeritageSearchAdapter.php +++ b/src/app/Packages/Domains/WorldHeritage/Adapter/AlgoliaWorldHeritageSearchAdapter.php @@ -6,6 +6,7 @@ use App\Packages\Domains\WorldHeritage\Ports\Dto\HeritageSearchResult; use App\Packages\Domains\WorldHeritage\Ports\WorldHeritageSearchPort; use App\Packages\Features\QueryUseCases\ListQuery\AlgoliaSearchListQuery; +use Illuminate\Support\Facades\Log; class AlgoliaWorldHeritageSearchAdapter implements WorldHeritageSearchPort { @@ -152,11 +153,26 @@ public function search( $hits = $response['hits'] ?? []; - $ids = array_values(array_filter(array_map(static function (array $h) { - return isset($h['id']) - ? (int) $h['id'] - : (isset($h['objectID']) ? (int) $h['objectID'] : null); - }, $hits))); + $ids = []; + foreach ($hits as $hit) { + $rawId = $hit['id'] ?? $hit['objectID'] ?? null; + + if ($rawId === null) { + continue; + } + + if (!is_numeric($rawId)) { + // Don't silently coerce a non-numeric id to 0 and drop it via + // array_filter: that would hide an Algolia/DB data inconsistency. + Log::warning('Algolia search hit has a non-numeric id', [ + 'id' => $hit['id'] ?? null, + 'objectID' => $hit['objectID'] ?? null, + ]); + continue; + } + + $ids[] = (int) $rawId; + } $nbHits = (int) ($response['nbHits'] ?? 0); $nbPages = (int) ($response['nbPages'] ?? 0); From 132befc7b4aa16ac5e524ad4bbfa0b5da3b81551 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 25 Oct 2026 15:40:27 +0900 Subject: [PATCH 12/12] test: cover non-numeric id handling in AlgoliaWorldHeritageSearchAdapterTest --- .../AlgoliaWorldHeritageSearchAdapterTest.php | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/src/app/Packages/Domains/WorldHeritage/Tests/AlgoliaWorldHeritageSearchAdapterTest.php b/src/app/Packages/Domains/WorldHeritage/Tests/AlgoliaWorldHeritageSearchAdapterTest.php index 5d500db..888c1c4 100644 --- a/src/app/Packages/Domains/WorldHeritage/Tests/AlgoliaWorldHeritageSearchAdapterTest.php +++ b/src/app/Packages/Domains/WorldHeritage/Tests/AlgoliaWorldHeritageSearchAdapterTest.php @@ -6,8 +6,9 @@ use App\Enums\StudyRegion; use App\Packages\Domains\WorldHeritage\Adapter\AlgoliaWorldHeritageSearchAdapter; use App\Packages\Features\QueryUseCases\ListQuery\AlgoliaSearchListQuery; +use Illuminate\Support\Facades\Log; use Mockery; -use PHPUnit\Framework\TestCase; +use Tests\TestCase; class AlgoliaWorldHeritageSearchAdapterTest extends TestCase { @@ -216,6 +217,53 @@ public function test_search_by_country_name_that_does_not_resolve_still_runs_ful $this->assertSame([7, 8], $result->ids); } + public function test_search_logs_and_skips_a_hit_with_a_non_numeric_id_instead_of_silently_dropping_it(): void + { + $indexName = 'world_heritage'; + $adapter = new AlgoliaWorldHeritageSearchAdapter($this->client, $indexName); + + $q = new AlgoliaSearchListQuery( + keyword: 'temple', + countryName: null, + countryIso3: null, + region: null, + category: null, + yearFrom: null, + yearTo: null, + criteria: [], + isEndangered: null, + currentPage: 1, + perPage: 30, + ); + + $this->client + ->shouldReceive('searchSingleIndex') + ->once() + ->andReturn([ + 'hits' => [ + ['id' => 10], + ['id' => 'not-a-number', 'objectID' => 'broken-1'], + ], + 'nbHits' => 2, + 'nbPages' => 1, + 'page' => 0, + 'hitsPerPage' => 30, + ]); + + Log::shouldReceive('warning') + ->once() + ->with('Algolia search hit has a non-numeric id', [ + 'id' => 'not-a-number', + 'objectID' => 'broken-1', + ]); + + $result = $adapter->search($q, currentPage: 1, perPage: 30); + + // The valid hit survives; the broken one is logged and skipped rather + // than silently coerced to 0 and dropped via array_filter. + $this->assertSame([10], $result->ids); + } + public function test_search_combines_keyword_with_country_iso3_filter_instead_of_dropping_keyword(): void { $indexName = 'world_heritage';