apilogs
This commit is contained in:
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,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',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
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,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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 {
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user