Reverse-geocode a general area label for logged searches
Each search now stores an `area_label` (district/town) reverse-geocoded from its coarsened ~1km lat/lng bucket via postcodes.io, surfaced in the Filament Searches admin as a sortable/searchable column plus an area filter. Geocoding is cached 30 days per bucket, queries a 2km radius so low-density buckets still match the default 100m miss, and fails gracefully to null. Adds `searches:backfill-areas` (scheduled hourly) to label existing rows and retry stragglers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,17 @@ uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->withHeaders(['X-Api-Key' => config('app.api_secret_key')]);
|
||||
|
||||
// Every search reverse-geocodes its lat/lng bucket to an area label. Fake the
|
||||
// postcodes.io reverse endpoint (query-string form) so tests never hit the
|
||||
// network; the path form (/postcodes/SW1A1AA) used for forward lookups is
|
||||
// matched by per-test stubs and is unaffected by this.
|
||||
Http::fake([
|
||||
'api.postcodes.io/postcodes?*' => Http::response([
|
||||
'status' => 200,
|
||||
'result' => [['admin_district' => 'Testshire']],
|
||||
]),
|
||||
]);
|
||||
});
|
||||
|
||||
function asPaidUserOnStations(string $tier = 'plus'): User
|
||||
@@ -114,6 +125,19 @@ it('logs a search record for each request', function () {
|
||||
]);
|
||||
});
|
||||
|
||||
it('stores the reverse-geocoded area label on the search record', function () {
|
||||
$station = Station::factory()->create(['lat' => 52.555064, 'lng' => -0.256119]);
|
||||
StationPriceCurrent::factory()->create(['station_id' => $station->node_id, 'fuel_type' => FuelType::B7Standard, 'price_pence' => 14500]);
|
||||
|
||||
$this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=b7_standard&radius=10');
|
||||
|
||||
$this->assertDatabaseHas('searches', [
|
||||
'lat_bucket' => '52.56',
|
||||
'lng_bucket' => '-0.26',
|
||||
'area_label' => 'Testshire',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns 422 when required params are missing', function () {
|
||||
$this->getJson('/api/stations?lat=52.5')
|
||||
->assertUnprocessable();
|
||||
|
||||
50
tests/Feature/Console/BackfillSearchAreasTest.php
Normal file
50
tests/Feature/Console/BackfillSearchAreasTest.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Search;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('fills the area label for searches missing one, once per bucket', function () {
|
||||
Http::fake([
|
||||
'*/postcodes?*' => Http::response([
|
||||
'status' => 200,
|
||||
'result' => [['admin_district' => 'Peterborough']],
|
||||
]),
|
||||
]);
|
||||
|
||||
// Two searches share a bucket; a third is in a different bucket.
|
||||
Search::factory()->count(2)->create(['lat_bucket' => 52.54, 'lng_bucket' => -0.21, 'area_label' => null]);
|
||||
Search::factory()->create(['lat_bucket' => 51.50, 'lng_bucket' => -0.14, 'area_label' => null]);
|
||||
|
||||
$this->artisan('searches:backfill-areas')->assertSuccessful();
|
||||
|
||||
expect(Search::whereNull('area_label')->count())->toBe(0)
|
||||
->and(Search::where('area_label', 'Peterborough')->count())->toBe(3);
|
||||
|
||||
// One reverse-geocode call per distinct bucket, not per row.
|
||||
Http::assertSentCount(2);
|
||||
});
|
||||
|
||||
it('leaves searches that already have an area label untouched', function () {
|
||||
Http::fake([
|
||||
'*/postcodes?*' => Http::response([
|
||||
'status' => 200,
|
||||
'result' => [['admin_district' => 'Peterborough']],
|
||||
]),
|
||||
]);
|
||||
|
||||
Search::factory()->create(['lat_bucket' => 52.54, 'lng_bucket' => -0.21, 'area_label' => 'Manchester']);
|
||||
|
||||
$this->artisan('searches:backfill-areas')->assertSuccessful();
|
||||
|
||||
expect(Search::where('area_label', 'Manchester')->count())->toBe(1);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('reports when there is nothing to backfill', function () {
|
||||
$this->artisan('searches:backfill-areas')
|
||||
->expectsOutputToContain('No searches need an area label.')
|
||||
->assertSuccessful();
|
||||
});
|
||||
Reference in New Issue
Block a user