Compare commits

...

2 Commits

Author SHA1 Message Date
Ovidiu U
097f1b0529 apilogs
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
2026-04-04 08:41:21 +01:00
Ovidiu U
9e0aebc729 docs: add apilog design spec 2026-04-04 08:34:56 +01:00
12 changed files with 671 additions and 73 deletions

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Console\Commands;
use App\Events\PricesUpdatedEvent;
use App\Services\FuelPriceService;
use Illuminate\Console\Command;
use Throwable;
class PollFuelPrices extends Command
{
protected $signature = 'fuel:poll {--full : Also refresh station metadata}';
protected $description = 'Poll the Fuel Finder API for latest prices';
public function handle(FuelPriceService $service): int
{
$fullRefresh = (bool) $this->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;
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class PricesUpdatedEvent
{
use Dispatchable, SerializesModels;
public function __construct(
/** Number of new price records inserted this poll */
public readonly int $insertedCount,
/** Whether this was a full metadata refresh */
public readonly bool $fullRefresh,
) {}
}

19
app/Models/ApiLog.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['service', 'method', 'url', 'status_code', 'duration_ms', 'error'])]
class ApiLog extends Model
{
const null UPDATED_AT = null;
protected function casts(): array
{
return [
'created_at' => 'datetime',
];
}
}

View File

@@ -15,17 +15,21 @@ class StationPriceCurrent extends Model
/** @use HasFactory<StationPriceCurrentFactory> */
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',
];
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Services;
use App\Models\ApiLog;
use Illuminate\Http\Client\Response;
use Throwable;
class ApiLogger
{
/**
* Execute an HTTP request and log it to api_logs.
*
* The callable must return an Illuminate\Http\Client\Response.
* Exceptions are logged and re-thrown so the caller handles them.
*
* @param callable(): Response $request
*/
public function send(string $service, string $method, string $url, callable $request): Response
{
$start = microtime(true);
$statusCode = null;
$error = null;
try {
$response = $request();
$statusCode = $response->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,
]);
}
}
}

View File

@@ -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<int, array<string, mixed>> $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<int, array<string, mixed>> $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);
}
}

View File

@@ -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'),
],

View File

@@ -0,0 +1,30 @@
<?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('api_logs', function (Blueprint $table): void {
$table->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');
}
};

View File

@@ -0,0 +1,53 @@
# API Log Design
**Date:** 2026-04-04
**Scope:** Outbound HTTP request logging only. Inbound request logging is out of scope (separate table `request_logs` when needed).
## Purpose
Log every outbound HTTP request made by this application to external APIs. Provides an audit trail for debugging failed polls, tracking latency, and monitoring API health over time.
## Table: `api_logs`
| Column | Type | Nullable | Notes |
|---|---|---|---|
| `id` | BIGINT UNSIGNED | no | Auto-increment PK |
| `service` | VARCHAR(32) | no | Identifies the external service: `fuel_finder`, `fred`, `postcodes_io`, `vonage`, `onesignal` |
| `method` | VARCHAR(8) | no | HTTP method: `GET`, `POST` |
| `url` | VARCHAR(512) | no | Full URL including query string |
| `status_code` | SMALLINT UNSIGNED | yes | HTTP response status code; null if request threw an exception |
| `duration_ms` | SMALLINT UNSIGNED | no | Round-trip response time in milliseconds |
| `error` | TEXT | yes | Exception message if the request failed; null on success |
| `created_at` | DATETIME | no | When the request was made |
No `updated_at` — rows are write-once.
## ApiLogger Service
A thin `ApiLogger` service wraps Laravel's `Http::` facade. It:
1. Records start time
2. Makes the HTTP request inside a try/catch
3. Writes an `api_logs` row with URL, status, duration, and error (if any) — always, via try/finally
4. Re-throws any exception so the calling service retains full control over error handling
`FuelPriceService` (and future services hitting external APIs) inject `ApiLogger` and use it instead of calling `Http::` directly.
## What Gets Logged
Every outbound GET and POST — including:
- Fuel Finder OAuth token requests (`POST /oauth/generate_access_token`)
- Fuel Finder price batch fetches (`GET /pfs/fuel-prices?batch-number=N`)
- Fuel Finder station metadata fetches (`GET /pfs?batch-number=N`)
- Future: FRED Brent crude fetch, Postcodes.io lookups, Vonage, OneSignal
## Out of Scope
- Response body storage (not logged — too large, not needed)
- Request headers or credentials (never logged)
- Inbound request logging (`request_logs` — separate feature)
- Relation between `api_logs` and `station_prices` rows
## Pruning
Old rows can be pruned on a schedule (e.g. keep 30 days). Not in scope for this implementation but the table should support it via `created_at` index.

View File

@@ -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();

View File

@@ -0,0 +1,65 @@
<?php
use App\Models\ApiLog;
use App\Services\ApiLogger;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->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');
});

View File

@@ -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);
});