Compare commits
2 Commits
ec3ad9130c
...
097f1b0529
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
097f1b0529 | ||
|
|
9e0aebc729 |
40
app/Console/Commands/PollFuelPrices.php
Normal file
40
app/Console/Commands/PollFuelPrices.php
Normal 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;
|
||||
}
|
||||
}
|
||||
18
app/Events/PricesUpdatedEvent.php
Normal file
18
app/Events/PricesUpdatedEvent.php
Normal 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
19
app/Models/ApiLog.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,12 @@ 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
|
||||
|
||||
45
app/Services/ApiLogger.php
Normal file
45
app/Services/ApiLogger.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,31 +2,116 @@
|
||||
|
||||
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', [
|
||||
$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
|
||||
{
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
53
docs/superpowers/specs/2026-04-04-apilog-design.md
Normal file
53
docs/superpowers/specs/2026-04-04-apilog-design.md
Normal 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.
|
||||
@@ -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();
|
||||
|
||||
65
tests/Unit/ApiLoggerTest.php
Normal file
65
tests/Unit/ApiLoggerTest.php
Normal 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');
|
||||
});
|
||||
@@ -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 {
|
||||
@@ -27,8 +28,8 @@ it('fetches and caches an access token', function (): void {
|
||||
|
||||
$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 {
|
||||
@@ -112,3 +113,155 @@ it('tags supermarket stations during upsert', function (): void {
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user