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<StationPriceCurrentFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'station_prices_current';
|
||||||
|
|
||||||
public $timestamps = false;
|
public $timestamps = false;
|
||||||
|
|
||||||
protected $primaryKey = null;
|
protected $primaryKey = null;
|
||||||
|
|
||||||
public $incrementing = false;
|
public $incrementing = false;
|
||||||
|
|
||||||
protected function casts(): array
|
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;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Enums\FuelType;
|
||||||
use App\Models\Station;
|
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\Cache;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Throwable;
|
||||||
|
use ValueError;
|
||||||
|
|
||||||
class FuelPriceService
|
class FuelPriceService
|
||||||
{
|
{
|
||||||
private const TOKEN_CACHE_KEY = 'fuel_finder_access_token';
|
private const string TOKEN_CACHE_KEY = 'fuel_finder_access_token';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly StationTaggingService $taggingService,
|
private readonly StationTaggingService $taggingService,
|
||||||
|
private readonly ApiLogger $apiLogger,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function getAccessToken(): string
|
public function getAccessToken(): string
|
||||||
{
|
{
|
||||||
return Cache::remember(self::TOKEN_CACHE_KEY, 3540, function (): string {
|
return Cache::remember(self::TOKEN_CACHE_KEY, 3540, function (): string {
|
||||||
$response = Http::timeout(10)
|
$url = config('services.fuel_finder.base_url').'/oauth/generate_access_token';
|
||||||
->post(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_id' => config('services.fuel_finder.client_id'),
|
||||||
'client_secret' => config('services.fuel_finder.client_secret'),
|
'client_secret' => config('services.fuel_finder.client_secret'),
|
||||||
]);
|
]));
|
||||||
|
|
||||||
return $response->json('data.access_token');
|
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 */
|
/** @param array<int, array<string, mixed>> $apiStations */
|
||||||
public function upsertStations(array $apiStations): void
|
public function upsertStations(array $apiStations): void
|
||||||
{
|
{
|
||||||
@@ -66,4 +151,75 @@ class FuelPriceService
|
|||||||
|
|
||||||
Station::upsert($rows, ['node_id'], array_keys($rows[0] ?? []));
|
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\Foundation\Inspiring;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Support\Facades\Schedule;
|
||||||
|
|
||||||
Artisan::command('inspire', function () {
|
Artisan::command('inspire', function () {
|
||||||
$this->comment(Inspiring::quote());
|
$this->comment(Inspiring::quote());
|
||||||
})->purpose('Display an 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\Station;
|
||||||
use App\Models\StationPrice;
|
use App\Models\StationPrice;
|
||||||
use App\Models\StationPriceCurrent;
|
use App\Models\StationPriceCurrent;
|
||||||
|
use App\Services\ApiLogger;
|
||||||
use App\Services\FuelPriceService;
|
use App\Services\FuelPriceService;
|
||||||
use App\Services\StationTaggingService;
|
use App\Services\StationTaggingService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
@@ -12,7 +13,7 @@ use Illuminate\Support\Facades\Http;
|
|||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
beforeEach(function (): void {
|
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 {
|
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();
|
$token = $this->service->getAccessToken();
|
||||||
|
|
||||||
expect($token)->toBe('test-token-abc');
|
expect($token)->toBe('test-token-abc')
|
||||||
expect(Cache::get('fuel_finder_access_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 {
|
it('returns cached token without hitting API', function (): void {
|
||||||
@@ -66,7 +67,7 @@ it('upserts stations from API batch response', function (): void {
|
|||||||
'longitude' => -0.1,
|
'longitude' => -0.1,
|
||||||
],
|
],
|
||||||
'amenities' => [],
|
'amenities' => [],
|
||||||
'opening_times'=> null,
|
'opening_times' => null,
|
||||||
'fuel_types' => ['E10', 'E5'],
|
'fuel_types' => ['E10', 'E5'],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
@@ -104,7 +105,7 @@ it('tags supermarket stations during upsert', function (): void {
|
|||||||
'longitude' => -2.6,
|
'longitude' => -2.6,
|
||||||
],
|
],
|
||||||
'amenities' => [],
|
'amenities' => [],
|
||||||
'opening_times'=> null,
|
'opening_times' => null,
|
||||||
'fuel_types' => ['E10'],
|
'fuel_types' => ['E10'],
|
||||||
]];
|
]];
|
||||||
|
|
||||||
@@ -112,3 +113,155 @@ it('tags supermarket stations during upsert', function (): void {
|
|||||||
|
|
||||||
expect(Station::find('tesco1')->is_supermarket)->toBeTrue();
|
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