feat(flights): with=bid fast-path + by-id search visibility
Add a controlled `bid` token to the flights get()/search() `with=` param.
When present, resolve the authenticated pilot's bid(s) on the flight, load
`bid.aircraft.subfleet.fares`, set `subfleets` to just those subfleet(s), and
skip accessibleSubfleetsFor entirely. No bid -> empty subfleets. `bid` is a
server-controlled whitelist token, never passed to Eloquent ->with().
search() also passes onlyActive = !filled('flight_id') so a keyed by-id lookup
returns the flight regardless of `visible` (parity with get()); browse search
still applies visible().
Cuts a bid briefing from expanding the whole accessible fleet (~234 subfleets on
the live VA) to ~5 indexed queries.
This commit is contained in:
parent
4c9a67c2e5
commit
36b5ead2d5
@ -10,6 +10,7 @@ use App\Http\Requests\SearchFlightsRequest;
|
||||
use App\Http\Resources\FlightResource;
|
||||
use App\Http\Resources\NavdataResource;
|
||||
use App\Models\Aircraft;
|
||||
use App\Models\Bid;
|
||||
use App\Models\Flight;
|
||||
use App\Models\SimBrief;
|
||||
use App\Models\User;
|
||||
@ -40,7 +41,7 @@ class FlightController extends Controller
|
||||
return $this->search($request);
|
||||
}
|
||||
|
||||
public function get(string $id): FlightResource
|
||||
public function get(string $id, Request $request): FlightResource
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
@ -55,10 +56,14 @@ class FlightController extends Controller
|
||||
])
|
||||
->findOrFail($id);
|
||||
|
||||
if ($this->hasBidToken($request)) {
|
||||
$this->loadBidSubfleets($flight, $user->id);
|
||||
} else {
|
||||
$flight->setRelation(
|
||||
'subfleets',
|
||||
$flight->accessibleSubfleetsFor($user, ['aircraft', 'fares']),
|
||||
);
|
||||
}
|
||||
|
||||
$flight = $this->fareSvc->getReconciledFaresForFlight($flight);
|
||||
|
||||
@ -70,7 +75,9 @@ class FlightController extends Controller
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$query = $this->flightSearchQuery->build($request)
|
||||
$onlyActive = !$request->filled('flight_id');
|
||||
|
||||
$query = $this->flightSearchQuery->build($request, $onlyActive)
|
||||
->whereHas('airline', function ($q): void {
|
||||
$q->where('active', true);
|
||||
});
|
||||
@ -103,9 +110,11 @@ class FlightController extends Controller
|
||||
$relations = explode(',', (string) $request->input('with', ''));
|
||||
}
|
||||
|
||||
$withBid = in_array('bid', $relations, true);
|
||||
|
||||
$query->with($with);
|
||||
|
||||
if (in_array('subfleets', $relations, true)) {
|
||||
if (!$withBid && in_array('subfleets', $relations, true)) {
|
||||
$query->withAccessibleSubfleets($user);
|
||||
}
|
||||
|
||||
@ -113,12 +122,51 @@ class FlightController extends Controller
|
||||
$flights = $query->paginate($perPage);
|
||||
|
||||
foreach ($flights as $flight) {
|
||||
if ($withBid) {
|
||||
$this->loadBidSubfleets($flight, $user->id);
|
||||
}
|
||||
|
||||
$this->fareSvc->getReconciledFaresForFlight($flight);
|
||||
}
|
||||
|
||||
return FlightResource::collection($flights);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the request carries the controlled `bid` token in `?with=`.
|
||||
*/
|
||||
private function hasBidToken(Request $request): bool
|
||||
{
|
||||
if (!$request->has('with')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tokens = array_map(trim(...), explode(',', (string) $request->input('with', '')));
|
||||
|
||||
return in_array('bid', $tokens, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the authenticated user's bid(s) on the given flight, load
|
||||
* `bid.aircraft.subfleet.fares`, and set the flight's `subfleets` relation
|
||||
* to exactly those subfleet(s). No fleet expansion is performed.
|
||||
* A pilot with no bid gets an empty `subfleets` collection.
|
||||
*/
|
||||
private function loadBidSubfleets(Flight $flight, int $userId): void
|
||||
{
|
||||
$bids = Bid::where('flight_id', $flight->id)
|
||||
->where('user_id', $userId)
|
||||
->with('aircraft.subfleet.fares')
|
||||
->get();
|
||||
|
||||
$subfleets = $bids->pluck('aircraft.subfleet')
|
||||
->filter()
|
||||
->unique('id')
|
||||
->values();
|
||||
|
||||
$flight->setRelation('subfleets', $subfleets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Output the flight briefing from simbrief or whatever other format
|
||||
*
|
||||
|
||||
@ -526,6 +526,11 @@ class Flight extends Model
|
||||
return $this->hasMany(FlightFieldValue::class, 'flight_id', 'id');
|
||||
}
|
||||
|
||||
public function bids(): HasMany
|
||||
{
|
||||
return $this->hasMany(Bid::class, 'flight_id');
|
||||
}
|
||||
|
||||
public function simbrief(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SimBrief::class, 'id', 'flight_id');
|
||||
|
||||
245
tests/Feature/FlightBidFastPathTest.php
Normal file
245
tests/Feature/FlightBidFastPathTest.php
Normal file
@ -0,0 +1,245 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Aircraft;
|
||||
use App\Models\Bid;
|
||||
use App\Models\Fare;
|
||||
use App\Models\Flight;
|
||||
use App\Models\Subfleet;
|
||||
use App\Models\User;
|
||||
use App\Services\FareService;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get() with=bid
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('get with bid token returns only bid subfleet and reconciled fares', function (): void {
|
||||
updateSetting('pireps.restrict_aircraft_to_rank', false);
|
||||
updateSetting('pireps.restrict_aircraft_to_typerating', false);
|
||||
|
||||
$fareSvc = app(FareService::class);
|
||||
|
||||
$user = User::factory()->create();
|
||||
apiAs($user);
|
||||
|
||||
$subfleet = Subfleet::factory()->create();
|
||||
$aircraft = Aircraft::factory()->create(['subfleet_id' => $subfleet->id]);
|
||||
|
||||
$fare = Fare::factory()->create();
|
||||
// Subfleet-level fare override — this is what the API returns (SubfleetResource applies
|
||||
// subfleet pivot; flight-level overrides are recomputed at PIREP-file time).
|
||||
$fareSvc->setForSubfleet($subfleet, $fare, ['price' => 100, 'capacity' => 50]);
|
||||
|
||||
$flight = Flight::factory()->create(['airline_id' => $user->airline_id]);
|
||||
$flight->subfleets()->attach($subfleet->id);
|
||||
|
||||
Bid::create([
|
||||
'user_id' => $user->id,
|
||||
'flight_id' => $flight->id,
|
||||
'aircraft_id' => $aircraft->id,
|
||||
]);
|
||||
|
||||
$res = $this->get('/api/flights/'.$flight->id.'?with=bid');
|
||||
$res->assertStatus(200);
|
||||
|
||||
$body = $res->json()['data'];
|
||||
// Subfleet is the bid's subfleet; fare is present with the subfleet-level price.
|
||||
expect($body['subfleets'])->toHaveCount(1)
|
||||
->and($body['subfleets'][0]['id'])->toBe($subfleet->id)
|
||||
->and($body['subfleets'][0]['fares'])->toHaveCount(1)
|
||||
->and((float) $body['subfleets'][0]['fares'][0]['price'])->toEqual(100.0);
|
||||
});
|
||||
|
||||
test('get with bid token returns empty subfleets when pilot has no bid', function (): void {
|
||||
updateSetting('pireps.restrict_aircraft_to_rank', false);
|
||||
|
||||
$user = User::factory()->create();
|
||||
apiAs($user);
|
||||
|
||||
$flight = Flight::factory()->create(['airline_id' => $user->airline_id]);
|
||||
|
||||
$res = $this->get('/api/flights/'.$flight->id.'?with=bid');
|
||||
$res->assertStatus(200);
|
||||
|
||||
expect($res->json()['data']['subfleets'])->toBeEmpty();
|
||||
});
|
||||
|
||||
test('get with bid token only shows the authenticated pilots own bid subfleet', function (): void {
|
||||
updateSetting('pireps.restrict_aircraft_to_rank', false);
|
||||
updateSetting('pireps.restrict_aircraft_to_typerating', false);
|
||||
|
||||
$pilotA = User::factory()->create();
|
||||
$pilotB = User::factory()->create();
|
||||
|
||||
$subfleetA = Subfleet::factory()->create();
|
||||
$aircraftA = Aircraft::factory()->create(['subfleet_id' => $subfleetA->id]);
|
||||
|
||||
$subfleetB = Subfleet::factory()->create();
|
||||
$aircraftB = Aircraft::factory()->create(['subfleet_id' => $subfleetB->id]);
|
||||
|
||||
$flight = Flight::factory()->create(['airline_id' => $pilotA->airline_id]);
|
||||
|
||||
Bid::create(['user_id' => $pilotA->id, 'flight_id' => $flight->id, 'aircraft_id' => $aircraftA->id]);
|
||||
Bid::create(['user_id' => $pilotB->id, 'flight_id' => $flight->id, 'aircraft_id' => $aircraftB->id]);
|
||||
|
||||
apiAs($pilotA);
|
||||
|
||||
$res = $this->get('/api/flights/'.$flight->id.'?with=bid');
|
||||
$res->assertStatus(200);
|
||||
|
||||
$subfleets = $res->json()['data']['subfleets'];
|
||||
expect($subfleets)->toHaveCount(1)
|
||||
->and($subfleets[0]['id'])->toBe($subfleetA->id);
|
||||
});
|
||||
|
||||
test('get without bid token performs full accessible fleet expansion', function (): void {
|
||||
updateSetting('pireps.restrict_aircraft_to_rank', false);
|
||||
updateSetting('pireps.restrict_aircraft_to_typerating', false);
|
||||
|
||||
$user = User::factory()->create();
|
||||
apiAs($user);
|
||||
|
||||
$subfleet1 = Subfleet::factory()->create();
|
||||
$subfleet2 = Subfleet::factory()->create();
|
||||
|
||||
$flight = Flight::factory()->create(['airline_id' => $user->airline_id]);
|
||||
$flight->subfleets()->attach([$subfleet1->id, $subfleet2->id]);
|
||||
|
||||
$res = $this->get('/api/flights/'.$flight->id);
|
||||
$res->assertStatus(200);
|
||||
|
||||
// Both pinned subfleets returned via accessibleSubfleetsFor (fleet expansion path)
|
||||
expect($res->json()['data']['subfleets'])->toHaveCount(2);
|
||||
});
|
||||
|
||||
test('get with bid token skips accessible fleet expansion (behavioral proof)', function (): void {
|
||||
updateSetting('pireps.restrict_aircraft_to_rank', false);
|
||||
updateSetting('pireps.restrict_aircraft_to_typerating', false);
|
||||
|
||||
$user = User::factory()->create();
|
||||
apiAs($user);
|
||||
|
||||
// Two subfleets both pinned to flight
|
||||
$subfleet1 = Subfleet::factory()->create();
|
||||
$subfleet2 = Subfleet::factory()->create();
|
||||
$aircraft1 = Aircraft::factory()->create(['subfleet_id' => $subfleet1->id]);
|
||||
|
||||
$flight = Flight::factory()->create(['airline_id' => $user->airline_id]);
|
||||
$flight->subfleets()->attach([$subfleet1->id, $subfleet2->id]);
|
||||
|
||||
// Bid only on subfleet1's aircraft
|
||||
Bid::create(['user_id' => $user->id, 'flight_id' => $flight->id, 'aircraft_id' => $aircraft1->id]);
|
||||
|
||||
$res = $this->get('/api/flights/'.$flight->id.'?with=bid');
|
||||
$res->assertStatus(200);
|
||||
|
||||
// Fleet expansion would return 2 subfleets; bid fast-path returns only 1
|
||||
$subfleets = $res->json()['data']['subfleets'];
|
||||
expect($subfleets)->toHaveCount(1)
|
||||
->and($subfleets[0]['id'])->toBe($subfleet1->id);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// search() with=bid
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('search with bid token decorates bid flights with subfleet and empty for non-bid flights', function (): void {
|
||||
updateSetting('pireps.restrict_aircraft_to_rank', false);
|
||||
updateSetting('pireps.restrict_aircraft_to_typerating', false);
|
||||
updateSetting('pilots.restrict_to_company', false);
|
||||
updateSetting('pilots.only_flights_from_current', false);
|
||||
|
||||
$user = User::factory()->create();
|
||||
apiAs($user);
|
||||
|
||||
$subfleet = Subfleet::factory()->create();
|
||||
$aircraft = Aircraft::factory()->create(['subfleet_id' => $subfleet->id]);
|
||||
|
||||
$flight1 = Flight::factory()->create(['airline_id' => $user->airline_id]);
|
||||
$flight2 = Flight::factory()->create(['airline_id' => $user->airline_id]);
|
||||
|
||||
Bid::create(['user_id' => $user->id, 'flight_id' => $flight1->id, 'aircraft_id' => $aircraft->id]);
|
||||
|
||||
$res = $this->get('/api/flights/search?with=bid');
|
||||
$res->assertStatus(200);
|
||||
|
||||
$data = collect($res->json()['data']);
|
||||
|
||||
$f1 = $data->firstWhere('id', $flight1->id);
|
||||
$f2 = $data->firstWhere('id', $flight2->id);
|
||||
|
||||
expect($f1)->not()->toBeNull()
|
||||
->and($f1['subfleets'])->toHaveCount(1)
|
||||
->and($f1['subfleets'][0]['id'])->toBe($subfleet->id);
|
||||
|
||||
expect($f2)->not()->toBeNull()
|
||||
->and($f2['subfleets'])->toBeEmpty();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// By-id search visibility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('search with flight_id returns invisible flight', function (): void {
|
||||
updateSetting('pireps.restrict_aircraft_to_rank', false);
|
||||
|
||||
$user = User::factory()->create();
|
||||
apiAs($user);
|
||||
|
||||
$flight = Flight::factory()->create([
|
||||
'airline_id' => $user->airline_id,
|
||||
'visible' => false,
|
||||
]);
|
||||
|
||||
$res = $this->get('/api/flights/search?flight_id='.$flight->id);
|
||||
$res->assertStatus(200);
|
||||
|
||||
$data = $res->json()['data'];
|
||||
expect($data)->toHaveCount(1)
|
||||
->and($data[0]['id'])->toBe($flight->id);
|
||||
});
|
||||
|
||||
test('browse search without flight_id hides invisible flights', function (): void {
|
||||
updateSetting('pireps.restrict_aircraft_to_rank', false);
|
||||
updateSetting('pilots.restrict_to_company', false);
|
||||
updateSetting('pilots.only_flights_from_current', false);
|
||||
|
||||
$user = User::factory()->create();
|
||||
apiAs($user);
|
||||
|
||||
$visible = Flight::factory()->create(['airline_id' => $user->airline_id, 'visible' => true]);
|
||||
$invisible = Flight::factory()->create(['airline_id' => $user->airline_id, 'visible' => false]);
|
||||
|
||||
$res = $this->get('/api/flights/search');
|
||||
$res->assertStatus(200);
|
||||
|
||||
$ids = collect($res->json()['data'])->pluck('id');
|
||||
expect($ids)->toContain($visible->id)
|
||||
->and($ids)->not()->toContain($invisible->id);
|
||||
});
|
||||
|
||||
test('search by flight_id with bid token returns invisible flight with bid subfleets', function (): void {
|
||||
updateSetting('pireps.restrict_aircraft_to_rank', false);
|
||||
updateSetting('pireps.restrict_aircraft_to_typerating', false);
|
||||
|
||||
$user = User::factory()->create();
|
||||
apiAs($user);
|
||||
|
||||
$subfleet = Subfleet::factory()->create();
|
||||
$aircraft = Aircraft::factory()->create(['subfleet_id' => $subfleet->id]);
|
||||
|
||||
$flight = Flight::factory()->create([
|
||||
'airline_id' => $user->airline_id,
|
||||
'visible' => false,
|
||||
]);
|
||||
|
||||
Bid::create(['user_id' => $user->id, 'flight_id' => $flight->id, 'aircraft_id' => $aircraft->id]);
|
||||
|
||||
$res = $this->get('/api/flights/search?flight_id='.$flight->id.'&with=bid');
|
||||
$res->assertStatus(200);
|
||||
|
||||
$data = $res->json()['data'];
|
||||
expect($data)->toHaveCount(1)
|
||||
->and($data[0]['id'])->toBe($flight->id)
|
||||
->and($data[0]['subfleets'])->toHaveCount(1)
|
||||
->and($data[0]['subfleets'][0]['id'])->toBe($subfleet->id);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user