From 8bd43ee9e49930801dee8d73e9c1571071856c92 Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Sat, 4 Apr 2026 19:27:55 +0100 Subject: [PATCH] feat: add GET /api/stations nearby stations endpoint with haversine query and search logging Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/Api/StationController.php | 72 ++++++++++++ .../Requests/Api/NearbyStationsRequest.php | 41 +++++++ app/Http/Resources/Api/StationResource.php | 31 ++++++ tests/Feature/Api/StationControllerTest.php | 104 ++++++++++++++++++ 4 files changed, 248 insertions(+) create mode 100644 app/Http/Controllers/Api/StationController.php create mode 100644 app/Http/Requests/Api/NearbyStationsRequest.php create mode 100644 app/Http/Resources/Api/StationResource.php create mode 100644 tests/Feature/Api/StationControllerTest.php diff --git a/app/Http/Controllers/Api/StationController.php b/app/Http/Controllers/Api/StationController.php new file mode 100644 index 0000000..a4e742e --- /dev/null +++ b/app/Http/Controllers/Api/StationController.php @@ -0,0 +1,72 @@ +input('lat'); + $lng = (float) $request->input('lng'); + $fuelType = $request->fuelType(); + $radius = $request->radius(); + $sort = $request->sort(); + + $all = Station::query() + ->selectRaw( + 'stations.*, spc.price_pence, spc.fuel_type, spc.price_effective_at, + (6371 * acos(MAX(-1.0, MIN(1.0, + cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) + + sin(radians(?)) * sin(radians(lat)) + )))) AS distance_km', + [$lat, $lng, $lat], + ) + ->join('station_prices_current as spc', function (JoinClause $join) use ($fuelType): void { + $join->on('stations.node_id', '=', 'spc.station_id') + ->where('spc.fuel_type', '=', $fuelType->value); + }) + ->where('stations.temporary_closure', false) + ->where('stations.permanent_closure', false) + ->get(); + + $stations = $all + ->filter(fn ($s) => (float) $s->distance_km <= $radius) + ->sortBy($sort === 'price' ? 'price_pence' : 'distance_km') + ->values(); + + $prices = $stations->pluck('price_pence'); + + Search::create([ + 'lat_bucket' => round($lat, 2), + 'lng_bucket' => round($lng, 2), + 'fuel_type' => $fuelType->value, + 'results_count' => $stations->count(), + 'lowest_pence' => $prices->min(), + 'highest_pence' => $prices->max(), + 'avg_pence' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null, + 'searched_at' => now(), + 'ip_hash' => hash('sha256', $request->ip() ?? ''), + ]); + + return response()->json([ + 'data' => StationResource::collection($stations), + 'meta' => [ + 'count' => $stations->count(), + 'fuel_type' => $fuelType->value, + 'radius_km' => $radius, + 'lowest_pence' => $prices->min(), + 'highest_pence' => $prices->max(), + 'cheapest_price_pence' => $prices->min(), + 'avg_pence' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null, + ], + ]); + } +} diff --git a/app/Http/Requests/Api/NearbyStationsRequest.php b/app/Http/Requests/Api/NearbyStationsRequest.php new file mode 100644 index 0000000..322c0c5 --- /dev/null +++ b/app/Http/Requests/Api/NearbyStationsRequest.php @@ -0,0 +1,41 @@ + ['required', 'numeric', 'between:-90,90'], + 'lng' => ['required', 'numeric', 'between:-180,180'], + 'fuel_type' => ['required', 'string'], + 'radius' => ['nullable', 'numeric', 'between:0.1,50'], + 'sort' => ['nullable', 'string', 'in:price,distance'], + 'pricing_mode' => ['nullable', 'string', 'in:pump'], + ]; + } + + public function fuelType(): FuelType + { + return FuelType::fromAlias($this->string('fuel_type')->toString()); + } + + public function radius(): float + { + return (float) $this->input('radius', 10.0); + } + + public function sort(): string + { + return $this->input('sort', 'price'); + } +} diff --git a/app/Http/Resources/Api/StationResource.php b/app/Http/Resources/Api/StationResource.php new file mode 100644 index 0000000..dcfcb0f --- /dev/null +++ b/app/Http/Resources/Api/StationResource.php @@ -0,0 +1,31 @@ + $this->node_id, + 'name' => $this->trading_name, + 'brand' => $this->brand_name, + 'is_supermarket' => (bool) $this->is_supermarket, + 'address' => implode(', ', array_filter([$this->address_line_1, $this->city])), + 'postcode' => $this->postcode, + 'lat' => (float) $this->lat, + 'lng' => (float) $this->lng, + 'distance_km' => round((float) $this->distance_km, 2), + 'fuel_type' => $this->fuel_type, + 'price_pence' => (int) $this->price_pence, + 'price' => round((int) $this->price_pence / 100, 2), + 'price_updated_at' => $this->price_effective_at + ? Carbon::parse($this->price_effective_at)->toISOString() + : null, + ]; + } +} diff --git a/tests/Feature/Api/StationControllerTest.php b/tests/Feature/Api/StationControllerTest.php new file mode 100644 index 0000000..32156cc --- /dev/null +++ b/tests/Feature/Api/StationControllerTest.php @@ -0,0 +1,104 @@ +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&sort=price') + ->assertOk() + ->assertJsonStructure([ + 'data' => [['station_id', 'name', 'brand', 'is_supermarket', 'lat', 'lng', 'distance_km', 'fuel_type', 'price_pence', 'price', 'price_updated_at']], + 'meta' => ['count', 'fuel_type', 'radius_km', 'lowest_pence'], + ]) + ->assertJsonPath('data.0.price_pence', 14500) + ->assertJsonPath('meta.fuel_type', 'b7_standard'); +}); + +it('excludes stations with no matching fuel type', function () { + $station = Station::factory()->create(['lat' => 52.555064, 'lng' => -0.256119]); + StationPriceCurrent::factory()->create([ + 'station_id' => $station->node_id, + 'fuel_type' => FuelType::E10, // not diesel + 'price_pence' => 13800, + ]); + + $this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=diesel&radius=10') + ->assertOk() + ->assertJsonPath('meta.count', 0); +}); + +it('excludes temporarily closed stations', function () { + $closed = Station::factory()->create([ + 'lat' => 52.555064, 'lng' => -0.256119, + 'temporary_closure' => true, + ]); + StationPriceCurrent::factory()->create([ + 'station_id' => $closed->node_id, + 'fuel_type' => FuelType::B7Standard, + 'price_pence' => 14200, + ]); + + $this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=diesel&radius=10') + ->assertOk() + ->assertJsonPath('meta.count', 0); +}); + +it('excludes stations beyond radius', function () { + // Station ~100km north + $farStation = Station::factory()->create(['lat' => 53.5, 'lng' => -0.256119]); + StationPriceCurrent::factory()->create([ + 'station_id' => $farStation->node_id, + 'fuel_type' => FuelType::B7Standard, + 'price_pence' => 14200, + ]); + + $this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=diesel&radius=10') + ->assertOk() + ->assertJsonPath('meta.count', 0); +}); + +it('sorts by price when sort=price', function () { + $sLat = 52.555; + $sLng = -0.256; + $cheap = Station::factory()->create(['lat' => $sLat, 'lng' => $sLng]); + $expensive = Station::factory()->create(['lat' => $sLat + 0.001, 'lng' => $sLng]); + + 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") + ->assertOk() + ->assertJsonPath('data.0.price_pence', 13900); +}); + +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->assertDatabaseHas('searches', [ + 'lat_bucket' => '52.56', + 'lng_bucket' => '-0.26', + 'fuel_type' => 'b7_standard', + 'results_count' => 1, + 'lowest_pence' => 14500, + 'highest_pence' => 14500, + ]); +}); + +it('returns 422 when required params are missing', function () { + $this->getJson('/api/stations?lat=52.5') + ->assertUnprocessable(); +});