Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf373a85f9 | ||
|
|
ea22387c9d | ||
|
|
040b2f627e |
54
app/Console/Commands/BackfillSearchAreas.php
Normal file
54
app/Console/Commands/BackfillSearchAreas.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Search;
|
||||||
|
use App\Services\PostcodeService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class BackfillSearchAreas extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'searches:backfill-areas {--limit=0 : Max distinct areas to resolve this run (0 = no limit)}';
|
||||||
|
|
||||||
|
protected $description = 'Reverse-geocode searches that have no area_label yet';
|
||||||
|
|
||||||
|
public function handle(PostcodeService $postcodes): int
|
||||||
|
{
|
||||||
|
$limit = (int) $this->option('limit');
|
||||||
|
|
||||||
|
$buckets = Search::query()
|
||||||
|
->whereNull('area_label')
|
||||||
|
->select('lat_bucket', 'lng_bucket')
|
||||||
|
->distinct()
|
||||||
|
->when($limit > 0, fn ($query) => $query->limit($limit))
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($buckets->isEmpty()) {
|
||||||
|
$this->info('No searches need an area label.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolved = 0;
|
||||||
|
$rowsUpdated = 0;
|
||||||
|
|
||||||
|
foreach ($buckets as $bucket) {
|
||||||
|
$label = $postcodes->reverseResolve((float) $bucket->lat_bucket, (float) $bucket->lng_bucket);
|
||||||
|
|
||||||
|
if ($label === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolved++;
|
||||||
|
$rowsUpdated += Search::query()
|
||||||
|
->whereNull('area_label')
|
||||||
|
->where('lat_bucket', $bucket->lat_bucket)
|
||||||
|
->where('lng_bucket', $bucket->lng_bucket)
|
||||||
|
->update(['area_label' => $label]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Resolved {$resolved} of {$buckets->count()} areas, updated {$rowsUpdated} search rows.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,11 @@ class SearchResource extends Resource
|
|||||||
->label('Searched At')
|
->label('Searched At')
|
||||||
->dateTime('d M Y H:i')
|
->dateTime('d M Y H:i')
|
||||||
->sortable(),
|
->sortable(),
|
||||||
|
TextColumn::make('area_label')
|
||||||
|
->label('Area')
|
||||||
|
->placeholder('Unknown')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
TextColumn::make('fuel_type')
|
TextColumn::make('fuel_type')
|
||||||
->label('Fuel Type')
|
->label('Fuel Type')
|
||||||
->badge(),
|
->badge(),
|
||||||
@@ -67,6 +72,15 @@ class SearchResource extends Resource
|
|||||||
'B10' => 'B10',
|
'B10' => 'B10',
|
||||||
'HVO' => 'HVO',
|
'HVO' => 'HVO',
|
||||||
]),
|
]),
|
||||||
|
SelectFilter::make('area_label')
|
||||||
|
->label('Area')
|
||||||
|
->searchable()
|
||||||
|
->options(fn (): array => Search::query()
|
||||||
|
->whereNotNull('area_label')
|
||||||
|
->distinct()
|
||||||
|
->orderBy('area_label')
|
||||||
|
->pluck('area_label', 'area_label')
|
||||||
|
->all()),
|
||||||
])
|
])
|
||||||
->recordActions([])
|
->recordActions([])
|
||||||
->toolbarActions([]);
|
->toolbarActions([]);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
#[Fillable(['lat_bucket', 'lng_bucket', 'fuel_type', 'results_count', 'lowest_pence', 'highest_pence', 'avg_pence', 'searched_at', 'ip_hash'])]
|
#[Fillable(['lat_bucket', 'lng_bucket', 'area_label', 'fuel_type', 'results_count', 'lowest_pence', 'highest_pence', 'avg_pence', 'searched_at', 'ip_hash'])]
|
||||||
class Search extends Model
|
class Search extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<SearchFactory> */
|
/** @use HasFactory<SearchFactory> */
|
||||||
|
|||||||
@@ -52,6 +52,77 @@ class PostcodeService
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse-geocode coordinates to a general UK area label (e.g. "Peterborough").
|
||||||
|
*
|
||||||
|
* Coordinates are bucketed to ~1km (2dp) before lookup so the cache is shared
|
||||||
|
* across nearby searches and nothing more precise than the stored bucket is
|
||||||
|
* ever queried. Returns null if the area cannot be determined.
|
||||||
|
*/
|
||||||
|
public function reverseResolve(float $lat, float $lng): ?string
|
||||||
|
{
|
||||||
|
$latBucket = round($lat, 2);
|
||||||
|
$lngBucket = round($lng, 2);
|
||||||
|
$cacheKey = "revgeo:{$latBucket},{$lngBucket}";
|
||||||
|
|
||||||
|
$cached = Cache::get($cacheKey);
|
||||||
|
|
||||||
|
if (is_string($cached)) {
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = $this->lookupArea($latBucket, $lngBucket);
|
||||||
|
|
||||||
|
if ($label !== null) {
|
||||||
|
Cache::put($cacheKey, $label, self::CACHE_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $label;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function lookupArea(float $lat, float $lng): ?string
|
||||||
|
{
|
||||||
|
$url = self::BASE_URL.'/postcodes';
|
||||||
|
// radius=2000 (postcodes.io max): we query the ~1km bucket centroid, which
|
||||||
|
// can sit up to ~780m from any real point in the bucket. The default 100m
|
||||||
|
// radius misses in low-density areas, so widen it to guarantee a hit.
|
||||||
|
$logUrl = $url.'?lon='.$lng.'&lat='.$lat.'&radius=2000&limit=1';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->apiLogger->send('postcodes_io', 'GET', $logUrl, fn () => Http::timeout(5)
|
||||||
|
->get($url, ['lon' => $lng, 'lat' => $lat, 'radius' => 2000, 'limit' => 1]));
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$results = $response->json('result');
|
||||||
|
|
||||||
|
if (! is_array($results) || ! isset($results[0]) || ! is_array($results[0])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer the most human "town/district" field, falling back to broader areas.
|
||||||
|
foreach (['admin_district', 'parish', 'admin_ward', 'region', 'country'] as $field) {
|
||||||
|
$value = $results[0][$field] ?? null;
|
||||||
|
|
||||||
|
if (is_string($value) && $value !== '') {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::error('PostcodeService: reverse geocode failed', [
|
||||||
|
'lat' => $lat,
|
||||||
|
'lng' => $lng,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function normalisePostcode(string $value): string
|
private function normalisePostcode(string $value): string
|
||||||
{
|
{
|
||||||
return strtoupper(preg_replace('/\s+/', '', $value));
|
return strtoupper(preg_replace('/\s+/', '', $value));
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Services\Forecasting\LocalSnapshotService;
|
|||||||
use App\Services\Forecasting\WeeklyForecastService;
|
use App\Services\Forecasting\WeeklyForecastService;
|
||||||
use App\Services\HaversineQuery;
|
use App\Services\HaversineQuery;
|
||||||
use App\Services\PlanFeatures;
|
use App\Services\PlanFeatures;
|
||||||
|
use App\Services\PostcodeService;
|
||||||
use Illuminate\Database\Query\JoinClause;
|
use Illuminate\Database\Query\JoinClause;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
@@ -20,6 +21,7 @@ final class StationSearchService
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly WeeklyForecastService $weeklyForecast,
|
private readonly WeeklyForecastService $weeklyForecast,
|
||||||
private readonly LocalSnapshotService $localSnapshot,
|
private readonly LocalSnapshotService $localSnapshot,
|
||||||
|
private readonly PostcodeService $postcodeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function search(SearchCriteria $criteria, ?User $user, ?string $ipHash): SearchResult
|
public function search(SearchCriteria $criteria, ?User $user, ?string $ipHash): SearchResult
|
||||||
@@ -118,6 +120,7 @@ final class StationSearchService
|
|||||||
Search::create([
|
Search::create([
|
||||||
'lat_bucket' => round($criteria->lat, 2),
|
'lat_bucket' => round($criteria->lat, 2),
|
||||||
'lng_bucket' => round($criteria->lng, 2),
|
'lng_bucket' => round($criteria->lng, 2),
|
||||||
|
'area_label' => $this->postcodeService->reverseResolve($criteria->lat, $criteria->lng),
|
||||||
'fuel_type' => $criteria->fuelType->value,
|
'fuel_type' => $criteria->fuelType->value,
|
||||||
'results_count' => $resultsCount,
|
'results_count' => $resultsCount,
|
||||||
'lowest_pence' => $prices->min(),
|
'lowest_pence' => $prices->min(),
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('searches', function (Blueprint $table): void {
|
||||||
|
$table->string('area_label', 100)
|
||||||
|
->nullable()
|
||||||
|
->after('lng_bucket')
|
||||||
|
->comment('General UK area (e.g. district/town) reverse-geocoded from the lat/lng bucket');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('searches', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('area_label');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
72
deploy.sh
Executable file
72
deploy.sh
Executable file
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# FuelAlert deploy script — run on the VPS from the project root:
|
||||||
|
#
|
||||||
|
# ./deploy.sh # deploy the latest main
|
||||||
|
# ./deploy.sh v0.1.3 # deploy a specific tag
|
||||||
|
#
|
||||||
|
# It puts the site in maintenance mode, updates the code, runs migrations and
|
||||||
|
# cache rebuilds, restarts the queue, then brings the site back up. composer
|
||||||
|
# install and npm build only run when their inputs actually changed, so most
|
||||||
|
# deploys skip them. If any step fails the script aborts and the site stays in
|
||||||
|
# maintenance mode on purpose — fix the issue, then re-run.
|
||||||
|
#
|
||||||
|
# See docs/ops/deployment.md for first-time setup and troubleshooting.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
REF="${1:-main}"
|
||||||
|
echo "==> Deploying ref: ${REF}"
|
||||||
|
|
||||||
|
# Remember the current commit so we can see what changed after checkout.
|
||||||
|
BEFORE="$(git rev-parse HEAD)"
|
||||||
|
|
||||||
|
echo "==> Maintenance mode on"
|
||||||
|
php artisan down --retry=15
|
||||||
|
|
||||||
|
git fetch --tags --prune origin
|
||||||
|
git checkout "${REF}"
|
||||||
|
|
||||||
|
# Fast-forward to the remote only when on a branch (a tag leaves a detached HEAD).
|
||||||
|
if git symbolic-ref -q HEAD >/dev/null; then
|
||||||
|
git pull --ff-only
|
||||||
|
fi
|
||||||
|
|
||||||
|
AFTER="$(git rev-parse HEAD)"
|
||||||
|
CHANGED="$(git diff --name-only "${BEFORE}" "${AFTER}" || true)"
|
||||||
|
|
||||||
|
# Reinstall PHP deps only if the lockfile moved.
|
||||||
|
if grep -q '^composer\.lock$' <<<"${CHANGED}"; then
|
||||||
|
echo "==> composer.lock changed — installing PHP deps"
|
||||||
|
composer install --no-dev --optimize-autoloader
|
||||||
|
else
|
||||||
|
echo "==> composer.lock unchanged — skipping composer install"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Rebuild the Vue SPA only if frontend sources or the JS lockfile moved.
|
||||||
|
if grep -qE '^(package(-lock)?\.json|vite\.config\.|resources/(js|css)/)' <<<"${CHANGED}"; then
|
||||||
|
echo "==> Frontend changed — rebuilding SPA"
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
else
|
||||||
|
echo "==> No frontend changes — skipping npm build"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Running migrations"
|
||||||
|
php artisan migrate --force
|
||||||
|
|
||||||
|
echo "==> Rebuilding caches"
|
||||||
|
php artisan config:cache
|
||||||
|
php artisan route:cache
|
||||||
|
php artisan view:cache
|
||||||
|
php artisan event:cache
|
||||||
|
|
||||||
|
echo "==> Restarting queue workers"
|
||||||
|
php artisan queue:restart
|
||||||
|
|
||||||
|
echo "==> Maintenance mode off"
|
||||||
|
php artisan up
|
||||||
|
|
||||||
|
echo "==> Deploy complete"
|
||||||
|
php artisan about
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Ovidiu Ungureanu is registered with the UK Information Commissioner's Office (ICO) as a
|
Ovidiu Ungureanu is registered with the UK Information Commissioner's Office (ICO) as a
|
||||||
data controller. <strong>ICO registration reference: 00014395133.</strong>
|
data controller. <strong>ICO registration reference: ZC171362.</strong>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
If you have any questions about this policy or how we handle your personal data, contact us at
|
If you have any questions about this policy or how we handle your personal data, contact us at
|
||||||
@@ -276,7 +276,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-zinc-600">
|
<p class="text-sm text-zinc-600">
|
||||||
Data controller: Ovidiu Ungureanu trading as FuelAlert, Peterborough, United Kingdom.
|
Data controller: Ovidiu Ungureanu trading as FuelAlert, Peterborough, United Kingdom.
|
||||||
ICO registration reference: 00014395133.
|
ICO registration reference: ZC171362.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</x-layouts.legal>
|
</x-layouts.legal>
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
FuelAlert is a trading name of <strong>Ovidiu Ungureanu</strong>, a sole trader based in
|
FuelAlert is a trading name of <strong>Ovidiu Ungureanu</strong>, a sole trader based in
|
||||||
Peterborough, United Kingdom ("we", "us", "our"). These terms form a legally binding
|
Peterborough, United Kingdom ("we", "us", "our"). These terms form a legally binding
|
||||||
contract between you and Ovidiu Ungureanu trading as FuelAlert.
|
contract between you and Ovidiu Ungureanu trading as FuelAlert.
|
||||||
ICO registration reference: 00014395133.
|
ICO registration reference: ZC171362.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
By creating an account or using the service, you confirm that you have read, understood
|
By creating an account or using the service, you confirm that you have read, understood
|
||||||
@@ -242,7 +242,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-zinc-600">
|
<p class="text-sm text-zinc-600">
|
||||||
Ovidiu Ungureanu trading as FuelAlert, Peterborough, United Kingdom.
|
Ovidiu Ungureanu trading as FuelAlert, Peterborough, United Kingdom.
|
||||||
ICO registration reference: 00014395133.
|
ICO registration reference: ZC171362.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</x-layouts.legal>
|
</x-layouts.legal>
|
||||||
@@ -77,6 +77,16 @@ Schedule::command('fuel:archive')
|
|||||||
->onOneServer()
|
->onOneServer()
|
||||||
->runInBackground();
|
->runInBackground();
|
||||||
|
|
||||||
|
// Retry area labels that failed to reverse-geocode at search time (transient
|
||||||
|
// postcodes.io blip, or a genuinely remote point). Searches normally get their
|
||||||
|
// area_label inline; this just mops up stragglers. Cached per bucket, so it
|
||||||
|
// only calls the API for buckets it hasn't resolved yet.
|
||||||
|
Schedule::command('searches:backfill-areas')
|
||||||
|
->hourly()
|
||||||
|
->withoutOverlapping()
|
||||||
|
->onOneServer()
|
||||||
|
->runInBackground();
|
||||||
|
|
||||||
// Scheduled WhatsApp updates — morning and evening
|
// Scheduled WhatsApp updates — morning and evening
|
||||||
Schedule::job(new SendScheduledWhatsAppJob('morning'))->dailyAt('07:30')->onOneServer();
|
Schedule::job(new SendScheduledWhatsAppJob('morning'))->dailyAt('07:30')->onOneServer();
|
||||||
Schedule::job(new SendScheduledWhatsAppJob('evening'))->dailyAt('18:00')->onOneServer();
|
Schedule::job(new SendScheduledWhatsAppJob('evening'))->dailyAt('18:00')->onOneServer();
|
||||||
|
|||||||
@@ -12,6 +12,17 @@ uses(RefreshDatabase::class);
|
|||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
$this->withHeaders(['X-Api-Key' => config('app.api_secret_key')]);
|
$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
|
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 () {
|
it('returns 422 when required params are missing', function () {
|
||||||
$this->getJson('/api/stations?lat=52.5')
|
$this->getJson('/api/stations?lat=52.5')
|
||||||
->assertUnprocessable();
|
->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();
|
||||||
|
});
|
||||||
@@ -279,3 +279,76 @@ it('persists an outcode resolved via HTTP fallback', function (): void {
|
|||||||
->and((float) $row->lat)->toBe(52.536397)
|
->and((float) $row->lat)->toBe(52.536397)
|
||||||
->and((float) $row->lng)->toBe(-0.210181);
|
->and((float) $row->lng)->toBe(-0.210181);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Reverse geocoding (area label) ---
|
||||||
|
|
||||||
|
it('reverse-geocodes coordinates to the admin district', function (): void {
|
||||||
|
Http::fake([
|
||||||
|
'*/postcodes?*' => Http::response([
|
||||||
|
'status' => 200,
|
||||||
|
'result' => [
|
||||||
|
['admin_district' => 'Peterborough', 'region' => 'East of England'],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($this->service->reverseResolve(52.5364, -0.2102))->toBe('Peterborough');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a broader area when admin district is missing', function (): void {
|
||||||
|
Http::fake([
|
||||||
|
'*/postcodes?*' => Http::response([
|
||||||
|
'status' => 200,
|
||||||
|
'result' => [
|
||||||
|
['admin_district' => null, 'region' => 'Scotland'],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($this->service->reverseResolve(57.4, -4.2))->toBe('Scotland');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when reverse geocoding finds no area', function (): void {
|
||||||
|
Http::fake([
|
||||||
|
'*/postcodes?*' => Http::response(['status' => 200, 'result' => null]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($this->service->reverseResolve(0.0, 0.0))->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the reverse geocode request fails', function (): void {
|
||||||
|
Http::fake([
|
||||||
|
'*/postcodes?*' => Http::response([], 500),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($this->service->reverseResolve(52.5364, -0.2102))->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caches the reverse-geocoded area per bucket', function (): void {
|
||||||
|
Http::fake([
|
||||||
|
'*/postcodes?*' => Http::response([
|
||||||
|
'status' => 200,
|
||||||
|
'result' => [['admin_district' => 'Peterborough']],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Two coordinates inside the same ~1km (2dp) bucket → one HTTP call.
|
||||||
|
$this->service->reverseResolve(52.5364, -0.2102);
|
||||||
|
$this->service->reverseResolve(52.5359, -0.2148);
|
||||||
|
|
||||||
|
Http::assertSentCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queries postcodes.io with a wide radius so low-density buckets still match', function (): void {
|
||||||
|
Http::fake([
|
||||||
|
'*/postcodes?*' => Http::response([
|
||||||
|
'status' => 200,
|
||||||
|
'result' => [['admin_district' => 'Peterborough']],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->service->reverseResolve(52.54, -0.25);
|
||||||
|
|
||||||
|
// The default 100m radius misses the bucket centroid in rural areas; we send 2000m.
|
||||||
|
Http::assertSent(fn ($request) => str_contains($request->url(), 'radius=2000'));
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user