From 097f1b052927b73364a50ab00d92c0defabcfc13 Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Sat, 4 Apr 2026 08:41:21 +0100 Subject: [PATCH] apilogs --- app/Console/Commands/PollFuelPrices.php | 40 +++ app/Events/PricesUpdatedEvent.php | 18 ++ app/Models/ApiLog.php | 19 ++ app/Models/StationPriceCurrent.php | 10 +- app/Services/ApiLogger.php | 45 ++++ app/Services/FuelPriceService.php | 212 +++++++++++++--- config/services.php | 4 +- ...026_04_04_073654_create_api_logs_table.php | 30 +++ routes/console.php | 15 ++ tests/Unit/ApiLoggerTest.php | 65 +++++ tests/Unit/Services/FuelPriceServiceTest.php | 233 +++++++++++++++--- 11 files changed, 618 insertions(+), 73 deletions(-) create mode 100644 app/Console/Commands/PollFuelPrices.php create mode 100644 app/Events/PricesUpdatedEvent.php create mode 100644 app/Models/ApiLog.php create mode 100644 app/Services/ApiLogger.php create mode 100644 database/migrations/2026_04_04_073654_create_api_logs_table.php create mode 100644 tests/Unit/ApiLoggerTest.php diff --git a/app/Console/Commands/PollFuelPrices.php b/app/Console/Commands/PollFuelPrices.php new file mode 100644 index 0000000..301d0f3 --- /dev/null +++ b/app/Console/Commands/PollFuelPrices.php @@ -0,0 +1,40 @@ +option('full'); + + try { + if ($fullRefresh) { + $this->info('Refreshing station metadata...'); + $service->refreshStations(); + } + + $this->info('Polling fuel prices...'); + $inserted = $service->pollPrices(); + + $this->info("Done. $inserted new price record(s) inserted."); + + PricesUpdatedEvent::dispatch($inserted, $fullRefresh); + } catch (Throwable $e) { + $this->error("Poll failed: {$e->getMessage()}"); + + return self::FAILURE; + } + + return self::SUCCESS; + } +} diff --git a/app/Events/PricesUpdatedEvent.php b/app/Events/PricesUpdatedEvent.php new file mode 100644 index 0000000..ef19e5f --- /dev/null +++ b/app/Events/PricesUpdatedEvent.php @@ -0,0 +1,18 @@ + 'datetime', + ]; + } +} diff --git a/app/Models/StationPriceCurrent.php b/app/Models/StationPriceCurrent.php index 187bb6c..39758be 100644 --- a/app/Models/StationPriceCurrent.php +++ b/app/Models/StationPriceCurrent.php @@ -15,17 +15,21 @@ class StationPriceCurrent extends Model /** @use HasFactory */ use HasFactory; + protected $table = 'station_prices_current'; + public $timestamps = false; + protected $primaryKey = null; + public $incrementing = false; protected function casts(): array { return [ - 'fuel_type' => FuelType::class, + 'fuel_type' => FuelType::class, 'price_effective_at' => 'datetime', - 'price_reported_at' => 'datetime', - 'recorded_at' => 'datetime', + 'price_reported_at' => 'datetime', + 'recorded_at' => 'datetime', ]; } diff --git a/app/Services/ApiLogger.php b/app/Services/ApiLogger.php new file mode 100644 index 0000000..2cda8c6 --- /dev/null +++ b/app/Services/ApiLogger.php @@ -0,0 +1,45 @@ +status(); + + return $response; + } catch (Throwable $e) { + $error = $e->getMessage(); + + throw $e; + } finally { + ApiLog::create([ + 'service' => $service, + 'method' => strtoupper($method), + 'url' => $url, + 'status_code' => $statusCode, + 'duration_ms' => (int) round((microtime(true) - $start) * 1000), + 'error' => $error, + ]); + } + } +} diff --git a/app/Services/FuelPriceService.php b/app/Services/FuelPriceService.php index 6dd831d..111731f 100644 --- a/app/Services/FuelPriceService.php +++ b/app/Services/FuelPriceService.php @@ -2,62 +2,147 @@ namespace App\Services; +use App\Enums\FuelType; use App\Models\Station; +use App\Models\StationPrice; +use App\Models\StationPriceCurrent; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; +use Throwable; +use ValueError; class FuelPriceService { - private const TOKEN_CACHE_KEY = 'fuel_finder_access_token'; + private const string TOKEN_CACHE_KEY = 'fuel_finder_access_token'; public function __construct( private readonly StationTaggingService $taggingService, + private readonly ApiLogger $apiLogger, ) {} public function getAccessToken(): string { return Cache::remember(self::TOKEN_CACHE_KEY, 3540, function (): string { - $response = Http::timeout(10) - ->post(config('services.fuel_finder.base_url').'/oauth/generate_access_token', [ - 'client_id' => config('services.fuel_finder.client_id'), + $url = config('services.fuel_finder.base_url').'/oauth/generate_access_token'; + $response = $this->apiLogger->send('fuel_finder', 'POST', $url, fn () => Http::timeout(10) + ->post($url, [ + 'client_id' => config('services.fuel_finder.client_id'), 'client_secret' => config('services.fuel_finder.client_secret'), - ]); + ])); return $response->json('data.access_token'); }); } + /** + * Poll the prices endpoint, deduplicate, and persist changes. + * + * @return int Number of new price records inserted + */ + public function pollPrices(): int + { + $token = $this->getAccessToken(); + $inserted = 0; + $batch = 1; + + do { + try { + $baseUrl = config('services.fuel_finder.base_url').'/pfs/fuel-prices'; + $params = ['batch-number' => $batch]; + $logUrl = $baseUrl.'?'.http_build_query($params); + $response = $this->apiLogger->send('fuel_finder', 'GET', $logUrl, fn () => Http::timeout(30) + ->withToken($token) + ->get($baseUrl, $params)); + + $stations = $response->json() ?? []; + } catch (Throwable $e) { + Log::error('FuelPriceService: price batch fetch failed', [ + 'batch' => $batch, + 'error' => $e->getMessage(), + ]); + break; + } + + if (empty($stations)) { + break; + } + + $inserted += $this->processPriceBatch($stations); + $batch++; + } while (true); + + return $inserted; + } + + /** + * Fetch and upsert all station metadata. + * Called on full daily refresh before pollPrices(). + */ + public function refreshStations(): void + { + $token = $this->getAccessToken(); + $batch = 1; + + do { + try { + $baseUrl = config('services.fuel_finder.base_url').'/pfs'; + $params = ['batch-number' => $batch]; + $logUrl = $baseUrl.'?'.http_build_query($params); + $response = $this->apiLogger->send('fuel_finder', 'GET', $logUrl, fn () => Http::timeout(30) + ->withToken($token) + ->get($baseUrl, $params)); + + $stations = $response->json() ?? []; + } catch (Throwable $e) { + Log::error('FuelPriceService: station batch fetch failed', [ + 'batch' => $batch, + 'error' => $e->getMessage(), + ]); + break; + } + + if (empty($stations)) { + break; + } + + $this->upsertStations($stations); + $batch++; + } while (true); + } + /** @param array> $apiStations */ public function upsertStations(array $apiStations): void { - $now = now(); + $now = now(); $rows = []; foreach ($apiStations as $data) { $station = new Station([ - 'node_id' => $data['node_id'], - 'trading_name' => $data['trading_name'], - 'brand_name' => $data['brand_name'] ?? null, - 'is_same_trading_and_brand' => $data['is_same_trading_and_brand_name'] ?? false, - 'is_supermarket' => false, - 'is_motorway_service_station' => $data['is_motorway_service_station'] ?? false, + 'node_id' => $data['node_id'], + 'trading_name' => $data['trading_name'], + 'brand_name' => $data['brand_name'] ?? null, + 'is_same_trading_and_brand' => $data['is_same_trading_and_brand_name'] ?? false, + 'is_supermarket' => false, + 'is_motorway_service_station' => $data['is_motorway_service_station'] ?? false, 'is_supermarket_service_station' => $data['is_supermarket_service_station'] ?? false, - 'temporary_closure' => $data['temporary_closure'] ?? false, - 'permanent_closure' => $data['permanent_closure'] ?? false, - 'permanent_closure_date' => $data['permanent_closure_date'] ?? null, - 'public_phone_number' => $data['public_phone_number'] ?? null, - 'address_line_1' => $data['location']['address_line_1'] ?? null, - 'address_line_2' => $data['location']['address_line_2'] ?? null, - 'city' => $data['location']['city'] ?? null, - 'county' => $data['location']['county'] ?? null, - 'country' => $data['location']['country'] ?? null, - 'postcode' => $data['location']['postcode'], - 'lat' => $data['location']['latitude'], - 'lng' => $data['location']['longitude'], - 'amenities' => $data['amenities'] ?? [], - 'opening_times' => $data['opening_times'] ?? null, - 'fuel_types' => $data['fuel_types'] ?? [], - 'last_seen_at' => $now, + 'temporary_closure' => $data['temporary_closure'] ?? false, + 'permanent_closure' => $data['permanent_closure'] ?? false, + 'permanent_closure_date' => $data['permanent_closure_date'] ?? null, + 'public_phone_number' => $data['public_phone_number'] ?? null, + 'address_line_1' => $data['location']['address_line_1'] ?? null, + 'address_line_2' => $data['location']['address_line_2'] ?? null, + 'city' => $data['location']['city'] ?? null, + 'county' => $data['location']['county'] ?? null, + 'country' => $data['location']['country'] ?? null, + 'postcode' => $data['location']['postcode'], + 'lat' => $data['location']['latitude'], + 'lng' => $data['location']['longitude'], + 'amenities' => $data['amenities'] ?? [], + 'opening_times' => $data['opening_times'] ?? null, + 'fuel_types' => $data['fuel_types'] ?? [], + 'last_seen_at' => $now, ]); $this->taggingService->tag($station); @@ -66,4 +151,75 @@ class FuelPriceService Station::upsert($rows, ['node_id'], array_keys($rows[0] ?? [])); } + + /** + * Process one batch of API price data. + * + * Loads current prices for all stations in the batch, inserts a new + * station_prices row only when the price has changed, and upserts + * station_prices_current to reflect the latest known price. + * + * @param array> $apiBatch + */ + private function processPriceBatch(array $apiBatch): int + { + $stationIds = array_column($apiBatch, 'node_id'); + + // Load current prices for all stations in this batch in one query + $currentPrices = StationPriceCurrent::whereIn('station_id', $stationIds) + ->get() + ->groupBy('station_id') + ->map(fn ($rows) => $rows->keyBy(fn ($r) => $r->fuel_type->value)); + + $now = now(); + $newPrices = []; + $upsertRows = []; + + foreach ($apiBatch as $station) { + $stationId = $station['node_id']; + + foreach ($station['fuel_prices'] ?? [] as $priceData) { + try { + $fuelType = FuelType::fromApiValue($priceData['fuel_type']); + } catch (ValueError) { + continue; // Skip unknown fuel types + } + + $pricePence = (int) round($priceData['price'] * 100); + $effectiveAt = Carbon::parse($priceData['price_change_effective_timestamp']); + $reportedAt = Carbon::parse($priceData['price_last_updated']); + $currentPricePence = $currentPrices[$stationId][$fuelType->value]->price_pence ?? null; + + $row = [ + 'station_id' => $stationId, + 'fuel_type' => $fuelType->value, + 'price_pence' => $pricePence, + 'price_effective_at' => $effectiveAt, + 'price_reported_at' => $reportedAt, + 'recorded_at' => $now, + ]; + + // Always upsert current; only write history when price changed + $upsertRows[] = $row; + + if ($currentPricePence !== $pricePence) { + $newPrices[] = $row; + } + } + } + + if (! empty($newPrices)) { + StationPrice::insert($newPrices); + } + + if (! empty($upsertRows)) { + StationPriceCurrent::upsert( + $upsertRows, + ['station_id', 'fuel_type'], + ['price_pence', 'price_effective_at', 'price_reported_at', 'recorded_at'], + ); + } + + return count($newPrices); + } } diff --git a/config/services.php b/config/services.php index 9410720..d1014f4 100644 --- a/config/services.php +++ b/config/services.php @@ -36,8 +36,8 @@ return [ ], 'fuel_finder' => [ - 'base_url' => env('FUEL_FINDER_BASE_URL', 'https://www.fuel-finder.service.gov.uk/api/v1'), - 'client_id' => env('FUEL_FINDER_CLIENT_ID'), + 'base_url' => env('FUEL_FINDER_BASE_URL', 'https://www.fuel-finder.service.gov.uk/api/v1'), + 'client_id' => env('FUEL_FINDER_CLIENT_ID'), 'client_secret' => env('FUEL_FINDER_CLIENT_SECRET'), ], diff --git a/database/migrations/2026_04_04_073654_create_api_logs_table.php b/database/migrations/2026_04_04_073654_create_api_logs_table.php new file mode 100644 index 0000000..d3102c2 --- /dev/null +++ b/database/migrations/2026_04_04_073654_create_api_logs_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('service', 32)->comment('e.g. fuel_finder, fred, postcodes_io'); + $table->string('method', 8)->comment('HTTP method: GET, POST'); + $table->string('url', 512)->comment('Full URL including query string'); + $table->unsignedSmallInteger('status_code')->nullable()->comment('HTTP status code; null if request threw an exception'); + $table->unsignedInteger('duration_ms')->comment('Round-trip response time in milliseconds'); + $table->text('error')->nullable()->comment('Exception message if request failed'); + $table->dateTime('created_at'); + + $table->index('service'); + $table->index('created_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('api_logs'); + } +}; diff --git a/routes/console.php b/routes/console.php index 3c9adf1..0bfe4ef 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,7 +2,22 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +// Poll for price changes every 15 minutes +Schedule::command('fuel:poll') + ->everyFifteenMinutes() + ->withoutOverlapping() + ->onOneServer() + ->runInBackground(); + +// Full refresh (station metadata + prices) once daily at 3am +Schedule::command('fuel:poll --full') + ->dailyAt('03:00') + ->withoutOverlapping() + ->onOneServer() + ->runInBackground(); diff --git a/tests/Unit/ApiLoggerTest.php b/tests/Unit/ApiLoggerTest.php new file mode 100644 index 0000000..4af7fd8 --- /dev/null +++ b/tests/Unit/ApiLoggerTest.php @@ -0,0 +1,65 @@ +apiLogger = new ApiLogger; +}); + +it('logs a successful GET request', function (): void { + Http::fake(['https://example.com/data' => Http::response(['ok' => true])]); + + $this->apiLogger->send('test_service', 'GET', 'https://example.com/data', fn () => Http::get('https://example.com/data')); + + $log = ApiLog::first(); + expect($log)->not->toBeNull() + ->and($log->service)->toBe('test_service') + ->and($log->method)->toBe('GET') + ->and($log->url)->toBe('https://example.com/data') + ->and($log->status_code)->toBe(200) + ->and($log->error)->toBeNull() + ->and($log->duration_ms)->toBeGreaterThanOrEqual(0); +}); + +it('logs a failed request and re-throws the exception', function (): void { + Http::fake(['https://example.com/fail' => fn () => throw new RuntimeException('connection refused')]); + + expect(fn () => $this->apiLogger->send( + 'test_service', 'GET', 'https://example.com/fail', + fn () => Http::get('https://example.com/fail') + ))->toThrow(RuntimeException::class, 'connection refused'); + + $log = ApiLog::first(); + expect($log)->not->toBeNull() + ->and($log->status_code)->toBeNull() + ->and($log->error)->toBe('connection refused'); +}); + +it('logs a POST request with correct method', function (): void { + Http::fake(['https://example.com/token' => Http::response(['token' => 'abc'], 201)]); + + $this->apiLogger->send('test_service', 'POST', 'https://example.com/token', fn () => Http::post('https://example.com/token', ['key' => 'val'])); + + expect(ApiLog::first()->method)->toBe('POST'); +}); + +it('records duration in milliseconds', function (): void { + Http::fake(['https://example.com/slow' => Http::response([])]); + + $this->apiLogger->send('test_service', 'GET', 'https://example.com/slow', fn () => Http::get('https://example.com/slow')); + + expect(ApiLog::first()->duration_ms)->toBeInt()->toBeGreaterThanOrEqual(0); +}); + +it('upcases the method', function (): void { + Http::fake(['https://example.com/*' => Http::response([])]); + + $this->apiLogger->send('test_service', 'get', 'https://example.com/x', fn () => Http::get('https://example.com/x')); + + expect(ApiLog::first()->method)->toBe('GET'); +}); diff --git a/tests/Unit/Services/FuelPriceServiceTest.php b/tests/Unit/Services/FuelPriceServiceTest.php index 338dbb4..455ab58 100644 --- a/tests/Unit/Services/FuelPriceServiceTest.php +++ b/tests/Unit/Services/FuelPriceServiceTest.php @@ -3,6 +3,7 @@ use App\Models\Station; use App\Models\StationPrice; use App\Models\StationPriceCurrent; +use App\Services\ApiLogger; use App\Services\FuelPriceService; use App\Services\StationTaggingService; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -12,7 +13,7 @@ use Illuminate\Support\Facades\Http; uses(RefreshDatabase::class); beforeEach(function (): void { - $this->service = new FuelPriceService(new StationTaggingService()); + $this->service = new FuelPriceService(new StationTaggingService, new ApiLogger); }); it('fetches and caches an access token', function (): void { @@ -20,15 +21,15 @@ it('fetches and caches an access token', function (): void { '*/oauth/generate_access_token' => Http::response([ 'data' => [ 'access_token' => 'test-token-abc', - 'expires_in' => 3600, + 'expires_in' => 3600, ], ]), ]); $token = $this->service->getAccessToken(); - expect($token)->toBe('test-token-abc'); - expect(Cache::get('fuel_finder_access_token'))->toBe('test-token-abc'); + expect($token)->toBe('test-token-abc') + ->and(Cache::get('fuel_finder_access_token'))->toBe('test-token-abc'); }); it('returns cached token without hitting API', function (): void { @@ -45,29 +46,29 @@ it('returns cached token without hitting API', function (): void { it('upserts stations from API batch response', function (): void { $apiStations = [ [ - 'node_id' => 'abc123', - 'trading_name' => 'Village Garage', - 'brand_name' => 'Village Garage', + 'node_id' => 'abc123', + 'trading_name' => 'Village Garage', + 'brand_name' => 'Village Garage', 'is_same_trading_and_brand_name' => true, - 'is_motorway_service_station' => false, + 'is_motorway_service_station' => false, 'is_supermarket_service_station' => false, - 'temporary_closure' => false, - 'permanent_closure' => false, - 'permanent_closure_date' => null, - 'public_phone_number' => null, - 'location' => [ + 'temporary_closure' => false, + 'permanent_closure' => false, + 'permanent_closure_date' => null, + 'public_phone_number' => null, + 'location' => [ 'address_line_1' => '1 High Street', 'address_line_2' => null, - 'city' => 'London', - 'county' => null, - 'country' => 'England', - 'postcode' => 'SW1A 1AA', - 'latitude' => 51.5, - 'longitude' => -0.1, + 'city' => 'London', + 'county' => null, + 'country' => 'England', + 'postcode' => 'SW1A 1AA', + 'latitude' => 51.5, + 'longitude' => -0.1, ], - 'amenities' => [], - 'opening_times'=> null, - 'fuel_types' => ['E10', 'E5'], + 'amenities' => [], + 'opening_times' => null, + 'fuel_types' => ['E10', 'E5'], ], ]; @@ -83,32 +84,184 @@ it('upserts stations from API batch response', function (): void { it('tags supermarket stations during upsert', function (): void { $apiStations = [[ - 'node_id' => 'tesco1', - 'trading_name' => 'TESCO', - 'brand_name' => 'TESCO', + 'node_id' => 'tesco1', + 'trading_name' => 'TESCO', + 'brand_name' => 'TESCO', 'is_same_trading_and_brand_name' => true, - 'is_motorway_service_station' => false, + 'is_motorway_service_station' => false, 'is_supermarket_service_station' => true, - 'temporary_closure' => false, - 'permanent_closure' => false, - 'permanent_closure_date' => null, - 'public_phone_number' => null, - 'location' => [ + 'temporary_closure' => false, + 'permanent_closure' => false, + 'permanent_closure_date' => null, + 'public_phone_number' => null, + 'location' => [ 'address_line_1' => '1 Tesco Way', 'address_line_2' => null, - 'city' => 'Bristol', - 'county' => null, - 'country' => 'England', - 'postcode' => 'BS1 1AA', - 'latitude' => 51.45, - 'longitude' => -2.6, + 'city' => 'Bristol', + 'county' => null, + 'country' => 'England', + 'postcode' => 'BS1 1AA', + 'latitude' => 51.45, + 'longitude' => -2.6, ], - 'amenities' => [], - 'opening_times'=> null, - 'fuel_types' => ['E10'], + 'amenities' => [], + 'opening_times' => null, + 'fuel_types' => ['E10'], ]]; $this->service->upsertStations($apiStations); expect(Station::find('tesco1')->is_supermarket)->toBeTrue(); }); + +// --- pollPrices --- + +it('inserts new price records and upserts current prices on poll', function (): void { + Cache::put('fuel_finder_access_token', 'tok', 3540); + + Http::fake([ + '*/pfs/fuel-prices*' => Http::sequence() + ->push([ + [ + 'node_id' => 'sta1', + 'fuel_prices' => [ + [ + 'fuel_type' => 'E10', + 'price' => 142.9, + 'price_last_updated' => '2026-04-04T10:00:00.000Z', + 'price_change_effective_timestamp' => '2026-04-04T10:00:00.000Z', + ], + ], + ], + ]) + ->push([]), + ]); + + Station::factory()->create(['node_id' => 'sta1']); + + $inserted = $this->service->pollPrices(); + + expect($inserted)->toBe(1) + ->and(StationPrice::count())->toBe(1) + ->and(StationPriceCurrent::where('station_id', 'sta1')->where('fuel_type', 'e10')->value('price_pence'))->toBe(14290); +}); + +it('does not insert a history row when price is unchanged', function (): void { + Cache::put('fuel_finder_access_token', 'tok', 3540); + + Station::factory()->create(['node_id' => 'sta1']); + + StationPriceCurrent::insert([ + 'station_id' => 'sta1', + 'fuel_type' => 'e10', + 'price_pence' => 14290, + 'price_effective_at' => now()->subHour(), + 'price_reported_at' => now()->subHour(), + 'recorded_at' => now()->subHour(), + ]); + + Http::fake([ + '*/pfs/fuel-prices*' => Http::sequence() + ->push([ + [ + 'node_id' => 'sta1', + 'fuel_prices' => [ + [ + 'fuel_type' => 'E10', + 'price' => 142.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(0) + ->and(StationPrice::count())->toBe(0); +}); + +it('inserts a history row when price has changed', function (): void { + Cache::put('fuel_finder_access_token', 'tok', 3540); + + Station::factory()->create(['node_id' => 'sta1']); + + StationPriceCurrent::insert([ + 'station_id' => 'sta1', + 'fuel_type' => 'e10', + 'price_pence' => 14290, + 'price_effective_at' => now()->subHour(), + 'price_reported_at' => now()->subHour(), + 'recorded_at' => now()->subHour(), + ]); + + Http::fake([ + '*/pfs/fuel-prices*' => Http::sequence() + ->push([ + [ + 'node_id' => 'sta1', + 'fuel_prices' => [ + [ + 'fuel_type' => 'E10', + 'price' => 139.9, + 'price_last_updated' => '2026-04-04T12:00:00.000Z', + 'price_change_effective_timestamp' => '2026-04-04T12:00:00.000Z', + ], + ], + ], + ]) + ->push([]), + ]); + + $inserted = $this->service->pollPrices(); + + expect($inserted)->toBe(1) + ->and(StationPrice::first()->price_pence)->toBe(13990) + ->and(StationPriceCurrent::where('station_id', 'sta1')->value('price_pence'))->toBe(13990); +}); + +it('skips unknown fuel types without error', 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' => [ + [ + 'fuel_type' => 'UNKNOWN_FUEL', + 'price' => 150.0, + '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(0) + ->and(StationPrice::count())->toBe(0); +}); + +it('stops pagination when an empty batch is returned', function (): void { + Cache::put('fuel_finder_access_token', 'tok', 3540); + + Http::fake([ + '*/pfs/fuel-prices*' => Http::sequence() + ->push([]) + ->push([['node_id' => 'never', 'fuel_prices' => []]]), + ]); + + $this->service->pollPrices(); + + Http::assertSentCount(1); +});