feat: add postcode resolution to /api/stations and Filament SearchResource
Extends NearbyStationsRequest to accept `postcode` (full or outcode) as an alternative to lat/lng. PostcodeService resolves it via postcodes.io and falls through to coordinates. Also adds SearchResource to the Filament admin panel for viewing logged search activity with fuel type filter and price/distance stats columns. Includes SQLite GREATEST/LEAST function polyfills in AppServiceProvider for test compatibility. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
81
tests/Feature/Api/AuthControllerTest.php
Normal file
81
tests/Feature/Api/AuthControllerTest.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('registers a new user and returns a token', function () {
|
||||
$this->postJson('/api/auth/register', [
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password',
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonStructure(['token', 'user' => ['id', 'name', 'email']]);
|
||||
});
|
||||
|
||||
it('returns 422 when register fields are missing', function () {
|
||||
$this->postJson('/api/auth/register')
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['name', 'email', 'password']);
|
||||
});
|
||||
|
||||
it('returns 422 when email is already taken', function () {
|
||||
User::factory()->create(['email' => 'taken@example.com']);
|
||||
|
||||
$this->postJson('/api/auth/register', [
|
||||
'name' => 'Another User',
|
||||
'email' => 'taken@example.com',
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password',
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['email']);
|
||||
});
|
||||
|
||||
it('logs in with valid credentials and returns a token', function () {
|
||||
$user = User::factory()->create(['password' => bcrypt('secret123')]);
|
||||
|
||||
$this->postJson('/api/auth/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'secret123',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonStructure(['token', 'user']);
|
||||
});
|
||||
|
||||
it('returns 401 for invalid credentials', function () {
|
||||
User::factory()->create(['email' => 'user@example.com', 'password' => bcrypt('correct')]);
|
||||
|
||||
$this->postJson('/api/auth/login', [
|
||||
'email' => 'user@example.com',
|
||||
'password' => 'wrong',
|
||||
])->assertUnauthorized();
|
||||
});
|
||||
|
||||
it('returns the authenticated user on /me', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->getJson('/api/auth/me')
|
||||
->assertOk()
|
||||
->assertJsonPath('email', $user->email);
|
||||
});
|
||||
|
||||
it('logs out and revokes the token', function () {
|
||||
$user = User::factory()->create();
|
||||
$token = $user->createToken('api')->plainTextToken;
|
||||
|
||||
$this->withToken($token)
|
||||
->postJson('/api/auth/logout')
|
||||
->assertOk()
|
||||
->assertJsonPath('message', 'Logged out.');
|
||||
|
||||
expect($user->tokens()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('returns 401 on protected routes without a token', function () {
|
||||
$this->getJson('/api/auth/me')->assertUnauthorized();
|
||||
});
|
||||
@@ -4,6 +4,7 @@ use App\Enums\FuelType;
|
||||
use App\Models\Station;
|
||||
use App\Models\StationPriceCurrent;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -102,3 +103,53 @@ 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']);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user