[8.x] refactor(FlightService, PirepService, UserService): replace UserService usage with direct user methods for subfleet and aircraft access (#2217)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Refactor**
* Consolidated aircraft/subfleet access into model-driven authorization
and moved fare override resolution to API responses for more consistent,
permission-aware results.

* **Tests**
* Added feature and unit tests validating subfleet/aircraft access rules
and enforcing query-count bounds on hot paths.

* **Chores**
* Added database indexes to improve query performance for
aircraft/subfleet and type-rating lookups.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/phpvms/phpvms/pull/2217?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Nabeel S. 2026-05-23 16:16:10 -05:00 committed by GitHub
commit f5dc0e4c5f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 747 additions and 301 deletions

View File

@ -16,7 +16,6 @@ use App\Models\User;
use App\Queries\FlightSearchQuery;
use App\Services\FareService;
use App\Services\FlightService;
use App\Services\UserService;
use Exception;
use Illuminate\Contracts\Routing\ResponseFactory;
use Illuminate\Http\Request;
@ -31,7 +30,6 @@ class FlightController extends Controller
private readonly FareService $fareSvc,
private readonly FlightSearchQuery $flightSearchQuery,
private readonly FlightService $flightSvc,
private readonly UserService $userSvc
) {}
/**
@ -48,15 +46,20 @@ class FlightController extends Controller
$user = Auth::user();
/** @var Flight $flight */
$flight = Flight::with([
'airline',
'fares',
'subfleets' => ['aircraft.bid', 'fares'],
'field_values',
'simbrief' => fn ($query) => $query->with('aircraft')->where('user_id', $user->id),
])->findOrFail($id);
$flight = Flight::query()
->with([
'airline',
'fares',
'field_values',
'simbrief' => fn ($query) => $query->with('aircraft')->where('user_id', $user->id),
])
->findOrFail($id);
$flight->setRelation(
'subfleets',
$flight->accessibleSubfleetsFor($user, ['aircraft', 'fares']),
);
$flight = $this->flightSvc->filterSubfleets($user, $flight);
$flight = $this->fareSvc->getReconciledFaresForFlight($flight);
return new FlightResource($flight);
@ -100,26 +103,16 @@ class FlightController extends Controller
$relations = explode(',', (string) $request->input('with', ''));
}
foreach ($relations as $relation) {
$with = array_merge($with, match ($relation) {
'subfleets' => [
'subfleets',
'subfleets.aircraft',
'subfleets.aircraft.bid',
'subfleets.fares',
],
default => [],
});
$query->with($with);
if (in_array('subfleets', $relations, true)) {
$query->withAccessibleSubfleets($user);
}
$perPage = paginate_limit($request->integer('limit') ?: null);
$flights = $query->with($with)->paginate($perPage);
$flights = $query->paginate($perPage);
foreach ($flights as $flight) {
if (in_array('subfleets', $relations)) {
$this->flightSvc->filterSubfleets($user, $flight);
}
$this->fareSvc->getReconciledFaresForFlight($flight);
}
@ -168,33 +161,29 @@ class FlightController extends Controller
public function aircraft(string $id, Request $request)
{
/** @var Flight $flight */
$flight = Flight::with('subfleets')->findOrFail($id);
$flight = Flight::findOrFail($id);
$user_subfleets = $this->userSvc->getAllowableSubfleets(Auth::user())->pluck('id')->toArray();
$flight_subfleets = $flight->subfleets->pluck('id')->toArray();
/** @var User $user */
$user = Auth::user();
$subfleet_ids = filled($flight_subfleets) ? array_intersect($user_subfleets, $flight_subfleets) : $user_subfleets;
// Prepare variables for single aircraft query
$where = [];
$where['state'] = AircraftState::PARKED;
$where['status'] = AircraftStatus::ACTIVE;
if (setting('pireps.only_aircraft_at_dpt_airport')) {
$where['airport_id'] = $flight->dpt_airport_id;
}
$withCount = ['bid', 'simbriefs' => function ($query): void {
$query->whereNull('pirep_id');
}];
// Build proper aircraft collection considering all possible settings
// Flight subfleets, user subfleet restrictions, pirep restrictions, simbrief blocking etc
$aircraft = Aircraft::withCount($withCount)->where($where)
->when(setting('simbrief.block_aircraft'), fn ($query) => $query->having('simbriefs_count', 0))->when(setting('bids.block_aircraft'), fn ($query) => $query->having('bid_count', 0))->whereIn('subfleet_id', $subfleet_ids)
->orderby('icao')->orderby('registration')
return Aircraft::query()
->allowedFor($user, $flight)
->where('state', AircraftState::PARKED)
->where('status', AircraftStatus::ACTIVE)
->when(
$flight->subfleets()->exists(),
fn ($q) => $q->whereIn('subfleet_id', $flight->subfleets()->pluck('subfleets.id')),
)
->withCount([
'bid',
'simbriefs' => fn ($q) => $q->whereNull('pirep_id'),
])
->when(
setting('simbrief.block_aircraft'),
fn ($q) => $q->having('simbriefs_count', 0),
)
->orderBy('icao')
->orderBy('registration')
->get();
return $aircraft;
}
}

View File

