Remove obsolete Livewire fuel search components and consolidate pricing tiers
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

- Delete unused Livewire Search test and fuel type select Blade component
- Move subscription webhook listener from EventServiceProvider to AppServiceProvider
- Add FUEL_TYPES global config to app layout for client-side use
- Add Billable trait to User model and include email_verified_at in fillable
- Implement monthly/annual cadence toggle with pricing display and smart CTA routing on homepage
- Update VerifyApiKeyMiddlewareTest to use e10 instead of petrol
- Refactor PollFuelPrices to auto-refresh stale stations based on last_seen_at
- Add incremental polling with cached timestamp and effective-start-timestamp param to FuelPriceService
- Normalize amenities/fuel_types from API objects to flat arrays, skip stations missing required fields
- Log response body on API failures in ApiLogger
- Default homepage sort to 'reliable' instead of 'price'
This commit is contained in:
Ovidiu U
2026-04-20 14:12:15 +01:00
parent aec547cd86
commit 5acb99c9e3
33 changed files with 739 additions and 391 deletions

View File

@@ -20,7 +20,7 @@ it('returns stations near coordinates filtered by fuel type', function () {
'price_pence' => 14500,
]);
$this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=diesel&radius=10&sort=price')
$this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=b7_standard&radius=10&sort=price')
->assertOk()
->assertJsonStructure([
'data' => [['station_id', 'name', 'brand', 'is_supermarket', 'lat', 'lng', 'distance_km', 'fuel_type', 'price_pence', 'price', 'price_updated_at']],
@@ -38,7 +38,7 @@ it('excludes stations with no matching fuel type', function () {
'price_pence' => 13800,
]);
$this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=diesel&radius=10')
$this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=b7_standard&radius=10')
->assertOk()
->assertJsonPath('meta.count', 0);
});
@@ -54,7 +54,7 @@ it('excludes temporarily closed stations', function () {
'price_pence' => 14200,
]);
$this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=diesel&radius=10')
$this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=b7_standard&radius=10')
->assertOk()
->assertJsonPath('meta.count', 0);
});
@@ -68,7 +68,7 @@ it('excludes stations beyond radius', function () {
'price_pence' => 14200,
]);
$this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=diesel&radius=10')
$this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=b7_standard&radius=10')
->assertOk()
->assertJsonPath('meta.count', 0);
});
@@ -82,7 +82,7 @@ it('sorts by price when sort=price', function () {
StationPriceCurrent::factory()->create(['station_id' => $cheap->node_id, 'fuel_type' => FuelType::B7Standard, 'price_pence' => 13900]);
StationPriceCurrent::factory()->create(['station_id' => $expensive->node_id, 'fuel_type' => FuelType::B7Standard, 'price_pence' => 14500]);
$this->getJson("/api/stations?lat={$sLat}&lng={$sLng}&fuel_type=diesel&radius=10&sort=price")
$this->getJson("/api/stations?lat={$sLat}&lng={$sLng}&fuel_type=b7_standard&radius=10&sort=price")
->assertOk()
->assertJsonPath('data.0.price_pence', 13900);
});
@@ -91,7 +91,7 @@ it('logs a search record for each request', function () {
$station = Station::factory()->create(['lat' => 52.555064, 'lng' => -0.256119]);
StationPriceCurrent::factory()->create(['station_id' => $station->node_id, 'fuel_type' => FuelType::B7Standard, 'price_pence' => 14500]);
$this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=diesel&radius=10');
$this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=b7_standard&radius=10');
$this->assertDatabaseHas('searches', [
'lat_bucket' => '52.56',
@@ -166,7 +166,7 @@ it('includes resolved lat and lng in meta', function () {
'price_pence' => 14500,
]);
$this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=diesel&radius=10')
$this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=b7_standard&radius=10')
->assertOk()
->assertJsonPath('meta.lat', 52.555064)
->assertJsonPath('meta.lng', -0.256119);

View File

@@ -5,23 +5,23 @@ use Illuminate\Support\Facades\Hash;
use Laravel\Sanctum\Sanctum;
it('returns user preferences for authenticated user', function (): void {
$user = User::factory()->create(['preferred_fuel_type' => 'diesel']);
$user = User::factory()->create(['preferred_fuel_type' => 'b7_standard']);
Sanctum::actingAs($user);
$this->getJson('/api/user/preferences')
->assertOk()
->assertJsonFragment(['preferred_fuel_type' => 'diesel']);
->assertJsonFragment(['preferred_fuel_type' => 'b7_standard']);
});
it('updates user preferences', function (): void {
$user = User::factory()->create(['preferred_fuel_type' => 'petrol']);
$user = User::factory()->create(['preferred_fuel_type' => 'e10']);
Sanctum::actingAs($user);
$this->putJson('/api/user/preferences', ['preferred_fuel_type' => 'diesel'])
$this->putJson('/api/user/preferences', ['preferred_fuel_type' => 'b7_standard'])
->assertOk()
->assertJsonFragment(['preferred_fuel_type' => 'diesel']);
->assertJsonFragment(['preferred_fuel_type' => 'b7_standard']);
expect($user->fresh()->preferred_fuel_type)->toBe('diesel');
expect($user->fresh()->preferred_fuel_type)->toBe('b7_standard');
});
it('rejects invalid fuel type in preferences update', function (): void {

View File

@@ -1,217 +0,0 @@
<?php
use App\Livewire\Public\Fuel\Search;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('renders the search component', function () {
Livewire::test(Search::class)
->assertStatus(200)
->assertSeeHtml('name="search"');
});
it('has default property values', function () {
Livewire::test(Search::class)
->assertSet('search', '')
->assertSet('fuelType', 'petrol')
->assertSet('radius', 5)
->assertSet('sort', 'reliable')
->assertSet('apiError', null)
->assertSet('hasSearched', false);
});
it('validates search is required', function () {
Livewire::test(Search::class)
->call('findStations')
->assertHasErrors(['search' => 'required']);
});
it('validates fuelType is required', function () {
Livewire::test(Search::class)
->set('search', 'SW1A 1AA')
->set('fuelType', '')
->call('findStations')
->assertHasErrors(['fuelType' => 'required']);
});
it('dispatches stations-found with results, meta, prediction and radius on successful search', function () {
Http::fake([
'*/api/stations*' => Http::response([
'data' => [
[
'station_id' => 'abc123',
'name' => 'BP Garage',
'brand' => 'BP',
'is_supermarket' => false,
'address' => '1 High Street',
'postcode' => 'SW1A 1AA',
'lat' => 51.5074,
'lng' => -0.1278,
'distance_km' => 1.5,
'fuel_type' => 'e10',
'price_pence' => 14390,
'price' => 143.9,
'price_updated_at' => '2026-04-05T08:00:00.000Z',
'price_classification' => 'current',
'price_classification_label' => 'Current',
],
],
'meta' => ['count' => 1, 'lowest_pence' => 14390, 'avg_pence' => 14390.0],
], 200),
'*/api/prediction*' => Http::response([
'action' => 'fill_now',
'confidence_score' => 80.0,
'confidence_label' => 'high',
'reasoning' => 'Prices rising.',
'predicted_direction' => 'up',
'predicted_change_pence' => 3.5,
], 200),
]);
Livewire::test(Search::class)
->set('search', 'SW1A 1AA')
->set('fuelType', 'petrol')
->call('findStations')
->assertSet('hasSearched', true)
->assertSet('apiError', null)
->assertDispatched('stations-found', fn ($event, $params) =>
count($params['results']) === 1
&& $params['results'][0]['name'] === 'BP Garage'
&& $params['meta']['count'] === 1
&& $params['prediction']['action'] === 'fill_now'
&& $params['radius'] === 5
);
});
it('sets apiError from 422 station response and does not dispatch stations-found', function () {
Http::fake([
'*/api/stations*' => Http::response([
'errors' => ['postcode' => ['Postcode not found.']],
], 422),
]);
Livewire::test(Search::class)
->set('search', 'ZZ99 9ZZ')
->set('fuelType', 'petrol')
->call('findStations')
->assertSet('hasSearched', false)
->assertSet('apiError', 'Postcode not found.')
->assertNotDispatched('stations-found');
});
it('sets generic apiError on server error', function () {
Http::fake([
'*/api/stations*' => Http::response([], 500),
]);
Livewire::test(Search::class)
->set('search', 'SW1A 1AA')
->set('fuelType', 'petrol')
->call('findStations')
->assertSet('apiError', 'Unable to fetch stations. Please try again.');
});
it('converts radius from miles to km in the outgoing stations request', function () {
Http::fake([
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
'*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200),
]);
Livewire::test(Search::class)
->set('search', 'SW1A 1AA')
->set('fuelType', 'petrol')
->set('radius', 5)
->call('findStations');
Http::assertSent(function ($request) {
if (! str_contains($request->url(), 'api/stations')) {
return false;
}
$data = $request->data();
return isset($data['radius']) && abs((float) $data['radius'] - 8.05) < 0.01;
});
});
it('resets apiError before each new search', function () {
Http::fake([
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
'*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200),
]);
Livewire::test(Search::class)
->set('search', 'SW1A 1AA')
->set('fuelType', 'petrol')
->set('apiError', 'Old error')
->call('findStations')
->assertSet('apiError', null);
});
it('does not call findStations on updatedFuelType if not yet searched', function () {
Http::fake();
Livewire::test(Search::class)
->set('fuelType', 'diesel');
Http::assertNothingSent();
});
it('re-runs findStations on updatedFuelType when already searched', function () {
Http::fake([
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
'*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200),
]);
Livewire::test(Search::class)
->set('hasSearched', true)
->set('search', 'SW1A 1AA')
->set('fuelType', 'diesel');
Http::assertSentCount(2);
});
it('re-runs findStations on updatedRadius when already searched', function () {
Http::fake([
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
'*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200),
]);
Livewire::test(Search::class)
->set('hasSearched', true)
->set('search', 'SW1A 1AA')
->set('radius', 10);
Http::assertSentCount(2);
});
it('re-runs findStations on updatedSort when already searched', function () {
Http::fake([
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
'*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200),
]);
Livewire::test(Search::class)
->set('hasSearched', true)
->set('search', 'SW1A 1AA')
->set('sort', 'price');
Http::assertSentCount(2);
});
it('prediction is null in stations-found payload when prediction api fails', function () {
Http::fake([
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
'*/api/prediction*' => Http::response([], 500),
]);
Livewire::test(Search::class)
->set('search', 'SW1A 1AA')
->set('fuelType', 'petrol')
->call('findStations')
->assertSet('hasSearched', true)
->assertDispatched('stations-found', fn ($event, $params) =>
$params['prediction'] === null
);
});

View File

@@ -4,7 +4,7 @@ use App\Models\User;
use Laravel\Sanctum\Sanctum;
it('rejects requests without api key or sanctum session', function (): void {
$response = $this->getJson('/api/stations?postcode=SW1A1AA&fuel_type=petrol');
$response = $this->getJson('/api/stations?postcode=SW1A1AA&fuel_type=e10');
$response->assertStatus(403);
});
@@ -13,7 +13,7 @@ it('accepts requests with valid api key', function (): void {
config(['app.api_secret_key' => 'test-secret']);
$response = $this->withHeader('X-Api-Key', 'test-secret')
->getJson('/api/stations?postcode=SW1A1AA&fuel_type=petrol');
->getJson('/api/stations?postcode=SW1A1AA&fuel_type=e10');
// 403 would mean middleware rejected — any other status means it passed through
expect($response->status())->not->toBe(403);
@@ -23,7 +23,7 @@ it('accepts requests from sanctum authenticated users', function (): void {
$user = User::factory()->create();
Sanctum::actingAs($user);
$response = $this->getJson('/api/stations?postcode=SW1A1AA&fuel_type=petrol');
$response = $this->getJson('/api/stations?postcode=SW1A1AA&fuel_type=e10');
expect($response->status())->not->toBe(403);
});

View File

@@ -40,6 +40,16 @@ it('logs a failed request and re-throws the exception', function (): void {
->and($log->error)->toBe('connection refused');
});
it('captures response body as error when status is 4xx/5xx', function (): void {
Http::fake(['https://example.com/missing' => Http::response('Not Found', 404)]);
$this->apiLogger->send('test_service', 'GET', 'https://example.com/missing', fn () => Http::get('https://example.com/missing'));
$log = ApiLog::first();
expect($log->status_code)->toBe(404)
->and($log->error)->toBe('Not Found');
});
it('logs a POST request with correct method', function (): void {
Http::fake(['https://example.com/token' => Http::response(['token' => 'abc'], 201)]);

View File

@@ -2,27 +2,22 @@
use App\Enums\FuelType;
it('resolves diesel alias to B7Standard', function () {
expect(FuelType::fromAlias('diesel'))->toBe(FuelType::B7Standard);
it('maps UK API uppercase values to the canonical lowercase enum', function () {
expect(FuelType::fromApiValue('E10'))->toBe(FuelType::E10)
->and(FuelType::fromApiValue('B7_STANDARD'))->toBe(FuelType::B7Standard)
->and(FuelType::fromApiValue('HVO'))->toBe(FuelType::Hvo);
});
it('resolves petrol alias to E10', function () {
expect(FuelType::fromAlias('petrol'))->toBe(FuelType::E10);
it('accepts already-lowercase values', function () {
expect(FuelType::fromApiValue('e5'))->toBe(FuelType::E5);
});
it('resolves unleaded alias to E10', function () {
expect(FuelType::fromAlias('unleaded'))->toBe(FuelType::E10);
it('exposes a human label for each case', function () {
expect(FuelType::E10->label())->toBe('Petrol (E10)')
->and(FuelType::B7Standard->label())->toBe('Diesel (B7)')
->and(FuelType::Hvo->label())->toBe('HVO');
});
it('resolves premium_unleaded alias to E5', function () {
expect(FuelType::fromAlias('premium_unleaded'))->toBe(FuelType::E5);
});
it('accepts canonical enum values as aliases', function () {
expect(FuelType::fromAlias('e10'))->toBe(FuelType::E10);
expect(FuelType::fromAlias('b7_standard'))->toBe(FuelType::B7Standard);
});
it('throws ValueError for unknown alias', function () {
FuelType::fromAlias('avgas');
it('throws ValueError for unknown fuel types', function () {
FuelType::fromApiValue('avgas');
})->throws(ValueError::class);

View File

@@ -6,6 +6,7 @@ use App\Models\StationPriceCurrent;
use App\Services\ApiLogger;
use App\Services\FuelPriceService;
use App\Services\StationTaggingService;
use Carbon\CarbonInterface;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
@@ -310,3 +311,110 @@ it('stops pagination when an empty batch is returned', function (): void {
Http::assertSentCount(1);
});
it('caches the poll timestamp and sends it on subsequent polls', function (): void {
Cache::put('fuel_finder_access_token', 'tok', 3540);
Cache::forget('fuel_finder_last_price_poll_at');
Http::fake([
'*/pfs/fuel-prices*' => Http::response([]),
]);
$this->service->pollPrices();
expect(Cache::get('fuel_finder_last_price_poll_at'))->toBeInstanceOf(CarbonInterface::class);
$this->service->pollPrices();
Http::assertSent(fn ($request) => str_contains($request->url(), 'effective-start-timestamp='));
});
it('does not cache the poll timestamp when a batch errors', function (): void {
Cache::put('fuel_finder_access_token', 'tok', 3540);
Cache::forget('fuel_finder_last_price_poll_at');
Http::fake([
'*/pfs/fuel-prices*' => Http::response([], 500),
]);
$this->service->pollPrices();
expect(Cache::has('fuel_finder_last_price_poll_at'))->toBeFalse();
});
it('skips price rows for stations not present in the stations table', function (): void {
Cache::put('fuel_finder_access_token', 'tok', 3540);
Http::fake([
'*/pfs/fuel-prices*' => Http::sequence()
->push([[
'node_id' => 'unknown-station',
'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)
->and(StationPriceCurrent::count())->toBe(0);
});
it('normalises amenities and fuel_types object payloads to flat arrays', function (): void {
$apiStations = [[
'node_id' => 'abc999',
'trading_name' => 'Shell Somewhere',
'brand_name' => 'Shell',
'is_same_trading_and_brand_name' => false,
'is_motorway_service_station' => false,
'is_supermarket_service_station' => false,
'temporary_closure' => false,
'permanent_closure' => false,
'location' => [
'postcode' => 'AB1 2CD',
'latitude' => 52.1,
'longitude' => -1.2,
],
'amenities' => [
'adblue_pumps' => true,
'car_wash' => false,
'customer_toilets' => true,
],
'fuel_types' => [
'E10' => true,
'E5' => true,
'B7_Standard' => true,
'B7_Premium' => false,
'B10' => false,
'HVO' => false,
],
]];
$this->service->upsertStations($apiStations);
$station = Station::find('abc999');
expect($station->amenities)->toBe(['adblue_pumps', 'customer_toilets'])
->and($station->fuel_types)->toBe(['E10', 'E5', 'B7_Standard']);
});
it('skips stations missing required fields', function (): void {
$apiStations = [
['node_id' => 'missing-loc', 'trading_name' => 'Bad Data'],
[
'node_id' => 'good',
'trading_name' => 'Good Station',
'location' => ['postcode' => 'AB1 2CD', 'latitude' => 52.0, 'longitude' => -1.0],
],
];
$this->service->upsertStations($apiStations);
expect(Station::find('missing-loc'))->toBeNull()
->and(Station::find('good'))->not->toBeNull();
});