diff --git a/app/Http/Controllers/AircraftController.php b/app/Http/Controllers/AircraftController.php index 29172f5..ca20897 100644 --- a/app/Http/Controllers/AircraftController.php +++ b/app/Http/Controllers/AircraftController.php @@ -175,6 +175,7 @@ public function create(Request $request) { 'mtow' => 'nullable|integer|min:0|max:1000000', 'mzfw' => 'nullable|integer|min:0|max:1000000', 'mlw' => 'nullable|integer|min:0|max:1000000', + 'service_ceiling' => 'nullable|integer|min:1000|max:60000', 'remarks' => 'nullable|string|max:1000', 'current_loc' => 'required|max:4', ]); @@ -243,6 +244,7 @@ public function edit(Request $request, Aircraft $aircraft) { 'mtow' => 'nullable|integer|min:0|max:1000000', 'mzfw' => 'nullable|integer|min:0|max:1000000', 'mlw' => 'nullable|integer|min:0|max:1000000', + 'service_ceiling' => 'nullable|integer|min:1000|max:60000', 'remarks' => 'nullable|string|max:1000', ]); @@ -269,6 +271,7 @@ public function edit(Request $request, Aircraft $aircraft) { $targetAircraft->mtow = $request->post('mtow'); $targetAircraft->mzfw = $request->post('mzfw'); $targetAircraft->mlw = $request->post('mlw'); + $targetAircraft->service_ceiling = $request->post('service_ceiling'); $targetAircraft->status = $finalStatus; $targetAircraft->remarks = $request->post('remarks'); diff --git a/app/Http/Controllers/Api/V1/AircraftAPIController.php b/app/Http/Controllers/Api/V1/AircraftAPIController.php index 2114077..4b63bdb 100644 --- a/app/Http/Controllers/Api/V1/AircraftAPIController.php +++ b/app/Http/Controllers/Api/V1/AircraftAPIController.php @@ -140,6 +140,7 @@ public function show(Request $request, Airline $airline, Aircraft $aircraft) * @bodyParam mtow integer Max take-off weight (kg), 0-1000000. Example: 78000 * @bodyParam mzfw integer Max zero-fuel weight (kg), 0-1000000. Example: 62500 * @bodyParam mlw integer Max landing weight (kg), 0-1000000. Example: 66000 + * @bodyParam service_ceiling integer Service ceiling (ft), 1000-60000; PIREPs with a higher cruise altitude are rejected. Example: 41000 * @bodyParam remarks string Free-text remarks, max 1000 chars. Example: Delivered new. * * @response 201 { diff --git a/app/Http/Controllers/Api/V1/FlightAPIController.php b/app/Http/Controllers/Api/V1/FlightAPIController.php index 5436c40..6ae1691 100644 --- a/app/Http/Controllers/Api/V1/FlightAPIController.php +++ b/app/Http/Controllers/Api/V1/FlightAPIController.php @@ -100,8 +100,8 @@ public function index(Request $request, Airline $airline) * @bodyParam callsign string required Radio callsign, 1-4 digits optionally followed by up to 2 letters. Example: 421 * @bodyParam crzalt integer required Cruise altitude in feet, max 50000. Example: 36000 * @bodyParam blockoff string required Block-off time (UTC), format Y-m-d H:i:s. Example: 2026-07-11 10:00:00 - * @bodyParam blockon string required Block-on time (UTC), format Y-m-d H:i:s. Example: 2026-07-11 11:30:00 - * @bodyParam burned_fuel number required Fuel burned. Example: 4200 + * @bodyParam blockon string required Block-on time (UTC), format Y-m-d H:i:s. Must be after blockoff; flight duration may not exceed 26 hours. Example: 2026-07-11 11:30:00 + * @bodyParam burned_fuel integer required Fuel burned, in the airline's unit (min 1, max 600000). Example: 4200 * @bodyParam route string required Filed route string. Example: SOVAT UL610 KONAN * @bodyParam online_network_id integer required Online network ID (must exist in online_networks). Example: 1 * @bodyParam remarks string Optional remarks (letters, digits, spaces, . , -). Example: Smooth flight. diff --git a/app/Http/Controllers/FlightController.php b/app/Http/Controllers/FlightController.php index 235f494..65461ac 100644 --- a/app/Http/Controllers/FlightController.php +++ b/app/Http/Controllers/FlightController.php @@ -15,6 +15,7 @@ use App\Notifications\PirepRejected; use Illuminate\Support\Facades\Notification as NotificationFacade; use App\Support\ActivityLevel; +use App\Support\FlightSanity; class FlightController extends Controller { @@ -174,7 +175,7 @@ public function addFlight(Request $request) "crzalt" => "numeric|max:50000|digits_between:1,5|required", "blockoff" => "required", "blockon" => "required", - "burned_fuel" => "numeric|required", + "burned_fuel" => "required|integer|min:" . Flight::MIN_BURNED_FUEL . "|max:" . Flight::MAX_BURNED_FUEL, "route" => "required", "online_network_id" => "required", "remarks" => [ @@ -198,6 +199,17 @@ public function addFlight(Request $request) ]); } + // Physical sanity: ordering, not-in-future, and within the max duration + $timingError = FlightSanity::timingError( + $request->post("blockoff"), + $request->post("blockon"), + ); + if ($timingError !== null) { + throw ValidationException::withMessages([ + "blockon" => $timingError, + ]); + } + // Check if the aircraft is indeed part of the active airline AND is active $aircraft = Aircraft::query() ->where("id", "=", $request->post("aircraft_id")) @@ -210,6 +222,17 @@ public function addFlight(Request $request) ]); } + // Physical sanity: cruise altitude must not exceed the airframe's service ceiling + $altitudeError = FlightSanity::altitudeError( + (int) $validated["crzalt"], + $aircraft->service_ceiling !== null ? (int) $aircraft->service_ceiling : null, + ); + if ($altitudeError !== null) { + throw ValidationException::withMessages([ + "crzalt" => $altitudeError, + ]); + } + // Location continuity: the flight must depart from where the airframe currently is if ($tempAirline->location_continuity && strtoupper($validated["departure_icao"]) !== strtoupper((string) $aircraft->current_loc)) { diff --git a/app/Http/Requests/Api/V1/StoreAircraftRequest.php b/app/Http/Requests/Api/V1/StoreAircraftRequest.php index 3aa7f63..aa355f9 100644 --- a/app/Http/Requests/Api/V1/StoreAircraftRequest.php +++ b/app/Http/Requests/Api/V1/StoreAircraftRequest.php @@ -32,6 +32,7 @@ public function rules(): array 'mtow' => 'nullable|integer|min:0|max:1000000', 'mzfw' => 'nullable|integer|min:0|max:1000000', 'mlw' => 'nullable|integer|min:0|max:1000000', + 'service_ceiling' => 'nullable|integer|min:1000|max:60000', 'remarks' => 'nullable|string|max:1000', 'current_loc' => 'required|max:4|exists:airports,icao_code', ]; diff --git a/app/Http/Requests/Api/V1/StoreFlightRequest.php b/app/Http/Requests/Api/V1/StoreFlightRequest.php index e3eeda1..6dc7e33 100644 --- a/app/Http/Requests/Api/V1/StoreFlightRequest.php +++ b/app/Http/Requests/Api/V1/StoreFlightRequest.php @@ -4,6 +4,8 @@ use App\Models\Aircraft; use App\Models\Airline; +use App\Models\Flight; +use App\Support\FlightSanity; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Validator; @@ -40,7 +42,7 @@ public function rules(): array 'crzalt' => 'numeric|max:50000|digits_between:1,5|required', 'blockoff' => 'required|date_format:Y-m-d H:i:s', 'blockon' => 'required|date_format:Y-m-d H:i:s', - 'burned_fuel' => 'numeric|required', + 'burned_fuel' => 'required|integer|min:' . Flight::MIN_BURNED_FUEL . '|max:' . Flight::MAX_BURNED_FUEL, 'route' => 'required', 'online_network_id' => 'required|exists:online_networks,id', 'remarks' => 'nullable|regex:/^[\pL\s\d\.\,\-]+$/u', @@ -70,6 +72,15 @@ function (Validator $validator) { return; } + // Physical sanity: ordering, not-in-future, and within the max duration + $timingError = FlightSanity::timingError( + $this->input('blockoff'), + $this->input('blockon'), + ); + if ($timingError !== null) { + $validator->errors()->add('blockon', $timingError); + } + $aircraft = $this->aircraft(); if ($aircraft === null) { $validator->errors()->add('aircraft_id', 'This aircraft is not available or not owned by your airline.'); @@ -77,6 +88,15 @@ function (Validator $validator) { return; } + // Physical sanity: cruise altitude must not exceed the airframe's service ceiling + $altitudeError = FlightSanity::altitudeError( + (int) $this->input('crzalt'), + $aircraft->service_ceiling !== null ? (int) $aircraft->service_ceiling : null, + ); + if ($altitudeError !== null) { + $validator->errors()->add('crzalt', $altitudeError); + } + // Location continuity: the flight must depart from where the airframe currently is if ($this->airline()->location_continuity && $this->input('departure_icao') !== strtoupper((string) $aircraft->current_loc)) { diff --git a/app/Http/Resources/V1/AircraftResource.php b/app/Http/Resources/V1/AircraftResource.php index 8c46abe..0d93d07 100644 --- a/app/Http/Resources/V1/AircraftResource.php +++ b/app/Http/Resources/V1/AircraftResource.php @@ -29,6 +29,7 @@ public function toArray(Request $request): array 'mtow' => $this->mtow, 'mzfw' => $this->mzfw, 'mlw' => $this->mlw, + 'service_ceiling' => $this->service_ceiling, 'remarks' => $this->remarks, // We don't have to return this, since this can only be accessed through a route, which already specifies the airline. diff --git a/app/Models/Aircraft.php b/app/Models/Aircraft.php index 5a5e90d..0733200 100644 --- a/app/Models/Aircraft.php +++ b/app/Models/Aircraft.php @@ -24,6 +24,7 @@ class Aircraft extends Model 'mtow', 'mzfw', 'mlw', + 'service_ceiling', 'remarks', 'current_loc', 'used_by', diff --git a/app/Models/Flight.php b/app/Models/Flight.php index d596984..6fb9552 100644 --- a/app/Models/Flight.php +++ b/app/Models/Flight.php @@ -14,6 +14,24 @@ class Flight extends Model { use HasFactory, LogsModelActivity; + /** + * Physical sanity bounds enforced when a PIREP is filed. These are universal + * aviation limits, not per-instance policy, so they live as constants. + * + * The longest airliner flight on record is the Boeing 777-200LR ferry (2005, + * ~22h42m airborne); ~23h airborne + taxi, rounded up with a safety net. + */ + public const MAX_DURATION_MINUTES = 26 * 60; + + /** Fuel must be positive; 0/negative is nonsense. */ + public const MIN_BURNED_FUEL = 1; + + /** + * Generous upper ceiling to catch typos in either unit: an A380 holds + * ~256 t (~564,000 lb) of fuel, so this clears every real airframe. + */ + public const MAX_BURNED_FUEL = 600000; + protected $fillable = [ 'airline_id', 'callsign', diff --git a/app/Support/FlightSanity.php b/app/Support/FlightSanity.php new file mode 100644 index 0000000..336fb0a --- /dev/null +++ b/app/Support/FlightSanity.php @@ -0,0 +1,85 @@ +lessThanOrEqualTo($off)) { + return 'Block on time must be after block off time.'; + } + + if ($on->greaterThan(Carbon::now('UTC')->addMinutes(self::FUTURE_TOLERANCE_MINUTES))) { + return 'Block on time cannot be in the future - a PIREP records a flight that already happened.'; + } + + $maxHours = intdiv(Flight::MAX_DURATION_MINUTES, 60); + + if ($off->diffInMinutes($on) > Flight::MAX_DURATION_MINUTES) { + return "Flight duration exceeds the maximum of {$maxHours} hours."; + } + + return null; + } + + /** + * Reject a cruise altitude above the aircraft's service ceiling. The ceiling + * is optional per airframe (nullable column): when it is not set, no + * per-aircraft limit applies and only the global crzalt field rule governs. + * Returns a human-readable error or null. + */ + public static function altitudeError(?int $crzalt, ?int $serviceCeiling): ?string + { + if ($crzalt === null || $serviceCeiling === null) { + return null; + } + + if ($crzalt > $serviceCeiling) { + return 'Cruise altitude exceeds the service ceiling of this aircraft (' + . number_format($serviceCeiling) . ' ft).'; + } + + return null; + } +} diff --git a/database/factories/AircraftFactory.php b/database/factories/AircraftFactory.php index 7550c3a..3a86382 100644 --- a/database/factories/AircraftFactory.php +++ b/database/factories/AircraftFactory.php @@ -25,9 +25,18 @@ public function definition(): array 'current_loc' => fn () => Airport::factory()->create()->icao_code, 'used_by' => Airline::factory(), 'status' => Aircraft::STATUS_ACTIVE, + 'service_ceiling' => 41000, ]; } + /** + * Pin the service ceiling (feet). + */ + public function serviceCeiling(int $ft): static + { + return $this->state(fn () => ['service_ceiling' => $ft]); + } + /** * Park the airframe at a specific, already-existing airport. */ diff --git a/database/migrations/2026_07_22_000000_add_service_ceiling_to_aircraft_table.php b/database/migrations/2026_07_22_000000_add_service_ceiling_to_aircraft_table.php new file mode 100644 index 0000000..b4d3c97 --- /dev/null +++ b/database/migrations/2026_07_22_000000_add_service_ceiling_to_aircraft_table.php @@ -0,0 +1,24 @@ +integer('service_ceiling')->nullable()->after('mlw'); + }); + } + + public function down(): void + { + Schema::table('aircraft', function (Blueprint $table) { + $table->dropColumn('service_ceiling'); + }); + } +}; diff --git a/resources/views/fleet/create.blade.php b/resources/views/fleet/create.blade.php index e4882b3..916acf9 100644 --- a/resources/views/fleet/create.blade.php +++ b/resources/views/fleet/create.blade.php @@ -155,6 +155,16 @@
Maximum Landing Weight
+
+
+ +
+ + ft +
+
Max cruise altitude; PIREPs above it are rejected
+
+
diff --git a/resources/views/fleet/detail.blade.php b/resources/views/fleet/detail.blade.php index da77f4a..9b6b474 100644 --- a/resources/views/fleet/detail.blade.php +++ b/resources/views/fleet/detail.blade.php @@ -204,6 +204,12 @@ {{ $aircraft->mlw ? number_format($aircraft->mlw) . ' kg' : 'N/A' }} + + Service Ceiling + + {{ $aircraft->service_ceiling ? number_format($aircraft->service_ceiling) . ' ft' : 'N/A' }} + + diff --git a/resources/views/fleet/edit.blade.php b/resources/views/fleet/edit.blade.php index c8f1d33..79c2401 100644 --- a/resources/views/fleet/edit.blade.php +++ b/resources/views/fleet/edit.blade.php @@ -133,6 +133,16 @@
Maximum Landing Weight
+
+
+ +
+ + ft +
+
Max cruise altitude; PIREPs above it are rejected
+
+
diff --git a/resources/views/flights/add.blade.php b/resources/views/flights/add.blade.php index dc04c0e..dcda498 100644 --- a/resources/views/flights/add.blade.php +++ b/resources/views/flights/add.blade.php @@ -141,7 +141,7 @@
@php $isLbs = session('activeairline')->unit_is_lbs; @endphp - +
diff --git a/tests/Feature/Api/FlightApiTest.php b/tests/Feature/Api/FlightApiTest.php index 478a8a4..621d13a 100644 --- a/tests/Feature/Api/FlightApiTest.php +++ b/tests/Feature/Api/FlightApiTest.php @@ -71,6 +71,118 @@ public function test_a_member_can_file_a_pirep(): void ]); } + public function test_non_positive_fuel_is_rejected(): void + { + $airline = Airline::factory()->create(); + $aircraft = Aircraft::factory()->create(['used_by' => $airline->id]); + $pilot = $this->memberOf($airline, 'Pilot'); + + Sanctum::actingAs($pilot); + + $this->postJson( + route('api.v1.airlines.flights.store', $airline), + ['burned_fuel' => 0] + $this->payload($aircraft), + ) + ->assertStatus(422) + ->assertJsonValidationErrors('burned_fuel'); + + $this->assertDatabaseCount('flights', 0); + } + + public function test_absurd_fuel_is_rejected(): void + { + $airline = Airline::factory()->create(); + $aircraft = Aircraft::factory()->create(['used_by' => $airline->id]); + $pilot = $this->memberOf($airline, 'Pilot'); + + Sanctum::actingAs($pilot); + + $this->postJson( + route('api.v1.airlines.flights.store', $airline), + ['burned_fuel' => 9_000_000] + $this->payload($aircraft), + ) + ->assertStatus(422) + ->assertJsonValidationErrors('burned_fuel'); + + $this->assertDatabaseCount('flights', 0); + } + + public function test_block_on_before_block_off_is_rejected(): void + { + $airline = Airline::factory()->create(); + $aircraft = Aircraft::factory()->create(['used_by' => $airline->id]); + $pilot = $this->memberOf($airline, 'Pilot'); + + Sanctum::actingAs($pilot); + + $this->postJson(route('api.v1.airlines.flights.store', $airline), [ + 'blockoff' => '2026-07-11 11:30:00', + 'blockon' => '2026-07-11 10:00:00', + ] + $this->payload($aircraft)) + ->assertStatus(422) + ->assertJsonValidationErrors('blockon'); + + $this->assertDatabaseCount('flights', 0); + } + + public function test_flight_longer_than_the_max_duration_is_rejected(): void + { + $airline = Airline::factory()->create(); + $aircraft = Aircraft::factory()->create(['used_by' => $airline->id]); + $pilot = $this->memberOf($airline, 'Pilot'); + + Sanctum::actingAs($pilot); + + // 27 hours, above the 26-hour cap. + $this->postJson(route('api.v1.airlines.flights.store', $airline), [ + 'blockoff' => '2026-07-11 10:00:00', + 'blockon' => '2026-07-12 13:00:00', + ] + $this->payload($aircraft)) + ->assertStatus(422) + ->assertJsonValidationErrors('blockon'); + + $this->assertDatabaseCount('flights', 0); + } + + public function test_a_flight_in_the_future_is_rejected(): void + { + $airline = Airline::factory()->create(); + $aircraft = Aircraft::factory()->create(['used_by' => $airline->id]); + $pilot = $this->memberOf($airline, 'Pilot'); + + Sanctum::actingAs($pilot); + + $off = now('UTC')->addDays(2); + $on = $off->copy()->addMinutes(90); + + $this->postJson(route('api.v1.airlines.flights.store', $airline), [ + 'blockoff' => $off->format('Y-m-d H:i:s'), + 'blockon' => $on->format('Y-m-d H:i:s'), + ] + $this->payload($aircraft)) + ->assertStatus(422) + ->assertJsonValidationErrors('blockon'); + + $this->assertDatabaseCount('flights', 0); + } + + public function test_cruise_altitude_above_the_service_ceiling_is_rejected(): void + { + $airline = Airline::factory()->create(); + $aircraft = Aircraft::factory()->serviceCeiling(41000)->create(['used_by' => $airline->id]); + $pilot = $this->memberOf($airline, 'Pilot'); + + Sanctum::actingAs($pilot); + + $this->postJson( + route('api.v1.airlines.flights.store', $airline), + ['crzalt' => 45000] + $this->payload($aircraft), + ) + ->assertStatus(422) + ->assertJsonValidationErrors('crzalt'); + + $this->assertDatabaseCount('flights', 0); + } + public function test_location_continuity_is_enforced_on_the_api(): void { $airline = Airline::factory()->locationContinuity()->create(); diff --git a/tests/Feature/FlightSanityWebTest.php b/tests/Feature/FlightSanityWebTest.php new file mode 100644 index 0000000..018a584 --- /dev/null +++ b/tests/Feature/FlightSanityWebTest.php @@ -0,0 +1,144 @@ +seedReferenceData(); + } + + /** + * @return array{0: Airline, 1: Aircraft, 2: User} + */ + private function airlineWithAircraft(): array + { + $airline = Airline::factory()->create(); + $aircraft = Aircraft::factory()->create(['used_by' => $airline->id]); + $pilot = $this->memberOf($airline, 'Pilot'); + + return [$airline, $aircraft, $pilot]; + } + + private function payload(Aircraft $aircraft, array $overrides = []): array + { + return $overrides + [ + 'flightnumber' => 421, + 'departure_icao' => 'EDDF', + 'arrival_icao' => 'EGLL', + 'aircraft_id' => $aircraft->id, + 'callsign' => '421', + 'crzalt' => 35000, + 'blockoff' => '2026-07-11 10:00:00', + 'blockon' => '2026-07-11 11:30:00', + 'burned_fuel' => 5000, + 'route' => 'DCT', + 'online_network_id' => 1, + ]; + } + + private function file(User $pilot, Airline $airline, array $payload) + { + return $this->actingAs($pilot) + ->withSession(['activeairline' => $airline]) + ->post(route('flightadd'), $payload); + } + + public function test_non_positive_fuel_is_rejected(): void + { + [$airline, $aircraft, $pilot] = $this->airlineWithAircraft(); + + $this->file($pilot, $airline, $this->payload($aircraft, ['burned_fuel' => 0])) + ->assertSessionHasErrors('burned_fuel'); + + $this->assertDatabaseCount('flights', 0); + } + + public function test_absurd_fuel_is_rejected(): void + { + [$airline, $aircraft, $pilot] = $this->airlineWithAircraft(); + + $this->file($pilot, $airline, $this->payload($aircraft, ['burned_fuel' => 9_000_000])) + ->assertSessionHasErrors('burned_fuel'); + + $this->assertDatabaseCount('flights', 0); + } + + public function test_block_on_before_block_off_is_rejected(): void + { + [$airline, $aircraft, $pilot] = $this->airlineWithAircraft(); + + $this->file($pilot, $airline, $this->payload($aircraft, [ + 'blockoff' => '2026-07-11 11:30:00', + 'blockon' => '2026-07-11 10:00:00', + ]))->assertSessionHasErrors('blockon'); + + $this->assertDatabaseCount('flights', 0); + } + + public function test_flight_longer_than_the_max_duration_is_rejected(): void + { + [$airline, $aircraft, $pilot] = $this->airlineWithAircraft(); + + // 27 hours, above the 26-hour cap. + $this->file($pilot, $airline, $this->payload($aircraft, [ + 'blockoff' => '2026-07-11 10:00:00', + 'blockon' => '2026-07-12 13:00:00', + ]))->assertSessionHasErrors('blockon'); + + $this->assertDatabaseCount('flights', 0); + } + + public function test_a_flight_in_the_future_is_rejected(): void + { + [$airline, $aircraft, $pilot] = $this->airlineWithAircraft(); + + $off = now('UTC')->addDays(2); + $on = $off->copy()->addMinutes(90); + + $this->file($pilot, $airline, $this->payload($aircraft, [ + 'blockoff' => $off->format('Y-m-d H:i:s'), + 'blockon' => $on->format('Y-m-d H:i:s'), + ]))->assertSessionHasErrors('blockon'); + + $this->assertDatabaseCount('flights', 0); + } + + public function test_cruise_altitude_above_the_service_ceiling_is_rejected(): void + { + $airline = Airline::factory()->create(); + $aircraft = Aircraft::factory()->serviceCeiling(41000)->create(['used_by' => $airline->id]); + $pilot = $this->memberOf($airline, 'Pilot'); + + $this->file($pilot, $airline, $this->payload($aircraft, ['crzalt' => 45000])) + ->assertSessionHasErrors('crzalt'); + + $this->assertDatabaseCount('flights', 0); + } + + public function test_a_valid_flight_is_still_accepted(): void + { + [$airline, $aircraft, $pilot] = $this->airlineWithAircraft(); + + $this->file($pilot, $airline, $this->payload($aircraft)) + ->assertRedirect(route('flightlist')); + + $this->assertDatabaseCount('flights', 1); + } +} diff --git a/tests/Unit/FlightSanityTest.php b/tests/Unit/FlightSanityTest.php new file mode 100644 index 0000000..0371080 --- /dev/null +++ b/tests/Unit/FlightSanityTest.php @@ -0,0 +1,98 @@ +subDays($days)->startOfHour(); + } + + public function test_a_normal_flight_passes(): void + { + $off = $this->ago(2); + $on = $off->copy()->addMinutes(90); + + $this->assertNull(FlightSanity::timingError($off->format('Y-m-d H:i:s'), $on->format('Y-m-d H:i:s'))); + } + + public function test_the_web_datetime_local_format_is_accepted(): void + { + // datetime-local inputs post `Y-m-d\TH:i` with no seconds. + $off = $this->ago(2); + $on = $off->copy()->addMinutes(90); + + $this->assertNull(FlightSanity::timingError($off->format('Y-m-d\TH:i'), $on->format('Y-m-d\TH:i'))); + } + + public function test_exactly_the_max_duration_passes(): void + { + // 26 hours on the nose is allowed. + $off = $this->ago(3); + $on = $off->copy()->addHours(26); + + $this->assertNull(FlightSanity::timingError($off->format('Y-m-d H:i:s'), $on->format('Y-m-d H:i:s'))); + } + + public function test_one_minute_over_the_max_duration_is_rejected(): void + { + $off = $this->ago(3); + $on = $off->copy()->addHours(26)->addMinute(); + + $this->assertNotNull(FlightSanity::timingError($off->format('Y-m-d H:i:s'), $on->format('Y-m-d H:i:s'))); + } + + public function test_block_on_before_block_off_is_rejected(): void + { + $off = $this->ago(2); + $on = $off->copy()->subMinutes(90); + + $this->assertNotNull(FlightSanity::timingError($off->format('Y-m-d H:i:s'), $on->format('Y-m-d H:i:s'))); + } + + public function test_equal_block_times_are_rejected(): void + { + $off = $this->ago(2); + + $this->assertNotNull(FlightSanity::timingError($off->format('Y-m-d H:i:s'), $off->format('Y-m-d H:i:s'))); + } + + public function test_a_flight_in_the_future_is_rejected(): void + { + $off = Carbon::now('UTC')->addDays(2); + $on = $off->copy()->addMinutes(90); + + $this->assertNotNull(FlightSanity::timingError($off->format('Y-m-d H:i:s'), $on->format('Y-m-d H:i:s'))); + } + + public function test_missing_timestamps_defer_to_the_field_rules(): void + { + $this->assertNull(FlightSanity::timingError(null, '2026-07-11 11:30:00')); + $this->assertNull(FlightSanity::timingError('2026-07-11 10:00:00', null)); + } + + public function test_altitude_within_the_service_ceiling_passes(): void + { + $this->assertNull(FlightSanity::altitudeError(35000, 41000)); + } + + public function test_altitude_above_the_service_ceiling_is_rejected(): void + { + $this->assertNotNull(FlightSanity::altitudeError(45000, 41000)); + } + + public function test_altitude_check_is_skipped_when_no_ceiling_is_set(): void + { + $this->assertNull(FlightSanity::altitudeError(45000, null)); + } +}