@ -32,7 +32,6 @@ use App\Models\User;
use App\Queries\JournalTransactionQuery;
use App\Services\Finance\PirepFinanceService;
use App\Services\PirepService;
use App\Services\UserService;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\ModelNotFoundException;
@ -51,7 +50,6 @@ class PirepController extends Controller
private readonly PirepFinanceService $financeSvc,
private readonly JournalTransactionQuery $journalTransactions,
private readonly PirepService $pirepSvc,
private readonly UserService $userSvc
) {}
/**
@ -229,7 +227,7 @@ class PirepController extends Controller
if (array_key_exists('aircraft_id', $attrs)
&& setting('pireps.restrict_aircraft_to_rank', false)
) {
$can_use_ac = $this->userSvc->aircraftAllowed($user, $pirep->aircraft_id);
$can_use_ac = $user->allowedAircraft()->whereKey($pirep->aircraft_id)->exists();
if (!$can_use_ac) {
throw new AircraftPermissionDenied($user, $pirep->aircraft);
}
@ -271,7 +269,7 @@ class PirepController extends Controller
if (array_key_exists('aircraft_id', $attrs)
&& setting('pireps.restrict_aircraft_to_rank', false)
) {
$can_use_ac = $this->userSvc->aircraftAllowed($user, $pirep->aircraft_id);
$can_use_ac = $user->allowedAircraft()->whereKey($pirep->aircraft_id)->exists();
if (!$can_use_ac) {
throw new AircraftPermissionDenied($user, $pirep->aircraft);
}

View File

@ -159,7 +159,9 @@ class UserController extends Controller
$perPage = paginate_limit($request->integer('limit') ?: null);
$subfleets = $this->userSvc->getAllowableSubfleets($user, true, $perPage)
$subfleets = $user->allowedSubfleets()
->with(['aircraft', 'aircraft.bid', 'fares'])
->paginate($perPage)
->appends($request->except(['page', 'user']));
return SubfleetResource::collection($subfleets);

View File

@ -15,7 +15,6 @@ use App\Queries\FlightSearchQuery;
use App\Services\FlightService;
use App\Services\GeoService;
use App\Services\ModuleService;
use App\Services\UserService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
@ -30,7 +29,6 @@ class FlightController extends Controller
private readonly FlightService $flightSvc,
private readonly GeoService $geoSvc,
private readonly ModuleService $moduleSvc,
private readonly UserService $userSvc
) {}
public function index(SearchFlightsRequest $request): View
@ -66,7 +64,7 @@ class FlightController extends Controller
if ($filter_by_user) {
// Get allowed subfleets for the user
$user_subfleets = $this->userSvc->getAllowableSubfleets($user)->pluck('id')->toArray();
$user_subfleets = $user->allowedSubfleets()->pluck('id')->all();
$allowed_flights = $this->flightSvc->getAccessibleFlightIds($user);
// Build aircraft icao codes by considering allowed subfleets
$icao_codes = Aircraft::whereIn('subfleet_id', $user_subfleets)->groupBy('icao')->orderBy('icao')->pluck('icao')->toArray();
@ -214,9 +212,10 @@ class FlightController extends Controller
return redirect(route('frontend.dashboard.index'));
}
if (setting('flights.only_company_aircraft', false)) {
$flight = $this->flightSvc->filterSubfleets($user, $flight);
}
$flight->setRelation(
'subfleets',
$flight->accessibleSubfleetsFor($user, ['airline']),
);
$map_features = $this->geoSvc->flightGeoJson($flight);

View File

@ -18,17 +18,18 @@ use App\Models\Pirep;
use App\Models\PirepFare;
use App\Models\PirepField;
use App\Models\SimBrief;
use App\Models\Subfleet;
use App\Models\User;
use App\Queries\PirepSearchQuery;
use App\Services\FareService;
use App\Services\GeoService;
use App\Services\PirepService;
use App\Services\SimBriefService;
use App\Services\UserService;
use App\Support\Units\Fuel;
use App\Support\Units\Time;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@ -44,7 +45,6 @@ class PirepController extends Controller
private readonly GeoService $geoSvc,
private readonly PirepSearchQuery $pirepSearchQuery,
private readonly PirepService $pirepSvc,
private readonly UserService $userSvc
) {}
/**
@ -59,18 +59,25 @@ class PirepController extends Controller
$location_check = setting('pireps.only_aircraft_at_dpt_airport', false);
$aircraft = [];
$subfleets = $this->userSvc->getAllowableSubfleets($user);
if ($add_blank) {
$aircraft[''] = '';
}
$subfleets->loadMissing('aircraft');
/** @var Collection<int, Subfleet> $subfleets */
$subfleets = $user->allowedSubfleets()
->with([
'aircraft' => fn ($q) => $q->when(
$location_check,
fn ($q2) => $q2->where('airport_id', $user_loc),
),
])
->get();
foreach ($subfleets as $subfleet) {
$tmp = [];
foreach ($subfleet->aircraft->when($location_check, fn ($query) => $query->where('airport_id', $user_loc)) as $ac) {
$tmp[$ac->id] = $ac['name'].' - '.$ac['registration'];
foreach ($subfleet->aircraft as $ac) {
$tmp[$ac->id] = $ac->name.' - '.$ac->registration;
}
$aircraft[$subfleet->type] = $tmp;
@ -322,7 +329,7 @@ class PirepController extends Controller
// Can they fly this aircraft?
if (setting('pireps.restrict_aircraft_to_rank', false)
&& !$this->userSvc->aircraftAllowed($user, $pirep->aircraft_id)) {
&& !$user->allowedAircraft()->whereKey($pirep->aircraft_id)->exists()) {
Log::info('Pilot '.$user->id.' not allowed to fly aircraft');
return $this->flashError(

View File

@ -19,7 +19,6 @@ use App\Models\User;
use App\Services\FareService;
use App\Services\ModuleService;
use App\Services\SimBriefService;
use App\Services\UserService;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@ -33,7 +32,6 @@ class SimBriefController
private readonly FareService $fareSvc,
private readonly ModuleService $moduleSvc,
private readonly SimBriefService $simBriefSvc,
private readonly UserService $userSvc
) {}
/**
@ -85,35 +83,23 @@ class SimBriefController
// No aircraft selected, show selection form
if (!$aircraft_id) {
// Get user's allowed subfleets and intersect it with flight subfleets
// so we will have a proper list which the user is allowed to fly
$user_subfleets = $this->userSvc->getAllowableSubfleets($user)->pluck('id')->toArray();
$flight_subfleets = $flight->subfleets->pluck('id')->toArray();
$subfleet_ids = $flight->accessibleSubfleetsFor($user)->pluck('id');
if ((blank($flight_subfleets) || count($flight_subfleets) === 0) && setting('flights.only_company_aircraft', false)) {
$flight_subfleets = Subfleet::where(['airline_id' => $flight->airline_id])->pluck('id')->toArray();
}
$subfleet_ids = filled($flight_subfleets) ? array_intersect($user_subfleets, $flight_subfleets) : $user_subfleets;
// Prepare variables for single aircraft query
$where = [];
$where['state'] = AircraftState::PARKED;
$where['status'] = AircraftStatus::ACTIVE;
if (setting('pireps.only_aircraft_at_dpt_airport')) {
$where['airport_id'] = $flight->dpt_airport_id;
}
$withCount = ['simbriefs' => function ($query): void {
$query->whereNull('pirep_id');
}];
// Build proper aircraft collection considering all possible settings
// Flight subfleets, user subfleet restrictions, pirep restrictions, simbrief blocking etc
$aircraft = Aircraft::withCount($withCount)->with(['sbaircraft', 'sbairframes'])->where($where)
->when(setting('simbrief.block_aircraft'), fn ($query) => $query->having('simbriefs_count', 0))->whereIn('subfleet_id', $subfleet_ids)
->orderby('icao')->orderby('registration')
$aircraft = Aircraft::query()
->allowedFor($user, $flight)
->where('state', AircraftState::PARKED)
->where('status', AircraftStatus::ACTIVE)
->whereIn('subfleet_id', $subfleet_ids)
->with(['sbaircraft', 'sbairframes'])
->withCount([
'simbriefs' => fn ($q) => $q->whereNull('pirep_id'),
])
->when(
setting('simbrief.block_aircraft'),
fn ($q) => $q->having('simbriefs_count', 0),
)
->orderBy('icao')
->orderBy('registration')
->get();
return view('flights.simbrief_aircraft', [

View File

@ -6,6 +6,7 @@ namespace App\Http\Resources;
use App\Contracts\Resource;
use App\Models\Subfleet;
use App\Services\FareService;
use Illuminate\Http\Request;
/**
@ -17,7 +18,14 @@ class SubfleetResource extends Resource
public function toArray(Request $request)
{
$res = parent::toArray($request);
$res['fares'] = FareResource::collection($this->fares);
// Apply subfleet-level fare overrides at the response boundary.
// The access-control loader no longer bakes these in (the financial
// path through FareService::saveToPirep recomputes server-side at
// file time and is independent of the loader).
$fares = app(FareService::class)->getForSubfleet($this->resource);
$res['fares'] = FareResource::collection($fares);
$res['aircraft'] = AircraftResource::collection($this->aircraft);
return $res;

View File

@ -8,10 +8,13 @@ use App\Contracts\Model;
use App\Enums\AircraftState;
use App\Enums\AircraftStatus;
use App\Observers\AircraftObserver;
use App\Support\SubfleetAccessPolicy;
use App\Traits\ExpensableTrait;
use App\Traits\FilesTrait;
use Carbon\Carbon;
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;
@ -272,6 +275,17 @@ class Aircraft extends Model
return $this->belongsTo(Subfleet::class, 'subfleet_id');
}
/**
* Restrict to aircraft the given user is allowed to operate. Optionally
* scope to a flight context to apply departure-airport restriction.
* See SubfleetAccessPolicy.
*/
#[Scope]
protected function allowedFor(Builder $query, User $user, ?Flight $flight = null): Builder
{
return (new SubfleetAccessPolicy($user, $flight))->applyToAircraft($query);
}
public function sbaircraft(): HasOne
{
return $this->hasOne(SimBriefAircraft::class, 'icao', 'icao');

View File

@ -373,6 +373,37 @@ class Flight extends Model
return $this->belongsToMany(Subfleet::class, 'flight_subfleet');
}
/**
* Resolve the subfleets shown for this flight given a user.
*
* Pinned subfleets win. If none are pinned, fall back to:
* - airline subfleets when `flights.only_company_aircraft` is on
* - all user-allowed subfleets otherwise
*
* User access constraints (rank / type rating) are applied throughout.
* Use this in single-flight controllers; list endpoints use the
* `withAccessibleSubfleets` scope which skips the fallback.
*/
public function accessibleSubfleetsFor(User $user, array $with = []): Collection
{
$pinned = Subfleet::query()
->allowedFor($user)
->whereHas('flights', fn ($q) => $q->whereKey($this->id))
->with($with)
->get();
if ($pinned->isNotEmpty()) {
return $pinned;
}
$fallback = Subfleet::query()->allowedFor($user)->with($with);
if (setting('flights.only_company_aircraft', false)) {
$fallback->where('airline_id', $this->airline_id);
}
return $fallback->get();
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
@ -542,4 +573,28 @@ class Flight extends Model
fn (Builder $sq) => $sq->whereIn('subfleets.id', $subfleetIds)
);
}
/**
* Eager-load subfleets, their aircraft, and their fares constrained to the
* user's access policy. No flight context is passed to the aircraft scope
* here airport restriction is a per-flight concern handled by
* single-flight callers via `Aircraft::allowedFor($user, $flight)`. Rank,
* type-rating, and bid-block constraints still apply.
*
* `fares` is eager-loaded because callers commonly run the result through
* `FareService::getReconciledFaresForFlight()`, which reads
* `$subfleet->fares`. Without that load, lazy-loading kicks in and trips
* `preventLazyLoading()` in non-prod environments.
*/
#[Scope]
protected function withAccessibleSubfleets(Builder $query, User $user): Builder
{
return $query->with([
'subfleets' => fn ($sq) => $sq->allowedFor($user)->with([
'aircraft' => fn ($aq) => $aq->allowedFor($user),
'aircraft.bid',
'fares',
]),
]);
}
}

View File

@ -7,9 +7,12 @@ use App\Enums\AircraftStatus;
use App\Enums\FlightType;
use App\Enums\FuelType;
use App\Observers\SubfleetObserver;
use App\Support\SubfleetAccessPolicy;
use App\Traits\ExpensableTrait;
use App\Traits\FilesTrait;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\AsEnumCollection;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Collection;
@ -203,6 +206,16 @@ class Subfleet extends Model
);
}
/**
* Restrict to subfleets the given user is allowed to operate, based on
* rank and type-rating settings. See SubfleetAccessPolicy.
*/
#[Scope]
protected function allowedFor(Builder $query, User $user): Builder
{
return (new SubfleetAccessPolicy($user))->applyToSubfleets($query);
}
/**
* The attributes that should be cast to native types.
*/

