Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions src/app/Console/Commands/AlgoliaSyncIndexSettings.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

namespace App\Console\Commands;

use Algolia\AlgoliaSearch\Api\SearchClient;
use App\Packages\Domains\WorldHeritage\Adapter\AlgoliaIndexSettingsSyncer;
use Illuminate\Console\Command;

class AlgoliaSyncIndexSettings extends Command
{
protected $signature = 'algolia:sync-index-settings {--dry-run}';

protected $description = 'Apply the attributesForFaceting declared in code to the Algolia index settings.';

/**
* Every attribute the search Adapter filters or optional-filters on must
* be declared here, or Algolia rejects the request with a facet error.
* Attributes not used for filtering by the Adapter (e.g. country_names_jp,
* region, study_regions) are kept as plain facets rather than
* filterOnly() for now: whether the frontend needs their facet counts
* has not been confirmed (see Domains/README.md).
*/
public const ATTRIBUTES_FOR_FACETING = [
'category',
'country',
'country_name_jp',
'country_names_jp',
'criteria',
'is_endangered',
'region',
'state_party_codes',
'study_region',
'study_regions',
];

public function handle(): int
{
$indexName = config('algolia.algolia_index');

// 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;
}

// 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;
}
}
16 changes: 15 additions & 1 deletion src/app/Packages/Domains/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,18 @@ AlgoliaAdapter
SearchResult (Ports)
↓
Dto
```
```

---

## 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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace App\Packages\Domains\WorldHeritage\Adapter;

use Algolia\AlgoliaSearch\Api\SearchClient;

class AlgoliaIndexSettingsSyncer
{
public function __construct(
private SearchClient $client,
) {}

public function syncAttributesForFaceting(string $indexName, array $attributesForFaceting): void
{
$res = $this->client->setSettings($indexName, [
'attributesForFaceting' => $attributesForFaceting,
]);

$taskId = $res['taskID'] ?? null;
if ($taskId !== null) {
$this->client->waitForTask($indexName, $taskId);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

namespace App\Packages\Domains\WorldHeritage\Tests;

use Algolia\AlgoliaSearch\Api\SearchClient;
use App\Packages\Domains\WorldHeritage\Adapter\AlgoliaIndexSettingsSyncer;
use Mockery;
use PHPUnit\Framework\Attributes\DoesNotPerformAssertions;
use PHPUnit\Framework\TestCase;

class AlgoliaIndexSettingsSyncerTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}

#[DoesNotPerformAssertions]
public function test_waits_for_the_indexing_task_when_algolia_returns_a_task_id(): void
{
$client = Mockery::mock(SearchClient::class);
$client->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']);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

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]);

$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);
}
}
}
7 changes: 7 additions & 0 deletions src/app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
});
}

/**
Expand Down
Loading