From 0b289c8ec251642094c9e01453c2f5020a6ae2b1 Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Tue, 7 Apr 2026 21:58:01 +0100 Subject: [PATCH] feat: extract fuel.search Livewire component with stations-found dispatch Co-Authored-By: Claude Sonnet 4.6 --- app/Livewire/Public/Fuel/Search.php | 118 ++++++++++ .../livewire/public/fuel/search.blade.php | 130 +++++++++++ tests/Feature/Livewire/Fuel/SearchTest.php | 217 ++++++++++++++++++ 3 files changed, 465 insertions(+) create mode 100644 app/Livewire/Public/Fuel/Search.php create mode 100644 resources/views/livewire/public/fuel/search.blade.php create mode 100644 tests/Feature/Livewire/Fuel/SearchTest.php diff --git a/app/Livewire/Public/Fuel/Search.php b/app/Livewire/Public/Fuel/Search.php new file mode 100644 index 0000000..3fa58cc --- /dev/null +++ b/app/Livewire/Public/Fuel/Search.php @@ -0,0 +1,118 @@ +hasSearched) { + $this->findStations(); + } + } + + public function updatedRadius(): void + { + if ($this->hasSearched) { + $this->findStations(); + } + } + + public function updatedSort(): void + { + if ($this->hasSearched) { + $this->findStations(); + } + } + + public function findStations(): void + { + $this->validate(); + + $this->apiError = null; + $this->hasSearched = false; + + $radiusKm = round($this->radius * 1.60934, 2); + + try { + $response = Http::timeout(10) + ->withHeaders(['X-Api-Key' => config('app.api_secret_key')]) + ->get(url('/api/stations'), [ + 'postcode' => $this->search, + 'fuel_type' => $this->fuelType, + 'radius' => $radiusKm, + 'sort' => $this->sort, + ]); + } catch (ConnectionException) { + $this->apiError = 'Unable to fetch stations. Please try again.'; + + return; + } + + if ($response->status() === 422) { + $errors = $response->json('errors', []); + $this->apiError = collect($errors)->flatten()->first() + ?? $response->json('message', 'Validation error.'); + + return; + } + + if (! $response->successful()) { + $this->apiError = 'Unable to fetch stations. Please try again.'; + + return; + } + + $results = $response->json('data', []); + $meta = $response->json('meta', []); + $this->hasSearched = true; + + $prediction = null; + + try { + $predictionResponse = Http::timeout(10) + ->withHeaders(['X-Api-Key' => config('app.api_secret_key')]) + ->get(url('/api/prediction')); + + if ($predictionResponse->successful()) { + $prediction = $predictionResponse->json(); + } + } catch (ConnectionException) { + // Prediction failure is silent — stations are more important + } + + $this->dispatch('stations-found', + results: $results, + meta: $meta, + prediction: $prediction, + radius: $this->radius, + ); + } + + public function render(): View + { + return view('livewire.public.fuel.search'); + } +} diff --git a/resources/views/livewire/public/fuel/search.blade.php b/resources/views/livewire/public/fuel/search.blade.php new file mode 100644 index 0000000..4c871db --- /dev/null +++ b/resources/views/livewire/public/fuel/search.blade.php @@ -0,0 +1,130 @@ +
+
+ +
+ + +
+ + + +
+ + {{-- IP fallback nudge --}} +
+
+

+ Showing approximate location. + Enter your postcode above for exact results. +

+
+
+
+ + @error('search') +

{{ $message }}

+ @enderror + +
+
+
+
+
+ +
+ + @if ($apiError) +
+ {{ $apiError }} +
+ @endif +
diff --git a/tests/Feature/Livewire/Fuel/SearchTest.php b/tests/Feature/Livewire/Fuel/SearchTest.php new file mode 100644 index 0000000..27af670 --- /dev/null +++ b/tests/Feature/Livewire/Fuel/SearchTest.php @@ -0,0 +1,217 @@ +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 + ); +});