diff --git a/app/Console/Commands/PollFuelPrices.php b/app/Console/Commands/PollFuelPrices.php index 301d0f3..cafcb87 100644 --- a/app/Console/Commands/PollFuelPrices.php +++ b/app/Console/Commands/PollFuelPrices.php @@ -31,6 +31,8 @@ class PollFuelPrices extends Command PricesUpdatedEvent::dispatch($inserted, $fullRefresh); } catch (Throwable $e) { $this->error("Poll failed: {$e->getMessage()}"); + $this->error("In {$e->getFile()}:{$e->getLine()}"); + $this->line($e->getTraceAsString()); return self::FAILURE; } diff --git a/app/Services/FuelPriceService.php b/app/Services/FuelPriceService.php index 111731f..d5c8842 100644 --- a/app/Services/FuelPriceService.php +++ b/app/Services/FuelPriceService.php @@ -17,6 +17,22 @@ class FuelPriceService { private const string TOKEN_CACHE_KEY = 'fuel_finder_access_token'; + /** + * Per-fuel-type valid price range in pence (as returned by the API). + * Based on UK all-time records + 30–75% headroom for future spikes. + * All-time records: petrol 191.6p, diesel 199.2p (Jul 2022). + * + * @var array + */ + private const array PRICE_LIMITS_PENCE = [ + 'e10' => ['min' => 80, 'max' => 750], + 'e5' => ['min' => 80, 'max' => 840], + 'b7_standard' => ['min' => 80, 'max' => 840], + 'b7_premium' => ['min' => 80, 'max' => 960], + 'b10' => ['min' => 80, 'max' => 840], + 'hvo' => ['min' => 80, 'max' => 1050], + ]; + public function __construct( private readonly StationTaggingService $taggingService, private readonly ApiLogger $apiLogger, @@ -56,6 +72,18 @@ class FuelPriceService ->withToken($token) ->get($baseUrl, $params)); + if ($response->notFound()) { + break; // No more batches + } + + if (! $response->successful()) { + Log::error('FuelPriceService: price batch returned error', [ + 'batch' => $batch, + 'status' => $response->status(), + ]); + break; + } + $stations = $response->json() ?? []; } catch (Throwable $e) { Log::error('FuelPriceService: price batch fetch failed', [ @@ -94,6 +122,18 @@ class FuelPriceService ->withToken($token) ->get($baseUrl, $params)); + if ($response->notFound()) { + break; // No more batches + } + + if (! $response->successful()) { + Log::error('FuelPriceService: station batch returned error', [ + 'batch' => $batch, + 'status' => $response->status(), + ]); + break; + } + $stations = $response->json() ?? []; } catch (Throwable $e) { Log::error('FuelPriceService: station batch fetch failed', [ @@ -152,6 +192,17 @@ class FuelPriceService Station::upsert($rows, ['node_id'], array_keys($rows[0] ?? [])); } + private function isValidPrice(FuelType $fuelType, float $pricePence): bool + { + $limits = self::PRICE_LIMITS_PENCE[$fuelType->value] ?? null; + + if ($limits === null) { + return false; + } + + return $pricePence >= $limits['min'] && $pricePence <= $limits['max']; + } + /** * Process one batch of API price data. * @@ -185,7 +236,20 @@ class FuelPriceService continue; // Skip unknown fuel types } - $pricePence = (int) round($priceData['price'] * 100); + $rawPrice = (float) $priceData['price']; + + if (! $this->isValidPrice($fuelType, $rawPrice)) { + Log::warning('FuelPriceService: price out of valid range — skipped', [ + 'station_id' => $stationId, + 'fuel_type' => $fuelType->value, + 'price' => $rawPrice, + 'limits' => self::PRICE_LIMITS_PENCE[$fuelType->value], + ]); + + continue; + } + + $pricePence = (int) round($rawPrice * 100); $effectiveAt = Carbon::parse($priceData['price_change_effective_timestamp']); $reportedAt = Carbon::parse($priceData['price_last_updated']); $currentPricePence = $currentPrices[$stationId][$fuelType->value]->price_pence ?? null; diff --git a/app/Services/LocationResult.php b/app/Services/LocationResult.php new file mode 100644 index 0000000..e6e8577 --- /dev/null +++ b/app/Services/LocationResult.php @@ -0,0 +1,13 @@ +isFullPostcode($query) => $this->lookupPostcode($query), + $this->isOutcode($query) => $this->lookupOutcode($query), + default => $this->lookupPlace($query), + }; + + if ($result !== null) { + Cache::put($cacheKey, $result, self::CACHE_TTL); + } + + return $result; + } + + 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); + } + + private function isOutcode(string $query): bool + { + return (bool) preg_match('/^[A-Z]{1,2}[0-9][0-9A-Z]?$/i', $query); + } + + private function lookupPostcode(string $postcode): ?LocationResult + { + $normalised = strtoupper(preg_replace('/\s+/', '', $postcode)); + $url = self::BASE_URL.'/postcodes/'.$normalised; + + try { + $response = $this->apiLogger->send('postcodes_io', 'GET', $url, fn () => Http::timeout(10)->get($url)); + + if (! $response->successful()) { + return null; + } + + $data = $response->json('result'); + + return new LocationResult( + query: $postcode, + displayName: $data['postcode'], + lat: $data['latitude'], + lng: $data['longitude'], + ); + } catch (Throwable $e) { + Log::error('PostcodeService: postcode lookup failed', [ + 'postcode' => $postcode, + 'error' => $e->getMessage(), + ]); + + return null; + } + } + + private function lookupOutcode(string $outcode): ?LocationResult + { + $normalised = strtoupper(trim($outcode)); + $url = self::BASE_URL.'/outcodes/'.$normalised; + + try { + $response = $this->apiLogger->send('postcodes_io', 'GET', $url, fn () => Http::timeout(10)->get($url)); + + if (! $response->successful()) { + return null; + } + + $data = $response->json('result'); + + return new LocationResult( + query: $outcode, + displayName: $data['outcode'], + lat: $data['latitude'], + lng: $data['longitude'], + ); + } catch (Throwable $e) { + Log::error('PostcodeService: outcode lookup failed', [ + 'outcode' => $outcode, + 'error' => $e->getMessage(), + ]); + + return null; + } + } + + private function lookupPlace(string $place): ?LocationResult + { + $url = self::BASE_URL.'/places'; + $logUrl = $url.'?q='.urlencode($place).'&limit=1'; + + try { + $response = $this->apiLogger->send('postcodes_io', 'GET', $logUrl, fn () => Http::timeout(10) + ->get($url, ['q' => $place, 'limit' => 1])); + + if (! $response->successful()) { + return null; + } + + $results = $response->json('result'); + + if (empty($results)) { + return null; + } + + $data = $results[0]; + + return new LocationResult( + query: $place, + displayName: $data['name_1'], + lat: $data['latitude'], + lng: $data['longitude'], + ); + } catch (Throwable $e) { + Log::error('PostcodeService: place lookup failed', [ + 'place' => $place, + 'error' => $e->getMessage(), + ]); + + return null; + } + } +} diff --git a/database/migrations/2026_04_04_093343_change_price_pence_to_mediumint_in_station_prices.php b/database/migrations/2026_04_04_093343_change_price_pence_to_mediumint_in_station_prices.php new file mode 100644 index 0000000..e82520c --- /dev/null +++ b/database/migrations/2026_04_04_093343_change_price_pence_to_mediumint_in_station_prices.php @@ -0,0 +1,44 @@ +unsignedMediumInteger('price_pence') + ->comment('Price in pence × 100, e.g. 15990 = 159.90p') + ->change(); + }); + + Schema::table('station_prices_current', function (Blueprint $table): void { + $table->unsignedMediumInteger('price_pence') + ->comment('Price in pence × 100, e.g. 15990 = 159.90p') + ->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('station_prices', function (Blueprint $table): void { + $table->unsignedSmallInteger('price_pence') + ->comment('Price in pence × 100') + ->change(); + }); + + Schema::table('station_prices_current', function (Blueprint $table): void { + $table->unsignedSmallInteger('price_pence') + ->comment('Price in pence × 100, e.g. 15990 = 159.90p') + ->change(); + }); + } +}; diff --git a/database/migrations/2026_04_04_093705_add_price_pence_check_constraint_to_station_prices.php b/database/migrations/2026_04_04_093705_add_price_pence_check_constraint_to_station_prices.php new file mode 100644 index 0000000..a2fdb91 --- /dev/null +++ b/database/migrations/2026_04_04_093705_add_price_pence_check_constraint_to_station_prices.php @@ -0,0 +1,34 @@ +and(StationPrice::count())->toBe(0); }); +it('skips prices outside valid range and logs a warning', function (): void { + Cache::put('fuel_finder_access_token', 'tok', 3540); + + Station::factory()->create(['node_id' => 'sta1']); + + Http::fake([ + '*/pfs/fuel-prices*' => Http::sequence() + ->push([ + [ + 'node_id' => 'sta1', + 'fuel_prices' => [ + // Way too high — clearly bad data + [ + 'fuel_type' => 'E10', + 'price' => 900.0, + 'price_last_updated' => '2026-04-04T10:00:00.000Z', + 'price_change_effective_timestamp' => '2026-04-04T10:00:00.000Z', + ], + // Too low — below minimum + [ + 'fuel_type' => 'E5', + 'price' => 10.0, + 'price_last_updated' => '2026-04-04T10:00:00.000Z', + 'price_change_effective_timestamp' => '2026-04-04T10:00:00.000Z', + ], + // Valid — should be inserted + [ + 'fuel_type' => 'B7_STANDARD', + 'price' => 155.9, + 'price_last_updated' => '2026-04-04T10:00:00.000Z', + 'price_change_effective_timestamp' => '2026-04-04T10:00:00.000Z', + ], + ], + ], + ]) + ->push([]), + ]); + + $inserted = $this->service->pollPrices(); + + expect($inserted)->toBe(1) + ->and(StationPrice::count())->toBe(1) + ->and(StationPrice::first()->price_pence)->toBe(15590); +}); + it('stops pagination when an empty batch is returned', function (): void { Cache::put('fuel_finder_access_token', 'tok', 3540); diff --git a/tests/Unit/Services/PostcodeServiceTest.php b/tests/Unit/Services/PostcodeServiceTest.php new file mode 100644 index 0000000..2417240 --- /dev/null +++ b/tests/Unit/Services/PostcodeServiceTest.php @@ -0,0 +1,173 @@ +service = new PostcodeService(new ApiLogger); +}); + +// --- Full postcode --- + +it('resolves a full postcode to coordinates', function (): void { + Http::fake([ + '*/postcodes/SW1A1AA' => Http::response([ + 'status' => 200, + 'result' => [ + 'postcode' => 'SW1A 1AA', + 'latitude' => 51.501009, + 'longitude' => -0.141588, + ], + ]), + ]); + + $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); +}); + +it('normalises postcode spacing before lookup', function (): void { + Http::fake([ + '*/postcodes/SW1A1AA' => Http::response([ + 'status' => 200, + 'result' => [ + 'postcode' => 'SW1A 1AA', + 'latitude' => 51.501009, + 'longitude' => -0.141588, + ], + ]), + ]); + + $result = $this->service->resolve('sw1a1aa'); + + expect($result)->not->toBeNull() + ->and($result->displayName)->toBe('SW1A 1AA'); +}); + +// --- Outcode --- + +it('resolves an outcode to coordinates', function (): void { + Http::fake([ + '*/outcodes/PE7' => Http::response([ + 'status' => 200, + 'result' => [ + 'outcode' => 'PE7', + 'latitude' => 52.536397, + 'longitude' => -0.210181, + ], + ]), + ]); + + $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); +}); + +it('resolves a lowercase outcode', function (): void { + Http::fake([ + '*/outcodes/M1' => Http::response([ + 'status' => 200, + 'result' => [ + 'outcode' => 'M1', + 'latitude' => 53.480957, + 'longitude' => -2.237428, + ], + ]), + ]); + + $result = $this->service->resolve('m1'); + + expect($result)->not->toBeNull() + ->and($result->displayName)->toBe('M1'); +}); + +// --- Place name --- + +it('resolves a city name to coordinates', function (): void { + Http::fake([ + '*/places*' => Http::response([ + 'status' => 200, + 'result' => [ + [ + 'name_1' => 'Manchester', + 'latitude' => 53.480957, + 'longitude' => -2.237428, + ], + ], + ]), + ]); + + $result = $this->service->resolve('Manchester'); + + expect($result)->toBeInstanceOf(LocationResult::class) + ->and($result->displayName)->toBe('Manchester') + ->and($result->lat)->toBe(53.480957) + ->and($result->lng)->toBe(-2.237428); +}); + +it('returns null when place name yields no results', function (): void { + Http::fake([ + '*/places*' => Http::response([ + 'status' => 200, + 'result' => [], + ]), + ]); + + $result = $this->service->resolve('Narnia'); + + expect($result)->toBeNull(); +}); + +// --- Caching --- + +it('caches a successful resolution for 30 days', function (): void { + Http::fake([ + '*/outcodes/PE7' => Http::response([ + 'status' => 200, + 'result' => [ + 'outcode' => 'PE7', + 'latitude' => 52.536397, + 'longitude' => -0.210181, + ], + ]), + ]); + + $this->service->resolve('PE7'); + $this->service->resolve('PE7'); + + Http::assertSentCount(1); + expect(Cache::get('postcode:pe7'))->toBeInstanceOf(LocationResult::class); +}); + +it('does not cache failed lookups', function (): void { + Http::fake([ + '*/postcodes/ZZ99ZZ' => Http::response(['status' => 404], 404), + ]); + + $result = $this->service->resolve('ZZ9 9ZZ'); + + expect($result)->toBeNull() + ->and(Cache::get('postcode:zz99zz'))->toBeNull(); +}); + +it('returns null and does not throw on API failure', function (): void { + Http::fake([ + '*/outcodes/PE7' => Http::response([], 500), + ]); + + $result = $this->service->resolve('PE7'); + + expect($result)->toBeNull(); +});