Remove obsolete Livewire fuel search components and consolidate pricing tiers
- 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:
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user