withHeaders(['X-Api-Key' => config('app.api_secret_key')]); }); function asPaidUserOnStations(string $tier = 'plus'): User { test()->artisan('db:seed', ['--class' => 'PlanSeeder']); $user = User::factory()->create(); UserResource::applyTier($user, $tier, 'monthly'); return $user; } it('returns stations near coordinates filtered by fuel type', 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=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']], '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=b7_standard&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=b7_standard&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=b7_standard&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=b7_standard&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=b7_standard&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(); }); it('resolves a full postcode to coordinates and returns nearby stations', function () { $station = Station::factory()->create(['lat' => 51.5010, 'lng' => -0.1415]); StationPriceCurrent::factory()->create([ 'station_id' => $station->node_id, 'fuel_type' => FuelType::E10, 'price_pence' => 14200, ]); Http::fake([ 'api.postcodes.io/postcodes/SW1A1AA' => Http::response([ 'status' => 200, 'result' => ['postcode' => 'SW1A 1AA', 'latitude' => 51.5010, 'longitude' => -0.1415], ]), ]); $this->getJson('/api/stations?postcode=SW1A+1AA&fuel_type=e10&radius=1') ->assertOk() ->assertJsonPath('meta.count', 1); }); it('resolves an outcode to coordinates', function () { $station = Station::factory()->create(['lat' => 51.5010, 'lng' => -0.1415]); StationPriceCurrent::factory()->create([ 'station_id' => $station->node_id, 'fuel_type' => FuelType::E10, 'price_pence' => 14200, ]); Http::fake([ 'api.postcodes.io/outcodes/SW1A' => Http::response([ 'status' => 200, 'result' => ['outcode' => 'SW1A', 'latitude' => 51.5010, 'longitude' => -0.1415], ]), ]); $this->getJson('/api/stations?postcode=SW1A&fuel_type=e10&radius=1') ->assertOk() ->assertJsonPath('meta.count', 1); }); it('returns 422 when postcode cannot be resolved', function () { Http::fake([ 'api.postcodes.io/*' => Http::response(['status' => 404, 'error' => 'Postcode not found'], 404), ]); $this->getJson('/api/stations?postcode=ZZ99+9ZZ&fuel_type=e10') ->assertUnprocessable() ->assertJsonValidationErrors(['postcode']); }); it('includes resolved lat and lng in meta', 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=b7_standard&radius=10') ->assertOk() ->assertJsonPath('meta.lat', 52.555064) ->assertJsonPath('meta.lng', -0.256119); }); it('includes resolved lat and lng in meta when postcode is provided', function () { $station = Station::factory()->create(['lat' => 51.5010, 'lng' => -0.1415]); StationPriceCurrent::factory()->create([ 'station_id' => $station->node_id, 'fuel_type' => FuelType::E10, 'price_pence' => 14200, ]); Http::fake([ 'api.postcodes.io/postcodes/SW1A1AA' => Http::response([ 'status' => 200, 'result' => ['postcode' => 'SW1A 1AA', 'latitude' => 51.5010, 'longitude' => -0.1415], ]), ]); $this->getJson('/api/stations?postcode=SW1A+1AA&fuel_type=e10&radius=1') ->assertOk() ->assertJsonPath('meta.lat', 51.5010) ->assertJsonPath('meta.lng', -0.1415); }); it('embeds a tier-locked prediction teaser for guest requests', function () { Station::factory()->create(['lat' => 52.555, 'lng' => -0.256]); $this->getJson('/api/stations?lat=52.555&lng=-0.256&fuel_type=e10&radius=10') ->assertOk() ->assertJsonPath('prediction.tier_locked', true) ->assertJsonStructure(['prediction' => ['fuel_type', 'predicted_direction', 'tier_locked']]) ->assertJsonMissingPath('prediction.signals') ->assertJsonMissingPath('prediction.weekly_summary'); }); it('embeds a tier-locked teaser for free-tier authenticated users', function () { asPaidUserOnStations('free'); $user = User::query()->latest('id')->first(); Station::factory()->create(['lat' => 52.555, 'lng' => -0.256]); $this->actingAs($user, 'sanctum') ->getJson('/api/stations?lat=52.555&lng=-0.256&fuel_type=e10&radius=10') ->assertOk() ->assertJsonPath('prediction.tier_locked', true) ->assertJsonMissingPath('prediction.signals'); }); it('embeds the full prediction payload for plus users', function () { $user = asPaidUserOnStations('plus'); Station::factory()->create(['lat' => 52.555, 'lng' => -0.256]); $this->actingAs($user, 'sanctum') ->getJson('/api/stations?lat=52.555&lng=-0.256&fuel_type=e10&radius=10') ->assertOk() ->assertJsonStructure(['prediction' => ['fuel_type', 'predicted_direction', 'confidence_score', 'reasoning', 'weekly_summary', 'signals']]) ->assertJsonMissingPath('prediction.tier_locked'); });