View File

@ -446,6 +446,26 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, MustVerif
return $this->hasManyDeep(Subfleet::class, ['typerating_user', Typerating::class, 'typerating_subfleet']);
}
/**
* Composable query for the subfleets this user is allowed to operate, given
* the current restrict_aircraft_to_rank / restrict_aircraft_to_typerating
* settings. Callers chain ->get(), ->paginate($per), ->pluck('id'), etc.
*/
public function allowedSubfleets(): Builder
{
return Subfleet::query()->allowedFor($this);
}
/**
* Composable query for the aircraft this user is allowed to operate.
* Pass a Flight to apply the only_aircraft_at_dpt_airport setting against
* that flight's departure airport.
*/
public function allowedAircraft(?Flight $flight = null): Builder
{
return Aircraft::query()->allowedFor($this, $flight);
}
public function canAccessPanel(Panel $panel): bool
{
// For phpvms panels

View File

@ -22,7 +22,6 @@ class BidService extends Service
{
public function __construct(
private readonly FareService $fareSvc,
private readonly FlightService $flightSvc
) {}
/**
@ -40,10 +39,6 @@ class BidService extends Service
},
'flight.simbrief.aircraft',
'flight.simbrief.aircraft.subfleet',
'flight.subfleets',
'flight.subfleets.aircraft',
'flight.subfleets.aircraft.bid',
'flight.subfleets.fares',
];
/** @var ?Bid $bid */
@ -52,17 +47,28 @@ class BidService extends Service
return null;
}
// Reconcile the aircraft for this bid
// TODO: Only do this if there isn't a Simbrief attached?
if (!empty($bid->aircraft)) {
$bid->flight->subfleets = $this->flightSvc->getSubfleetsForBid($bid);
} else {
// @phpstan-ignore-next-line
$bid->flight = $this->flightSvc->filterSubfleets($user, $bid->flight);
}
if ($bid->flight !== null) {
if ($bid->aircraft !== null) {
// Bid is for a specific aircraft — show only that aircraft's subfleet
$bid->flight->setRelation(
'subfleets',
$bid->flight->subfleets()
->where('subfleets.id', $bid->aircraft->subfleet_id)
->with([
'fares',
'aircraft' => fn ($q) => $q->where('id', $bid->aircraft_id),
])
->get(),
);
} else {
$bid->flight->setRelation(
'subfleets',
$bid->flight->accessibleSubfleetsFor($user, ['aircraft.bid', 'fares']),
);
}
// @phpstan-ignore-next-line
$bid->flight = $this->fareSvc->getReconciledFaresForFlight($bid->flight);
$this->fareSvc->getReconciledFaresForFlight($bid->flight);
}
return $bid;
}
@ -86,36 +92,41 @@ class BidService extends Service
},
];
foreach ($relations as $relation) {
$with = array_merge($with, match ($relation) {
'subfleets' => [
'flight.subfleets',
'flight.subfleets.aircraft',
'flight.subfleets.aircraft.bid',
'flight.subfleets.fares',
],
'simbrief_aircraft' => [
'flight.simbrief.aircraft',
'flight.simbrief.aircraft.subfleet',
'flight.simbrief.aircraft.subfleet.fares',
],
default => [],
});
$loadSubfleets = in_array('subfleets', $relations, true);
if ($loadSubfleets) {
// Eager-load filtered subfleets + their fares + aircraft via the
// access-policy scope in a single query plan per relation.
$with['flight'] = fn ($q) => $q->withAccessibleSubfleets($user);
}
if (in_array('simbrief_aircraft', $relations, true)) {
$with = array_merge($with, [
'flight.simbrief.aircraft',
'flight.simbrief.aircraft.subfleet',
'flight.simbrief.aircraft.subfleet.fares',
]);
}
$bids = Bid::with($with)->where(['user_id' => $user->id])->get();
if (in_array('subfleets', $relations, true)) {
if ($loadSubfleets) {
foreach ($bids as $bid) {
if ($bid->aircraft) {
$bid->flight->subfleets = $this->flightSvc->getSubfleetsForBid($bid);
} else {
// @phpstan-ignore-next-line
$bid->flight = $this->flightSvc->filterSubfleets($user, $bid->flight);
if ($bid->flight === null) {
continue;
}
// @phpstan-ignore-next-line
$bid->flight = $this->fareSvc->getReconciledFaresForFlight($bid->flight);
// If the bid is for a specific aircraft, narrow the subfleet list
// to that aircraft's subfleet only — preserves the historic UX of
// showing only the booked aircraft on the bid card.
if ($bid->aircraft !== null) {
$bid->flight->setRelation(
'subfleets',
$bid->flight->subfleets->where('id', $bid->aircraft->subfleet_id)->values(),
);
}
$this->fareSvc->getReconciledFaresForFlight($bid->flight);
}
}

View File

@ -8,13 +8,11 @@ use App\Contracts\Service;
use App\Enums\PirepState;
use App\Enums\PirepStatus;
use App\Exceptions\DuplicateFlight;
use App\Models\Aircraft;
use App\Models\Bid;
use App\Models\Flight;
use App\Models\FlightFieldValue;
use App\Models\Navdata;
use App\Models\Pirep;
use App\Models\Subfleet;
use App\Models\User;
use App\Support\Days;
use App\Support\Units\Time;
@ -25,7 +23,6 @@ class FlightService extends Service
{
public function __construct(
private readonly AirportService $airportSvc,
private readonly UserService $userSvc
) {}
/**
@ -122,7 +119,7 @@ class FlightService extends Service
*/
public function getAccessibleFlightIds(User $user): array
{
$userSubfleets = $this->userSvc->getAllowableSubfleets($user)->pluck('id')->all();
$userSubfleets = $user->allowedSubfleets()->pluck('id')->all();
$userFlights = Flight::query()
->whereHas('subfleets', static function ($query) use ($userSubfleets): void {
@ -142,88 +139,6 @@ class FlightService extends Service
return array_values(array_unique(array_merge($userFlights, $openFlights)));
}
/**
* Return the proper subfleets for the given bid
*
*
* @return mixed
*/
public function getSubfleetsForBid(Bid $bid)
{
return Subfleet::with([
'fares',
'aircraft' => function ($query) use ($bid): void {
$query->where('id', $bid->aircraft_id);
}])
->where('id', $bid->aircraft->subfleet_id)
->get();
}
/**
* Filter out subfleets to only include aircraft that a user has access to
*/
public function filterSubfleets(User $user, Flight $flight): Flight
{
// Eager load some of the relationships needed
// $flight->load(['flight.subfleets', 'flight.subfleets.aircraft', 'flight.subfleets.fares']);
$subfleets = $flight->subfleets;
// If no subfleets assigned and airline subfleets are forced, get airline subfleets
if ($subfleets->count() === 0 && setting('flights.only_company_aircraft', false)) {
$subfleets = Subfleet::where(['airline_id' => $flight->airline_id])->get();
}
// If no subfleets assigned to a flight get users allowed subfleets
if ($subfleets->count() === 0) {
$subfleets = $this->userSvc->getAllowableSubfleets($user);
}
// If subfleets are still empty return the flight
if ($subfleets->count() === 0) {
return $flight;
}
// Only allow aircraft that the user has access to by their rank or type rating
if (setting('pireps.restrict_aircraft_to_rank', false) || setting('pireps.restrict_aircraft_to_typerating', false)) {
$allowed_subfleets = $this->userSvc->getAllowableSubfleets($user)->pluck('id');
$subfleets = $subfleets->filter(fn (Subfleet $subfleet, $i) => $allowed_subfleets->contains($subfleet->id));
}
/*
* Only allow aircraft that are at the current departure airport
*/
$aircraft_at_dpt_airport = setting('pireps.only_aircraft_at_dpt_airport', false);
$aircraft_not_booked = setting('bids.block_aircraft', false);
if ($aircraft_at_dpt_airport || $aircraft_not_booked) {
// @phpstan-ignore-next-line
$subfleets->loadMissing('aircraft');
foreach ($subfleets as $subfleet) {
/** @var Subfleet $subfleet */
// @phpstan-ignore-next-line
$subfleet->aircraft = $subfleet->aircraft->filter(
function ($aircraft, $i) use ($user, $flight, $aircraft_at_dpt_airport, $aircraft_not_booked): bool {
if ($aircraft_at_dpt_airport && $aircraft->airport_id !== $flight->dpt_airport_id) {
return false;
}
if ($aircraft_not_booked && $aircraft->bid && $aircraft->bid->user_id !== $user->id) {
return false;
}
return true;
}
)->sortBy(fn (Aircraft $ac, int $_): bool => !empty($ac->bid));
}
}
/** @phpstan-ignore-next-line */
$flight->subfleets = $subfleets;
return $flight;
}
/**
* Check if this flight has a duplicate already.
*

View File

@ -112,7 +112,7 @@ class PirepService extends Service
// See if this user is allowed to fly this aircraft
if (setting('pireps.restrict_aircraft_to_rank', false)
&& !$this->userSvc->aircraftAllowed($user, $pirep->aircraft_id)) {
&& !$user->allowedAircraft()->whereKey($pirep->aircraft_id)->exists()) {
throw new AircraftPermissionDenied($user, $pirep->aircraft);
}

View File

@ -11,13 +11,11 @@ use App\Events\UserStateChanged;
use App\Events\UserStatsChanged;
use App\Exceptions\PilotIdNotFound;
use App\Exceptions\UserPilotIdExists;
use App\Models\Aircraft;
use App\Models\Airline;
use App\Models\Bid;
use App\Models\Pirep;
use App\Models\Rank;
use App\Models\Role;
use App\Models\Subfleet;
use App\Models\Typerating;
use App\Models\User;
use App\Models\UserField;
@ -30,16 +28,11 @@ use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Auth\Events\Registered;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
class UserService extends Service
{
public function __construct(
private readonly FareService $fareSvc,
) {}
/**
* Find the user and return them with all of the data properly attached
*/
@ -62,11 +55,11 @@ class UserService extends Service
return null;
}
if ($with_subfleets) {
// Load the proper subfleets to the rank
$user->rank->subfleets = $this->getAllowableSubfleets($user);
// @phpstan-ignore-next-line
$user->subfleets = $user->rank->subfleets;
if ($with_subfleets && $user->rank !== null) {
$user->rank->setRelation(
'subfleets',
$user->allowedSubfleets()->with(['aircraft', 'fares'])->get(),
);
}
return $user;
@ -332,63 +325,6 @@ class UserService extends Service
});
}
/**
* Return the subfleets this user is allowed access to,
* based on their current Rank and/or by Type Rating
*
* @param int|null $perPage Page size when paginating; null uses Laravel's default
* @return LengthAwarePaginator<int, Subfleet>|Collection<int, Subfleet>
*/
public function getAllowableSubfleets($user, bool $paginate = false, ?int $perPage = null)
{
$restrict_rank = setting('pireps.restrict_aircraft_to_rank', true);
$restrict_type = setting('pireps.restrict_aircraft_to_typerating', false);
$restricted_to = [];
if ($user) {
$rank_sf_array = $restrict_rank ? $user->rank->subfleets()->pluck('id')->toArray() : [];
$type_sf_array = $restrict_type ? $user->rated_subfleets->pluck('id')->toArray() : [];
if ($restrict_rank && !$restrict_type) {
$restricted_to = $rank_sf_array;
} elseif (!$restrict_rank && $restrict_type) {
$restricted_to = $type_sf_array;
} elseif ($restrict_rank && $restrict_type) {
$restricted_to = array_intersect($rank_sf_array, $type_sf_array);
}
} else {
$restrict_rank = false;
$restrict_type = false;
}
$subfleetsQuery = Subfleet::when($restrict_rank || $restrict_type, fn ($query) => $query->whereIn('id', $restricted_to))->with(['aircraft', 'aircraft.bid', 'fares']);
$mapper = function (Subfleet $sf): Subfleet {
// @phpstan-ignore-next-line
$sf->fares = $this->fareSvc->getForSubfleet($sf);
return $sf;
};
// through() preserves the paginator wrapper (links + meta);
// transform() on a non-paginated collection mutates it in place.
return $paginate
? $subfleetsQuery->paginate($perPage)->through($mapper)
: $subfleetsQuery->get()->transform($mapper);
}
/**
* Return a bool if a user is allowed to fly the current aircraft
*/
public function aircraftAllowed($user, $aircraft_id): bool
{
$aircraft = Aircraft::findOrFail($aircraft_id, ['subfleet_id']);
$subfleets = $this->getAllowableSubfleets($user);
$subfleet_ids = $subfleets->pluck('id')->toArray();
return \in_array($aircraft->subfleet_id, $subfleet_ids, true);
}
/**
* Change the user's state. PENDING to ACCEPTED, etc
* Send out an email

View File

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Models\Flight;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
final readonly class SubfleetAccessPolicy
{
public bool $rankRestricted;
public bool $typeRatingRestricted;
public bool $restrictToDepartureAirport;
public bool $blockBookedAircraft;
public function __construct(
public User $user,
public ?Flight $flight = null,
) {
$this->rankRestricted = (bool) setting('pireps.restrict_aircraft_to_rank', true);
$this->typeRatingRestricted = (bool) setting('pireps.restrict_aircraft_to_typerating', false);
$this->restrictToDepartureAirport = (bool) setting('pireps.only_aircraft_at_dpt_airport', false);
$this->blockBookedAircraft = (bool) setting('bids.block_aircraft', false);
}
/**
* Apply the rank + type-rating intersection to a Subfleet query.
* No constraints are added when both restriction settings are off.
*/
public function applyToSubfleets(Builder $query): Builder
{
if ($this->rankRestricted) {
$query->whereExists(function ($sub): void {
$sub->select(\DB::raw(1))
->from('subfleet_rank')
->whereColumn('subfleet_rank.subfleet_id', 'subfleets.id')
->where('subfleet_rank.rank_id', $this->user->rank_id);
});
}
if ($this->typeRatingRestricted) {
$query->whereExists(function ($sub): void {
$sub->select(\DB::raw(1))
->from('typerating_subfleet')
->join(
'typerating_user',
'typerating_user.typerating_id',
'=',
'typerating_subfleet.typerating_id'
)
->whereColumn('typerating_subfleet.subfleet_id', 'subfleets.id')
->where('typerating_user.user_id', $this->user->id);
});
}
return $query;
}
/**
* Apply subfleet, departure-airport, and bid-block constraints to an Aircraft query.
*/
public function applyToAircraft(Builder $query): Builder
{
$query->whereHas('subfleet', fn (Builder $sub): Builder => $this->applyToSubfleets($sub));
if ($this->restrictToDepartureAirport && $this->flight instanceof Flight) {
$query->where('aircraft.airport_id', $this->flight->dpt_airport_id);
}
if ($this->blockBookedAircraft) {
$query->whereNotExists(function ($sub): void {
$sub->select(\DB::raw(1))
->from('bids')
->whereColumn('bids.aircraft_id', 'aircraft.id')
->where('bids.user_id', '!=', $this->user->id);
});
}
return $query;
}
}

View File

@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class() extends Migration
{
public function up(): void
{
Schema::table('aircraft', function (Blueprint $table): void {
$table->index(['subfleet_id']);
});
Schema::table('bids', function (Blueprint $table): void {
$table->index(['aircraft_id']);
});
Schema::table('typerating_user', function (Blueprint $table): void {
$table->dropIndex(['typerating_id', 'user_id']);
$table->index(['user_id', 'typerating_id']);
});
Schema::table('typerating_subfleet', function (Blueprint $table): void {
$table->dropIndex(['typerating_id', 'subfleet_id']);
});
}
public function down(): void
{
Schema::table('typerating_subfleet', function (Blueprint $table): void {
$table->index(['typerating_id', 'subfleet_id']);
});
Schema::table('typerating_user', function (Blueprint $table): void {
$table->dropIndex(['user_id', 'typerating_id']);
$table->index(['typerating_id', 'user_id']);
});
Schema::table('bids', function (Blueprint $table): void {
$table->dropIndex(['aircraft_id']);
});
Schema::table('aircraft', function (Blueprint $table): void {
$table->dropIndex(['subfleet_id']);
});
}
};

View File

@ -0,0 +1,111 @@
<?php
use App\Models\Airline;
use App\Models\Airport;
use App\Models\Bid;
use App\Models\Flight;
use App\Models\Rank;
use App\Models\Subfleet;
use App\Models\User;
use Illuminate\Support\Facades\DB;
/**
* Pins the query count for the access-control hot paths. The point isn't the
* exact number it's that the count is O(1) wrt result-set size. If you change
* the policy / scopes and these numbers drift, decide whether the new shape is
* still constant-time before bumping the ceiling.
*/
function seedPagedFlights(int $count): array
{
$airport = Airport::factory()->create();
$airline = Airline::factory()->create();
$subfleet = Subfleet::factory()->hasAircraft(2)->create();
$rank = Rank::factory()->create();
$rank->subfleets()->attach($subfleet->id);
$user = User::factory()->create([
'rank_id' => $rank->id,
'airline_id' => $airline->id,
]);
$flights = collect();
for ($i = 0; $i < $count; $i++) {
$f = Flight::factory()->create([
'airline_id' => $airline->id,
'dpt_airport_id' => $airport->id,
'flight_number' => 1000 + $i,
]);
$f->subfleets()->attach($subfleet->id);
$flights->push($f);
}
return ['user' => $user, 'flights' => $flights, 'subfleet' => $subfleet, 'airline' => $airline];
}
it('runs O(1) queries when eager-loading accessible subfleets across many flights', function (): void {
updateSetting('pireps.restrict_aircraft_to_rank', true);
updateSetting('pireps.restrict_aircraft_to_typerating', false);
updateSetting('pireps.only_aircraft_at_dpt_airport', false);
updateSetting('bids.block_aircraft', false);
['user' => $user] = seedPagedFlights(25);
DB::flushQueryLog();
DB::enableQueryLog();
$page = Flight::query()
->withAccessibleSubfleets($user)
->paginate(25);
// Force iteration so eager-loads fire
$page->each(fn (Flight $f) => $f->subfleets->each(fn ($s) => $s->aircraft));
$queryCount = count(DB::getQueryLog());
DB::disableQueryLog();
// What's load-bearing is O(1) wrt row count — not the exact integer. A 25-flight
// page produces a constant set of queries (paginate count + select, plus eager
// loads of subfleets + aircraft + their pivots, plus the BelongsToThrough
// airline join the Aircraft model carries). Ceiling 16 keeps the test
// sensitive to a regression to N+1 (which would be 25+ on this fixture)
// without being brittle to Eloquent internals.
expect($queryCount)->toBeLessThanOrEqual(16);
});
it('runs O(1) queries for findBidsForUser-equivalent flow', function (): void {
updateSetting('pireps.restrict_aircraft_to_rank', true);
updateSetting('pireps.restrict_aircraft_to_typerating', false);
updateSetting('pireps.only_aircraft_at_dpt_airport', false);
updateSetting('bids.block_aircraft', false);
['user' => $user, 'flights' => $flights, 'subfleet' => $subfleet] = seedPagedFlights(20);
// Create 20 bids for this user, one per flight
$aircraftId = $subfleet->aircraft->first()->id;
foreach ($flights as $f) {
Bid::create([
'user_id' => $user->id,
'flight_id' => $f->id,
'aircraft_id' => $aircraftId,
]);
}
DB::flushQueryLog();
DB::enableQueryLog();
$bids = Bid::query()
->where('user_id', $user->id)
->with([
'flight' => fn ($q) => $q->withAccessibleSubfleets($user),
])
->get();
$bids->each(fn (Bid $b) => $b->flight?->subfleets->each(fn ($s) => $s->aircraft));
$queryCount = count(DB::getQueryLog());
DB::disableQueryLog();
expect($queryCount)->toBeLessThanOrEqual(16)
->and($bids->count())->toBe(20);
});

View File

@ -0,0 +1,202 @@
<?php
use App\Models\Airline;
use App\Models\Airport;
use App\Models\Bid;
use App\Models\Flight;
use App\Models\Rank;
use App\Models\Subfleet;
use App\Models\Typerating;
use App\Models\User;
/**
* Build a deterministic fixture covering the rank/type-rating/airport matrix.
*
* Topology:
* - rank R: attached to subfleets A, B
* - typerating T1: attached to subfleets B, C
* - typerating T2: attached to subfleet D
* - user holds T1 only
* - subfleet A has one aircraft at KJFK
* - subfleet B has one aircraft at KLAX
* - subfleet C has one aircraft at KJFK
* - subfleet D has one aircraft at KJFK
* - flight F departs KJFK with no pinned subfleets
*/
function seedAccessFixture(): array
{
Airport::factory()->create(['id' => 'KJFK']);
Airport::factory()->create(['id' => 'KLAX']);
$sfA = Subfleet::factory()->create(['name' => 'A']);
$sfB = Subfleet::factory()->create(['name' => 'B']);
$sfC = Subfleet::factory()->create(['name' => 'C']);
$sfD = Subfleet::factory()->create(['name' => 'D']);
$aircraftA = $sfA->aircraft()->create([
'name' => 'AC-A', 'subfleet_id' => $sfA->id, 'airport_id' => 'KJFK',
]);
$aircraftB = $sfB->aircraft()->create([
'name' => 'AC-B', 'subfleet_id' => $sfB->id, 'airport_id' => 'KLAX',
]);
$aircraftC = $sfC->aircraft()->create([
'name' => 'AC-C', 'subfleet_id' => $sfC->id, 'airport_id' => 'KJFK',
]);
$aircraftD = $sfD->aircraft()->create([
'name' => 'AC-D', 'subfleet_id' => $sfD->id, 'airport_id' => 'KJFK',
]);
$rank = Rank::factory()->create();
$rank->subfleets()->attach([$sfA->id, $sfB->id]);
$t1 = Typerating::create(['name' => 'T1', 'type' => 'T1', 'active' => 1]);
$t2 = Typerating::create(['name' => 'T2', 'type' => 'T2', 'active' => 1]);
$t1->subfleets()->attach([$sfB->id, $sfC->id]);
$t2->subfleets()->attach([$sfD->id]);
$user = User::factory()->create(['rank_id' => $rank->id]);
$t1->users()->attach($user->id);
$airline = Airline::factory()->create();
$flight = Flight::factory()->create([
'airline_id' => $airline->id,
'dpt_airport_id' => 'KJFK',
]);
return ['user' => $user, 'flight' => $flight, 'sfA' => $sfA, 'sfB' => $sfB, 'sfC' => $sfC, 'sfD' => $sfD, 'aircraftA' => $aircraftA, 'aircraftB' => $aircraftB, 'aircraftC' => $aircraftC, 'aircraftD' => $aircraftD];
}
dataset('access matrix',
// [rank_restrict, type_restrict, expected_subfleet_names]
// Topology:
// - rank attaches A, B
// - user's typerating attaches B, C
// - intersection: B
// - union (neither restricted): A, B, C, D
fn (): array => [
'no restrictions' => [false, false, ['A', 'B', 'C', 'D']],
'rank only' => [true, false, ['A', 'B']],
'typerating only' => [false, true, ['B', 'C']],
'rank AND typerating (intersect)' => [true, true, ['B']],
]);
it('returns the correct allowed subfleets per setting combination', function (
bool $rankRestrict,
bool $typeRestrict,
array $expectedNames,
): void {
updateSetting('pireps.restrict_aircraft_to_rank', $rankRestrict);
updateSetting('pireps.restrict_aircraft_to_typerating', $typeRestrict);
updateSetting('pireps.only_aircraft_at_dpt_airport', false);
updateSetting('bids.block_aircraft', false);
['user' => $user] = seedAccessFixture();
$names = $user->allowedSubfleets()->pluck('name')->sort()->values()->all();
expect($names)->toEqual($expectedNames);
})->with('access matrix');
it('applies airport restriction only when a flight context is provided', function (): void {
updateSetting('pireps.restrict_aircraft_to_rank', false);
updateSetting('pireps.restrict_aircraft_to_typerating', false);
updateSetting('pireps.only_aircraft_at_dpt_airport', true);
updateSetting('bids.block_aircraft', false);
['user' => $user, 'flight' => $flight] = seedAccessFixture();
// No flight: all 4 aircraft visible
$idsNoFlight = $user->allowedAircraft()->pluck('aircraft.id')->all();
expect($idsNoFlight)->toHaveCount(4);
// With flight departing KJFK: AC-B (at KLAX) excluded; AC-A, AC-C, AC-D included
$idsWithFlight = $user->allowedAircraft($flight)->pluck('aircraft.id')->all();
expect($idsWithFlight)->toHaveCount(3);
});
it('does not apply airport restriction when the setting is off', function (): void {
updateSetting('pireps.restrict_aircraft_to_rank', false);
updateSetting('pireps.restrict_aircraft_to_typerating', false);
updateSetting('pireps.only_aircraft_at_dpt_airport', false);
updateSetting('bids.block_aircraft', false);
['user' => $user, 'flight' => $flight] = seedAccessFixture();
expect($user->allowedAircraft($flight)->count())->toEqual(4);
});
it('excludes aircraft bid by another user when bid-block is on', function (): void {
updateSetting('pireps.restrict_aircraft_to_rank', false);
updateSetting('pireps.restrict_aircraft_to_typerating', false);
updateSetting('pireps.only_aircraft_at_dpt_airport', false);
updateSetting('bids.block_aircraft', true);
['user' => $user, 'flight' => $flight, 'aircraftA' => $aircraftA] = seedAccessFixture();
$otherUser = User::factory()->create();
Bid::create([
'user_id' => $otherUser->id,
'flight_id' => $flight->id,
'aircraft_id' => $aircraftA->id,
]);
$ids = $user->allowedAircraft()->pluck('aircraft.id')->all();
expect($ids)->not->toContain($aircraftA->id)
->and($ids)->toHaveCount(3);
});
it('includes the requesting user own bid', function (): void {
updateSetting('pireps.restrict_aircraft_to_rank', false);
updateSetting('pireps.restrict_aircraft_to_typerating', false);
updateSetting('pireps.only_aircraft_at_dpt_airport', false);
updateSetting('bids.block_aircraft', true);
['user' => $user, 'flight' => $flight, 'aircraftA' => $aircraftA] = seedAccessFixture();
Bid::create([
'user_id' => $user->id,
'flight_id' => $flight->id,
'aircraft_id' => $aircraftA->id,
]);
$ids = $user->allowedAircraft()->pluck('aircraft.id')->all();
expect($ids)->toContain($aircraftA->id);
});
it('ignores bid block when the setting is off', function (): void {
updateSetting('pireps.restrict_aircraft_to_rank', false);
updateSetting('pireps.restrict_aircraft_to_typerating', false);
updateSetting('pireps.only_aircraft_at_dpt_airport', false);
updateSetting('bids.block_aircraft', false);
['user' => $user, 'flight' => $flight, 'aircraftA' => $aircraftA] = seedAccessFixture();
$otherUser = User::factory()->create();
Bid::create([
'user_id' => $otherUser->id,
'flight_id' => $flight->id,
'aircraft_id' => $aircraftA->id,
]);
expect($user->allowedAircraft()->count())->toEqual(4);
});
it('combines flight-pinned subfleets via intersection with user access', function (): void {
updateSetting('pireps.restrict_aircraft_to_rank', true);
updateSetting('pireps.restrict_aircraft_to_typerating', false);
updateSetting('pireps.only_aircraft_at_dpt_airport', false);
updateSetting('bids.block_aircraft', false);
['user' => $user, 'flight' => $flight, 'sfA' => $sfA, 'sfC' => $sfC] = seedAccessFixture();
// Flight pinned to A + C; user (rank) allows A + B
$flight->subfleets()->attach([$sfA->id, $sfC->id]);
// Intersection: only A
$names = Subfleet::query()
->allowedFor($user)
->whereHas('flights', fn ($q) => $q->whereKey($flight->id))
->pluck('name')->all();
expect($names)->toEqual(['A']);
});

View File

@ -21,8 +21,6 @@ use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Hash;
test('rank subfleets', function (): void {
$userSvc = app(UserService::class);
// Add subfleets and aircraft, but also add another
// set of subfleets
$subfleet = Subfleet::factory()->hasAircraft(2)->count(2)->create();
@ -36,7 +34,7 @@ test('rank subfleets', function (): void {
$added_aircraft = $subfleetA->aircraft->pluck('id');
$subfleets = $userSvc->getAllowableSubfleets($user);
$subfleets = $user->allowedSubfleets()->with('aircraft')->get();
expect($subfleets->count())->toEqual(1);
$subfleet = $subfleets[0];
@ -76,7 +74,6 @@ test('rank subfleets', function (): void {
test('get all aircraft', function (): void {
$fareSvc = app(FareService::class);
$userSvc = app(UserService::class);
// Add subfleets and aircraft, but also add another
// set of subfleets
@ -110,7 +107,7 @@ test('get all aircraft', function (): void {
updateSetting('pireps.restrict_aircraft_to_rank', false);
$subfleets = $userSvc->getAllowableSubfleets($user);
$subfleets = $user->allowedSubfleets()->with(['aircraft', 'fares'])->get();
expect($subfleets->count())->toEqual(2);
$all_aircraft = array_merge(
@ -120,9 +117,11 @@ test('get all aircraft', function (): void {
expect($all_aircraft)->toEqual($added_aircraft);
$subfleetACalled = collect($subfleets)->firstWhere('id', $subfleetA->id);
expect($overrides['price'])->toEqual($subfleetACalled->fares[0]['price'])
->and($overrides['capacity'])->toEqual($subfleetACalled->fares[0]['capacity']);
// Override resolution now happens at the API Resource boundary (not in
// the loader). The /api/user/fleet check below pins that surface.
$subfleetACalled = $fareSvc->getForSubfleet($subfleets->firstWhere('id', $subfleetA->id));
expect($overrides['price'])->toEqual($subfleetACalled[0]->price)
->and($overrides['capacity'])->toEqual($subfleetACalled[0]->capacity);
/**
* Check via API, but should only show the single subfleet being returned

View File

@ -0,0 +1,47 @@
<?php
use App\Models\Flight;
use App\Models\User;
use App\Support\SubfleetAccessPolicy;
it('reads settings into typed flags', function (): void {
updateSetting('pireps.restrict_aircraft_to_rank', true);
updateSetting('pireps.restrict_aircraft_to_typerating', false);
updateSetting('pireps.only_aircraft_at_dpt_airport', true);
updateSetting('bids.block_aircraft', false);
$user = User::factory()->make();
$policy = new SubfleetAccessPolicy($user);
expect($policy->rankRestricted)->toBeTrue()
->and($policy->typeRatingRestricted)->toBeFalse()
->and($policy->restrictToDepartureAirport)->toBeTrue()
->and($policy->blockBookedAircraft)->toBeFalse();
});
it('exposes the constructed user and flight', function (): void {
$user = User::factory()->make();
$flight = Flight::factory()->make();
$policy = new SubfleetAccessPolicy($user, $flight);
expect($policy->user)->toBe($user)
->and($policy->flight)->toBe($flight);
$policyNoFlight = new SubfleetAccessPolicy($user);
expect($policyNoFlight->flight)->toBeNull();
});
it('inverts flags when settings flip', function (): void {
updateSetting('pireps.restrict_aircraft_to_rank', false);
updateSetting('pireps.restrict_aircraft_to_typerating', true);
updateSetting('pireps.only_aircraft_at_dpt_airport', false);
updateSetting('bids.block_aircraft', true);
$policy = new SubfleetAccessPolicy(User::factory()->make());
expect($policy->rankRestricted)->toBeFalse()
->and($policy->typeRatingRestricted)->toBeTrue()
->and($policy->restrictToDepartureAirport)->toBeFalse()
->and($policy->blockBookedAircraft)->toBeTrue();
});