refactor(airport): add #[Scope] methods + #[ObservedBy] modernization

- Scopes: active(), byHub(), orderByIcao() — all using #[Scope] attribute
- Attach #[ObservedBy(AirportObserver::class)], remove imperative
  Airport::observe() from ObserverServiceProviders
- Override resolveRouteBinding() for case-insensitive ICAO route binding
  (URLs like /api/airports/kjfk now resolve correctly)
- Drop dead public static array $rules (Prettus validator hook,
  never activated)
This commit is contained in:
Nabeel Shahzad 2026-04-25 11:41:49 -05:00
parent ab91a8c258
commit 1eb5f2eb1c
No known key found for this signature in database
3 changed files with 107 additions and 22 deletions

View File

@ -3,8 +3,12 @@
namespace App\Models;
use App\Contracts\Model;
use App\Models\Observers\AirportObserver;
use App\Models\Traits\ExpensableTrait;
use App\Models\Traits\FilesTrait;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -86,6 +90,7 @@ use Spatie\Activitylog\Traits\LogsActivity;
*
* @mixin \Eloquent
*/
#[ObservedBy(AirportObserver::class)]
class Airport extends Model
{
use ExpensableTrait;
@ -124,25 +129,6 @@ class Airport extends Model
'notes',
];
/**
* Validation rules
*/
public static array $rules = [
'icao' => 'required',
'iata' => 'sometimes|nullable',
'name' => 'required',
'location' => 'sometimes',
'region' => 'sometimes',
'country' => 'sometimes',
'lat' => 'required|numeric',
'lon' => 'required|numeric',
'elevation' => 'nullable|numeric',
'ground_handling_cost' => 'nullable|numeric',
'fuel_100ll_cost' => 'nullable|numeric',
'fuel_jeta_cost' => 'nullable|numeric',
'fuel_mogas_cost' => 'nullable|numeric',
];
public $sortable = [
'id',
'iata',
@ -255,6 +241,49 @@ class Airport extends Model
return $this->hasMany(User::class, 'home_airport_id');
}
/**
* Marker scope. Airport has no `status`/`active` column, so this is
* equivalent to the SoftDeletes default (excludes trashed).
*
* Reserved for cross-model consistency Phase 4 (User domain) and
* Phase 6 (Flight domain) will add `active()` scopes to `User`,
* `Airline`, and other models, at which point reading
* `Airport::active()` will match the convention naturally.
*/
#[Scope]
protected function active(Builder $q): Builder
{
return $q->whereNull('deleted_at');
}
/**
* Filter to hub airports only.
*/
#[Scope]
protected function byHub(Builder $q): Builder
{
return $q->where('hub', true);
}
/**
* Order results by ICAO ascending.
*/
#[Scope]
protected function orderByIcao(Builder $q): Builder
{
return $q->orderBy('icao', 'asc');
}
/**
* URLs typically use a lowercase ICAO (e.g. /api/airports/kjfk) but
* the table stores ICAO uppercased. Override the default Eloquent
* route binding so case-insensitive lookups resolve correctly.
*/
public function resolveRouteBinding($value, $field = null): ?\Illuminate\Database\Eloquent\Model
{
return $this->resolveRouteBindingQuery($this, strtoupper((string) $value), $field)->first();
}
protected function casts(): array
{
return [

View File

@ -2,13 +2,11 @@
namespace App\Providers;
use App\Models\Airport;
use App\Models\Flight;
use App\Models\FlightField;
use App\Models\FlightFieldValue;
use App\Models\Journal;
use App\Models\JournalTransaction;
use App\Models\Observers\AirportObserver;
use App\Models\Observers\FlightObserver;
use App\Models\Observers\JournalObserver;
use App\Models\Observers\JournalTransactionObserver;
@ -26,7 +24,6 @@ class ObserverServiceProviders extends ServiceProvider
{
public function boot(): void
{
Airport::observe(AirportObserver::class);
Journal::observe(JournalObserver::class);
JournalTransaction::observe(JournalTransactionObserver::class);

View File

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
use App\Models\Airport;
test('Airport::byHub returns only hubs', function () {
Airport::factory()->count(3)->create(['hub' => false]);
Airport::factory()->create(['hub' => true, 'icao' => 'KORD']);
$results = Airport::byHub()->get();
expect($results->pluck('icao')->all())->toBe(['KORD']);
});
test('Airport::orderByIcao returns rows in ascending icao order', function () {
foreach (['KZZZ', 'KAAA', 'KMMM'] as $icao) {
Airport::factory()->create(['id' => $icao, 'icao' => $icao]);
}
$results = Airport::orderByIcao()->get();
expect($results->pluck('icao')->all())->toBe(['KAAA', 'KMMM', 'KZZZ']);
});
test('Airport::active excludes soft-deleted airports', function () {
Airport::factory()->create(['icao' => 'KAAA']);
$trashed = Airport::factory()->create(['icao' => 'KBBB']);
$trashed->delete();
$results = Airport::active()->get();
expect($results->pluck('icao')->all())->toBe(['KAAA']);
});
test('Airport scopes compose with each other', function () {
Airport::factory()->create(['icao' => 'KZZZ', 'hub' => true]);
Airport::factory()->create(['icao' => 'KAAA', 'hub' => true]);
Airport::factory()->create(['icao' => 'KMMM', 'hub' => false]);
$results = Airport::byHub()->orderByIcao()->get();
expect($results->pluck('icao')->all())->toBe(['KAAA', 'KZZZ']);
});
test('Airport::resolveRouteBinding upcases the incoming ICAO', function () {
Airport::factory()->create(['id' => 'KJFK', 'icao' => 'KJFK']);
$resolved = (new Airport())->resolveRouteBinding('kjfk');
expect($resolved)->not->toBeNull()
->and($resolved->id)->toBe('KJFK');
});
test('Airport::resolveRouteBinding returns null for unknown ICAO', function () {
$resolved = (new Airport())->resolveRouteBinding('XXXX');
expect($resolved)->toBeNull();
});