Merge branch 'feat/self-hosted-postcodes'
Self-hosted UK postcode lookup: ONS Postcode Directory loaded into local postcodes/outcodes tables; postcodes.io retained as fallback for place names and unknown postcodes, with successful fallback results persisted back to the local tables.
This commit is contained in:
122
app/Console/Commands/ImportPostcodes.php
Normal file
122
app/Console/Commands/ImportPostcodes.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
#[Signature('postcodes:import {--file= : Path to ONSPD CSV file}')]
|
||||
#[Description('Import UK postcodes (ONSPD) into the local postcodes and outcodes tables')]
|
||||
final class ImportPostcodes extends Command
|
||||
{
|
||||
private const int CHUNK_SIZE = 1000;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$file = $this->option('file');
|
||||
|
||||
if ($file === null || ! is_readable($file)) {
|
||||
$this->error('--file is required and must be a readable path to an ONSPD CSV.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$handle = fopen($file, 'r');
|
||||
|
||||
if ($handle === false) {
|
||||
$this->error("Unable to open {$file}.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$header = fgetcsv($handle);
|
||||
|
||||
if ($header === false) {
|
||||
$this->error('CSV is empty.');
|
||||
fclose($handle);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$headerCounts = array_count_values(array_map('strtolower', $header));
|
||||
|
||||
foreach (['pcd', 'lat', 'long'] as $required) {
|
||||
if (($headerCounts[$required] ?? 0) > 1) {
|
||||
$this->error("Column '{$required}' appears more than once — refusing to import.");
|
||||
fclose($handle);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
$columns = array_change_key_case(array_flip($header), CASE_LOWER);
|
||||
|
||||
foreach (['pcd', 'lat', 'long'] as $required) {
|
||||
if (! isset($columns[$required])) {
|
||||
$this->error("Missing required column '{$required}'.");
|
||||
fclose($handle);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
$hasDoterm = isset($columns['doterm']);
|
||||
|
||||
DB::table('postcodes')->truncate();
|
||||
DB::table('outcodes')->truncate();
|
||||
|
||||
$buffer = [];
|
||||
$imported = 0;
|
||||
|
||||
while (($row = fgetcsv($handle)) !== false) {
|
||||
if ($hasDoterm && trim((string) ($row[$columns['doterm']] ?? '')) !== '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lat = trim((string) ($row[$columns['lat']] ?? ''));
|
||||
$lng = trim((string) ($row[$columns['long']] ?? ''));
|
||||
|
||||
if ($lat === '' || $lng === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pcd = strtoupper(preg_replace('/\s+/', '', (string) $row[$columns['pcd']]));
|
||||
|
||||
if ($pcd === '' || strlen($pcd) < 5) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$buffer[] = [
|
||||
'postcode' => $pcd,
|
||||
'outcode' => substr($pcd, 0, strlen($pcd) - 3),
|
||||
'lat' => (float) $lat,
|
||||
'lng' => (float) $lng,
|
||||
];
|
||||
|
||||
if (count($buffer) >= self::CHUNK_SIZE) {
|
||||
DB::table('postcodes')->insert($buffer);
|
||||
$imported += count($buffer);
|
||||
$buffer = [];
|
||||
}
|
||||
}
|
||||
|
||||
if ($buffer !== []) {
|
||||
DB::table('postcodes')->insert($buffer);
|
||||
$imported += count($buffer);
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
DB::statement(
|
||||
'INSERT INTO outcodes (outcode, lat, lng)
|
||||
SELECT outcode, AVG(lat), AVG(lng) FROM postcodes GROUP BY outcode'
|
||||
);
|
||||
|
||||
$this->info("Imported {$imported} postcodes.");
|
||||
$this->info('Derived '.DB::table('outcodes')->count().' outcode centroids.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
26
app/Models/Outcode.php
Normal file
26
app/Models/Outcode.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['outcode', 'lat', 'lng'])]
|
||||
class Outcode extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $primaryKey = 'outcode';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'lat' => 'float',
|
||||
'lng' => 'float',
|
||||
];
|
||||
}
|
||||
}
|
||||
26
app/Models/Postcode.php
Normal file
26
app/Models/Postcode.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['postcode', 'outcode', 'lat', 'lng'])]
|
||||
class Postcode extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $primaryKey = 'postcode';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'lat' => 'float',
|
||||
'lng' => 'float',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Outcode;
|
||||
use App\Models\Postcode;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -24,7 +26,16 @@ class PostcodeService
|
||||
public function resolve(string $query): ?LocationResult
|
||||
{
|
||||
$query = trim($query);
|
||||
$cacheKey = 'postcode:'.strtolower(preg_replace('/\s+/', '', $query));
|
||||
|
||||
if ($this->isFullPostcode($query)) {
|
||||
return $this->lookupLocalPostcode($query) ?? $this->lookupPostcode($query);
|
||||
}
|
||||
|
||||
if ($this->isOutcode($query)) {
|
||||
return $this->lookupLocalOutcode($query) ?? $this->lookupOutcode($query);
|
||||
}
|
||||
|
||||
$cacheKey = 'place:'.strtolower(preg_replace('/\s+/', '', $query));
|
||||
|
||||
$cached = Cache::get($cacheKey);
|
||||
|
||||
@@ -32,11 +43,7 @@ class PostcodeService
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$result = match (true) {
|
||||
$this->isFullPostcode($query) => $this->lookupPostcode($query),
|
||||
$this->isOutcode($query) => $this->lookupOutcode($query),
|
||||
default => $this->lookupPlace($query),
|
||||
};
|
||||
$result = $this->lookupPlace($query);
|
||||
|
||||
if ($result !== null) {
|
||||
Cache::put($cacheKey, $result, self::CACHE_TTL);
|
||||
@@ -45,6 +52,11 @@ class PostcodeService
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function normalisePostcode(string $value): string
|
||||
{
|
||||
return strtoupper(preg_replace('/\s+/', '', $value));
|
||||
}
|
||||
|
||||
private function isFullPostcode(string $query): bool
|
||||
{
|
||||
return (bool) preg_match('/^[A-Z]{1,2}[0-9][0-9A-Z]?\s*[0-9][A-Z]{2}$/i', $query);
|
||||
@@ -55,9 +67,55 @@ class PostcodeService
|
||||
return (bool) preg_match('/^[A-Z]{1,2}[0-9][0-9A-Z]?$/i', $query);
|
||||
}
|
||||
|
||||
private function lookupLocalPostcode(string $postcode): ?LocationResult
|
||||
{
|
||||
$normalised = $this->normalisePostcode($postcode);
|
||||
|
||||
$row = Postcode::find($normalised);
|
||||
|
||||
if ($row === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new LocationResult(
|
||||
query: $postcode,
|
||||
displayName: $this->formatPostcode($normalised),
|
||||
lat: $row->lat,
|
||||
lng: $row->lng,
|
||||
);
|
||||
}
|
||||
|
||||
private function lookupLocalOutcode(string $outcode): ?LocationResult
|
||||
{
|
||||
$normalised = strtoupper(trim($outcode));
|
||||
|
||||
$row = Outcode::find($normalised);
|
||||
|
||||
if ($row === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new LocationResult(
|
||||
query: $outcode,
|
||||
displayName: $normalised,
|
||||
lat: $row->lat,
|
||||
lng: $row->lng,
|
||||
);
|
||||
}
|
||||
|
||||
private function formatPostcode(string $normalised): string
|
||||
{
|
||||
// Insert the single space before the last 3 chars ("SW1A1AA" -> "SW1A 1AA").
|
||||
if (strlen($normalised) < 5) {
|
||||
return $normalised;
|
||||
}
|
||||
|
||||
return substr($normalised, 0, -3).' '.substr($normalised, -3);
|
||||
}
|
||||
|
||||
private function lookupPostcode(string $postcode): ?LocationResult
|
||||
{
|
||||
$normalised = strtoupper(preg_replace('/\s+/', '', $postcode));
|
||||
$normalised = $this->normalisePostcode($postcode);
|
||||
$url = self::BASE_URL.'/postcodes/'.$normalised;
|
||||
|
||||
try {
|
||||
@@ -69,12 +127,34 @@ class PostcodeService
|
||||
|
||||
$data = $response->json('result');
|
||||
|
||||
return new LocationResult(
|
||||
if (! is_array($data) || ! isset($data['postcode'], $data['latitude'], $data['longitude'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$result = new LocationResult(
|
||||
query: $postcode,
|
||||
displayName: $data['postcode'],
|
||||
lat: $data['latitude'],
|
||||
lng: $data['longitude'],
|
||||
);
|
||||
|
||||
try {
|
||||
Postcode::updateOrCreate(
|
||||
['postcode' => $normalised],
|
||||
[
|
||||
'outcode' => substr($normalised, 0, strlen($normalised) - 3),
|
||||
'lat' => $data['latitude'],
|
||||
'lng' => $data['longitude'],
|
||||
],
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('PostcodeService: failed to persist postcode after HTTP fallback', [
|
||||
'postcode' => $normalised,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
} catch (Throwable $e) {
|
||||
Log::error('PostcodeService: postcode lookup failed', [
|
||||
'postcode' => $postcode,
|
||||
@@ -99,12 +179,33 @@ class PostcodeService
|
||||
|
||||
$data = $response->json('result');
|
||||
|
||||
return new LocationResult(
|
||||
if (! is_array($data) || ! isset($data['outcode'], $data['latitude'], $data['longitude'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$result = new LocationResult(
|
||||
query: $outcode,
|
||||
displayName: $data['outcode'],
|
||||
lat: $data['latitude'],
|
||||
lng: $data['longitude'],
|
||||
);
|
||||
|
||||
try {
|
||||
Outcode::updateOrCreate(
|
||||
['outcode' => $normalised],
|
||||
[
|
||||
'lat' => $data['latitude'],
|
||||
'lng' => $data['longitude'],
|
||||
],
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('PostcodeService: failed to persist outcode after HTTP fallback', [
|
||||
'outcode' => $normalised,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
} catch (Throwable $e) {
|
||||
Log::error('PostcodeService: outcode lookup failed', [
|
||||
'outcode' => $outcode,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('postcodes', function (Blueprint $table): void {
|
||||
$table->string('postcode', 7)->primary()->comment('Normalised: uppercase, no spaces');
|
||||
$table->string('outcode', 4)->index();
|
||||
$table->decimal('lat', 10, 7);
|
||||
$table->decimal('lng', 10, 7);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('postcodes');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('outcodes', function (Blueprint $table): void {
|
||||
$table->string('outcode', 4)->primary();
|
||||
$table->decimal('lat', 10, 7);
|
||||
$table->decimal('lng', 10, 7);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('outcodes');
|
||||
}
|
||||
};
|
||||
1071
docs/superpowers/plans/2026-04-22-self-hosted-postcodes.md
Normal file
1071
docs/superpowers/plans/2026-04-22-self-hosted-postcodes.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -380,6 +380,7 @@
|
||||
<div class="max-w-7xl mx-auto pt-8 border-t border-zinc-300 flex flex-col md:flex-row justify-between items-center gap-4 text-[10px] font-bold uppercase tracking-widest text-zinc-500">
|
||||
<p>© 2024 FuelAlert UK Limited. All Rights Reserved.</p>
|
||||
<p>Data provided by official UK retail price transparency schemes.</p>
|
||||
<p>Postcode data contains OS data © Crown copyright & database right, Royal Mail data © Royal Mail copyright & database right, and National Statistics data © Crown copyright & database right.</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
84
tests/Feature/Console/ImportPostcodesTest.php
Normal file
84
tests/Feature/Console/ImportPostcodesTest.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Outcode;
|
||||
use App\Models\Postcode;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
if (! function_exists('writeOnspdFixture')) {
|
||||
function writeOnspdFixture(string $contents): string
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'onspd_').'.csv';
|
||||
file_put_contents($path, $contents);
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
|
||||
it('imports active postcodes from an ONSPD CSV', function (): void {
|
||||
$csv = <<<'CSV'
|
||||
pcd,pcds,doterm,lat,long
|
||||
"SW1A1AA","SW1A 1AA","",51.501009,-0.141588
|
||||
"M11AD","M1 1AD",,53.480957,-2.237428
|
||||
CSV;
|
||||
|
||||
$path = writeOnspdFixture($csv);
|
||||
|
||||
$this->artisan('postcodes:import', ['--file' => $path])
|
||||
->assertSuccessful();
|
||||
|
||||
expect(Postcode::count())->toBe(2)
|
||||
->and(Postcode::find('SW1A1AA')->outcode)->toBe('SW1A')
|
||||
->and(Postcode::find('M11AD')->outcode)->toBe('M1');
|
||||
});
|
||||
|
||||
it('skips terminated postcodes', function (): void {
|
||||
$csv = <<<'CSV'
|
||||
pcd,pcds,doterm,lat,long
|
||||
"SW1A1AA","SW1A 1AA","",51.501009,-0.141588
|
||||
"OLD1AA","OLD 1AA","202301",50.000000,-1.000000
|
||||
CSV;
|
||||
|
||||
$path = writeOnspdFixture($csv);
|
||||
|
||||
$this->artisan('postcodes:import', ['--file' => $path])->assertSuccessful();
|
||||
|
||||
expect(Postcode::count())->toBe(1)
|
||||
->and(Postcode::find('OLD1AA'))->toBeNull();
|
||||
});
|
||||
|
||||
it('skips rows with blank coordinates', function (): void {
|
||||
$csv = <<<'CSV'
|
||||
pcd,pcds,doterm,lat,long
|
||||
"SW1A1AA","SW1A 1AA","",51.501009,-0.141588
|
||||
"BT11AA","BT1 1AA","",,
|
||||
CSV;
|
||||
|
||||
$path = writeOnspdFixture($csv);
|
||||
|
||||
$this->artisan('postcodes:import', ['--file' => $path])->assertSuccessful();
|
||||
|
||||
expect(Postcode::count())->toBe(1)
|
||||
->and(Postcode::find('BT11AA'))->toBeNull();
|
||||
});
|
||||
|
||||
it('derives outcode centroids as the average of member postcodes', function (): void {
|
||||
$csv = <<<'CSV'
|
||||
pcd,pcds,doterm,lat,long
|
||||
"PE71AA","PE7 1AA","",52.500000,-0.200000
|
||||
"PE71AB","PE7 1AB","",52.600000,-0.220000
|
||||
"M11AD","M1 1AD","",53.480957,-2.237428
|
||||
CSV;
|
||||
|
||||
$path = writeOnspdFixture($csv);
|
||||
|
||||
$this->artisan('postcodes:import', ['--file' => $path])->assertSuccessful();
|
||||
|
||||
$pe7 = Outcode::find('PE7');
|
||||
|
||||
expect($pe7)->not->toBeNull()
|
||||
->and(round((float) $pe7->lat, 6))->toBe(52.550000)
|
||||
->and(round((float) $pe7->lng, 6))->toBe(-0.210000)
|
||||
->and(Outcode::find('M1'))->not->toBeNull();
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Outcode;
|
||||
use App\Models\Postcode;
|
||||
use App\Services\ApiLogger;
|
||||
use App\Services\LocationResult;
|
||||
use App\Services\PostcodeService;
|
||||
@@ -132,23 +134,41 @@ it('returns null when place name yields no results', function (): void {
|
||||
|
||||
// --- Caching ---
|
||||
|
||||
it('caches a successful resolution for 30 days', function (): void {
|
||||
it('caches a successful place resolution for 30 days', function (): void {
|
||||
Http::fake([
|
||||
'*/outcodes/PE7' => Http::response([
|
||||
'*/places*' => Http::response([
|
||||
'status' => 200,
|
||||
'result' => [[
|
||||
'name_1' => 'Manchester',
|
||||
'latitude' => 53.480957,
|
||||
'longitude' => -2.237428,
|
||||
]],
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->service->resolve('Manchester');
|
||||
$this->service->resolve('Manchester');
|
||||
|
||||
Http::assertSentCount(1);
|
||||
expect(Cache::get('place:manchester'))->toBeInstanceOf(LocationResult::class);
|
||||
});
|
||||
|
||||
it('does not cache postcode resolutions in the Cache store (DB is the cache)', function (): void {
|
||||
Http::fake([
|
||||
'*/postcodes/SW1A1AA' => Http::response([
|
||||
'status' => 200,
|
||||
'result' => [
|
||||
'outcode' => 'PE7',
|
||||
'latitude' => 52.536397,
|
||||
'longitude' => -0.210181,
|
||||
'postcode' => 'SW1A 1AA',
|
||||
'latitude' => 51.501009,
|
||||
'longitude' => -0.141588,
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->service->resolve('PE7');
|
||||
$this->service->resolve('PE7');
|
||||
$this->service->resolve('SW1A 1AA');
|
||||
|
||||
Http::assertSentCount(1);
|
||||
expect(Cache::get('postcode:pe7'))->toBeInstanceOf(LocationResult::class);
|
||||
expect(Cache::get('postcode:sw1a1aa'))->toBeNull()
|
||||
->and(Postcode::find('SW1A1AA'))->not->toBeNull();
|
||||
});
|
||||
|
||||
it('does not cache failed lookups', function (): void {
|
||||
@@ -171,3 +191,91 @@ it('returns null and does not throw on API failure', function (): void {
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
// --- Local DB (full postcode) ---
|
||||
|
||||
it('resolves a full postcode from local DB without calling HTTP', function (): void {
|
||||
Postcode::create([
|
||||
'postcode' => 'SW1A1AA',
|
||||
'outcode' => 'SW1A',
|
||||
'lat' => 51.501009,
|
||||
'lng' => -0.141588,
|
||||
]);
|
||||
|
||||
Http::fake(); // any HTTP call will be recorded
|
||||
|
||||
$result = $this->service->resolve('SW1A 1AA');
|
||||
|
||||
expect($result)->toBeInstanceOf(LocationResult::class)
|
||||
->and($result->displayName)->toBe('SW1A 1AA')
|
||||
->and($result->lat)->toBe(51.501009)
|
||||
->and($result->lng)->toBe(-0.141588);
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
// --- Local DB (outcode) ---
|
||||
|
||||
it('resolves an outcode from local DB without calling HTTP', function (): void {
|
||||
Outcode::create([
|
||||
'outcode' => 'PE7',
|
||||
'lat' => 52.536397,
|
||||
'lng' => -0.210181,
|
||||
]);
|
||||
|
||||
Http::fake();
|
||||
|
||||
$result = $this->service->resolve('PE7');
|
||||
|
||||
expect($result)->toBeInstanceOf(LocationResult::class)
|
||||
->and($result->displayName)->toBe('PE7')
|
||||
->and($result->lat)->toBe(52.536397)
|
||||
->and($result->lng)->toBe(-0.210181);
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
// --- HTTP fallback persistence ---
|
||||
|
||||
it('persists a full postcode resolved via HTTP fallback', function (): void {
|
||||
Http::fake([
|
||||
'*/postcodes/SW1A1AA' => Http::response([
|
||||
'status' => 200,
|
||||
'result' => [
|
||||
'postcode' => 'SW1A 1AA',
|
||||
'latitude' => 51.501009,
|
||||
'longitude' => -0.141588,
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->service->resolve('SW1A 1AA');
|
||||
|
||||
$row = Postcode::find('SW1A1AA');
|
||||
|
||||
expect($row)->not->toBeNull()
|
||||
->and($row->outcode)->toBe('SW1A')
|
||||
->and((float) $row->lat)->toBe(51.501009)
|
||||
->and((float) $row->lng)->toBe(-0.141588);
|
||||
});
|
||||
|
||||
it('persists an outcode resolved via HTTP fallback', function (): void {
|
||||
Http::fake([
|
||||
'*/outcodes/PE7' => Http::response([
|
||||
'status' => 200,
|
||||
'result' => [
|
||||
'outcode' => 'PE7',
|
||||
'latitude' => 52.536397,
|
||||
'longitude' => -0.210181,
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->service->resolve('PE7');
|
||||
|
||||
$row = Outcode::find('PE7');
|
||||
|
||||
expect($row)->not->toBeNull()
|
||||
->and((float) $row->lat)->toBe(52.536397)
|
||||
->and((float) $row->lng)->toBe(-0.210181);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user