From e0eb9c5ceac578a4fb0d9cf858f7c37cb9cbe719 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 26 Apr 2026 15:22:02 +0200 Subject: [PATCH 01/46] feat: unit tests (#231) --- .env.example | 1 + .github/workflows/ci.yaml | 67 +++ .github/workflows/linting.yaml | 3 +- .github/workflows/tests.yaml | 51 ++ .gitignore | 3 +- app/Models/UserList.php | 9 + config/app.php | 2 +- database/factories/AirportFactory.php | 41 ++ database/factories/UserFactory.php | 12 +- ...303_remove_unique_in_scener_simulators.php | 5 + database/seeders/DatabaseSeeder.php | 24 - database/seeders/TestAirportSeeder.php | 137 +++++ phpunit.xml | 55 +- resources/views/changelog.blade.php | 6 + routes/web.php | 2 +- tests/Feature/AdminTest.php | 59 ++ tests/Feature/ApiTest.php | 198 ++++++ .../{ExampleTest.php => ResponseTest.php} | 3 +- tests/Feature/SceneryTest.php | 182 ++++++ tests/Feature/SearchTest.php | 568 ++++++++++++++++++ tests/Feature/UserAccountTest.php | 309 ++++++++++ tests/Feature/UserListTest.php | 402 +++++++++++++ tests/TestCase.php | 34 ++ tests/Unit/.gitkeep | 0 tests/Unit/ExampleTest.php | 18 - 25 files changed, 2111 insertions(+), 80 deletions(-) create mode 100644 .github/workflows/tests.yaml create mode 100644 database/factories/AirportFactory.php delete mode 100644 database/seeders/DatabaseSeeder.php create mode 100644 database/seeders/TestAirportSeeder.php create mode 100644 tests/Feature/AdminTest.php create mode 100644 tests/Feature/ApiTest.php rename tests/Feature/{ExampleTest.php => ResponseTest.php} (76%) create mode 100644 tests/Feature/SceneryTest.php create mode 100644 tests/Feature/SearchTest.php create mode 100644 tests/Feature/UserAccountTest.php create mode 100644 tests/Feature/UserListTest.php create mode 100644 tests/Unit/.gitkeep delete mode 100644 tests/Unit/ExampleTest.php diff --git a/.env.example b/.env.example index 598054a7..3c35e863 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ APP_URL=https://where2fly.today APP_ENV=production APP_DEBUG=false +APP_KEY= APP_AIRLABS_KEY= APP_FSADDONCOMPARE_KEY= diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5ef8969b..237925b8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -5,14 +5,81 @@ on: push: paths-ignore: - "**.md" + pull_request: + paths-ignore: + - "**.md" env: IMAGE_NAME: blt950/where2fly jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup dependencies + uses: ./.github/actions/setup-dependencies + with: + path: "." + + - name: Check formatting + run: ./vendor/bin/pint --test + + tests: + name: PHPUnit + runs-on: ubuntu-latest + needs: lint + + services: + mysql: + image: mysql:8 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: where2fly_test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping --silent" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup dependencies + uses: ./.github/actions/setup-dependencies + with: + path: "." + setup-node: "true" + + - name: Build frontend assets + run: npm run build + + - name: Copy environment file + run: cp .env.example .env + + - name: Generate app key + run: php artisan key:generate + + - name: Run tests + run: php artisan test + env: + DB_HOST: 127.0.0.1 + DB_PORT: 3306 + DB_DATABASE: where2fly_test + DB_USERNAME: root + DB_PASSWORD: root + build-container: name: Build Container runs-on: ubuntu-latest + needs: tests + if: github.event_name == 'push' steps: - name: configure docker buildx uses: docker/setup-buildx-action@v3 diff --git a/.github/workflows/linting.yaml b/.github/workflows/linting.yaml index 917f2779..d3193337 100644 --- a/.github/workflows/linting.yaml +++ b/.github/workflows/linting.yaml @@ -1,8 +1,7 @@ --- name: Linting -on: - push: +on: {} # Superseded by ci.yaml jobs: lint-formatting: diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 00000000..c081d3fd --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,51 @@ +--- +name: "Tests" + +on: {} # Superseded by ci.yaml + +jobs: + tests: + name: PHPUnit + runs-on: ubuntu-latest + + services: + mysql: + image: mysql:8 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: where2fly_test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping --silent" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup dependencies + uses: ./.github/actions/setup-dependencies + with: + path: "." + setup-node: "true" + + - name: Build frontend assets + run: npm run build + + - name: Copy environment file + run: cp .env.example .env + + - name: Generate app key + run: php artisan key:generate + + - name: Run tests + run: php artisan test + env: + DB_HOST: 127.0.0.1 + DB_PORT: 3306 + DB_DATABASE: where2fly_test + DB_USERNAME: root + DB_PASSWORD: root diff --git a/.gitignore b/.gitignore index 43cbe0e9..b81db143 100644 --- a/.gitignore +++ b/.gitignore @@ -52,4 +52,5 @@ apache/* /resources/fonts/fa-sharp-thin-100.ttf /resources/fonts/fa-sharp-thin-100.woff2 /resources/fonts/fa-v4compatibility.ttf -/resources/fonts/fa-v4compatibility.woff2 \ No newline at end of file +/resources/fonts/fa-v4compatibility.woff2 +.phpunit.cache/test-results diff --git a/app/Models/UserList.php b/app/Models/UserList.php index e517c794..8809d810 100644 --- a/app/Models/UserList.php +++ b/app/Models/UserList.php @@ -9,6 +9,15 @@ class UserList extends Model { use HasFactory; + protected $fillable = [ + 'name', + 'color', + 'simulator_id', + 'user_id', + 'public', + 'hidden', + ]; + public function user() { return $this->belongsTo(User::class); diff --git a/config/app.php b/config/app.php index a2fcf3a5..5e5a874a 100644 --- a/config/app.php +++ b/config/app.php @@ -42,7 +42,7 @@ */ 'name' => env('APP_NAME', 'Where2Fly'), - 'version' => 'v2.8.2', + 'version' => 'v3.0.0', 'searchVersion' => 2, /* diff --git a/database/factories/AirportFactory.php b/database/factories/AirportFactory.php new file mode 100644 index 00000000..75d1b30f --- /dev/null +++ b/database/factories/AirportFactory.php @@ -0,0 +1,41 @@ + + */ +class AirportFactory extends Factory +{ + protected $model = Airport::class; + + public function definition(): array + { + $lat = fake()->latitude(); + $lon = fake()->longitude(); + + return [ + 'icao' => strtoupper(fake()->unique()->lexify('K???')), + 'type' => 'medium_airport', + 'name' => fake()->city() . ' Airport', + 'latitude_deg' => $lat, + 'longitude_deg' => $lon, + 'elevation_ft' => fake()->numberBetween(0, 5000), + 'continent' => 'NA', + 'iso_country' => 'US', + 'iso_region' => 'US-CA', + 'municipality' => fake()->city(), + 'scheduled_service' => 'yes', + 'gps_code' => null, + 'iata_code' => null, + 'local_code' => null, + 'total_score' => null, + 'coordinates' => new Point($lat, $lon, Srid::WGS84->value), + ]; + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index be6299a4..896855bf 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -19,14 +19,22 @@ class UserFactory extends Factory public function definition() { return [ - 'name' => fake()->name(), - 'email' => fake()->safeEmail(), + 'username' => fake()->unique()->userName(), + 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password 'remember_token' => Str::random(10), + 'admin' => false, ]; } + public function admin() + { + return $this->state(fn (array $attributes) => [ + 'admin' => true, + ]); + } + /** * Indicate that the model's email address should be unverified. * diff --git a/database/migrations/2025_02_02_094303_remove_unique_in_scener_simulators.php b/database/migrations/2025_02_02_094303_remove_unique_in_scener_simulators.php index ba908f9d..8ad2486a 100644 --- a/database/migrations/2025_02_02_094303_remove_unique_in_scener_simulators.php +++ b/database/migrations/2025_02_02_094303_remove_unique_in_scener_simulators.php @@ -12,6 +12,10 @@ public function up(): void { Schema::table('scenery_simulators', function (Blueprint $table) { + // Add a plain index so the foreign key on scenery_id still has + // index support after the composite unique index is removed. + // MySQL refuses to drop an index that is the sole index backing a FK. + $table->index('scenery_id'); $table->dropUnique(['scenery_id', 'simulator_id']); }); } @@ -23,6 +27,7 @@ public function down(): void { Schema::table('scenery_simulators', function (Blueprint $table) { $table->unique(['scenery_id', 'simulator_id']); + $table->dropIndex(['scenery_id']); }); } }; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php deleted file mode 100644 index 76d96dc7..00000000 --- a/database/seeders/DatabaseSeeder.php +++ /dev/null @@ -1,24 +0,0 @@ -create(); - - // \App\Models\User::factory()->create([ - // 'name' => 'Test User', - // 'email' => 'test@example.com', - // ]); - } -} diff --git a/database/seeders/TestAirportSeeder.php b/database/seeders/TestAirportSeeder.php new file mode 100644 index 00000000..9c4f7029 --- /dev/null +++ b/database/seeders/TestAirportSeeder.php @@ -0,0 +1,137 @@ + 'KLAX', 'name' => 'Los Angeles International', 'lat' => 33.9425, 'lon' => -118.4081, 'type' => 'large_airport', 'iso_country' => 'US', 'iso_region' => 'US-CA', 'continent' => 'NA', 'municipality' => 'Los Angeles'], + ['icao' => 'KSFO', 'name' => 'San Francisco International', 'lat' => 37.6189, 'lon' => -122.3750, 'type' => 'large_airport', 'iso_country' => 'US', 'iso_region' => 'US-CA', 'continent' => 'NA', 'municipality' => 'San Francisco'], + ['icao' => 'KJFK', 'name' => 'John F Kennedy International', 'lat' => 40.6398, 'lon' => -73.7789, 'type' => 'large_airport', 'iso_country' => 'US', 'iso_region' => 'US-NY', 'continent' => 'NA', 'municipality' => 'New York'], + ['icao' => 'KORD', 'name' => "Chicago O'Hare International", 'lat' => 41.9742, 'lon' => -87.9073, 'type' => 'large_airport', 'iso_country' => 'US', 'iso_region' => 'US-IL', 'continent' => 'NA', 'municipality' => 'Chicago'], + ['icao' => 'EGLL', 'name' => 'London Heathrow', 'lat' => 51.4775, 'lon' => -0.4614, 'type' => 'large_airport', 'iso_country' => 'GB', 'iso_region' => 'GB-ENG', 'continent' => 'EU', 'municipality' => 'London'], + ['icao' => 'EDDM', 'name' => 'Munich Airport', 'lat' => 48.3538, 'lon' => 11.7861, 'type' => 'large_airport', 'iso_country' => 'DE', 'iso_region' => 'DE-BY', 'continent' => 'EU', 'municipality' => 'Munich'], + ['icao' => 'EDDF', 'name' => 'Frankfurt Airport', 'lat' => 50.0333, 'lon' => 8.5706, 'type' => 'large_airport', 'iso_country' => 'DE', 'iso_region' => 'DE-HE', 'continent' => 'EU', 'municipality' => 'Frankfurt'], + ['icao' => 'EHAM', 'name' => 'Amsterdam Schiphol', 'lat' => 52.3086, 'lon' => 4.7639, 'type' => 'large_airport', 'iso_country' => 'NL', 'iso_region' => 'NL-NH', 'continent' => 'EU', 'municipality' => 'Amsterdam'], + ['icao' => 'LFPG', 'name' => 'Paris Charles de Gaulle', 'lat' => 49.0128, 'lon' => 2.5500, 'type' => 'large_airport', 'iso_country' => 'FR', 'iso_region' => 'FR-IDF', 'continent' => 'EU', 'municipality' => 'Paris'], + ['icao' => 'RJTT', 'name' => 'Tokyo Haneda', 'lat' => 35.5494, 'lon' => 139.7798, 'type' => 'large_airport', 'iso_country' => 'JP', 'iso_region' => 'JP-13', 'continent' => 'AS', 'municipality' => 'Tokyo'], + ['icao' => 'EDDS', 'name' => 'Stuttgart Airport', 'lat' => 48.69, 'lon' => 9.22, 'type' => 'large_airport', 'iso_country' => 'DE', 'iso_region' => 'DE-BW', 'continent' => 'EU', 'municipality' => 'Stuttgart', 'elevation_ft' => 1276], + ['icao' => 'EDDB', 'name' => 'Berlin Brandenburg Airport', 'lat' => 52.36, 'lon' => 13.50, 'type' => 'large_airport', 'iso_country' => 'DE', 'iso_region' => 'DE-BR', 'continent' => 'EU', 'municipality' => 'Berlin', 'elevation_ft' => 157], + ['icao' => 'ENBR', 'name' => 'Bergen Airport, Flesland', 'lat' => 60.29, 'lon' => 5.22, 'type' => 'large_airport', 'iso_country' => 'NO', 'iso_region' => 'NO-46', 'continent' => 'EU', 'municipality' => 'Bergen', 'elevation_ft' => 170], + ['icao' => 'ENGM', 'name' => 'Oslo Airport, Gardermoen', 'lat' => 60.19, 'lon' => 11.10, 'type' => 'large_airport', 'iso_country' => 'NO', 'iso_region' => 'NO-32', 'continent' => 'EU', 'municipality' => 'Oslo', 'elevation_ft' => 681], + ['icao' => 'ENSO', 'name' => 'Stord Airport, Sørstokken', 'lat' => 59.79, 'lon' => 5.34, 'type' => 'medium_airport', 'iso_country' => 'NO', 'iso_region' => 'NO-46', 'continent' => 'EU', 'municipality' => 'Leirvik', 'elevation_ft' => 160], + ['icao' => 'ENTC', 'name' => 'Tromsø Airport, Langnes', 'lat' => 69.68, 'lon' => 18.92, 'type' => 'large_airport', 'iso_country' => 'NO', 'iso_region' => 'NO-55', 'continent' => 'EU', 'municipality' => 'Tromsø', 'elevation_ft' => 31], + ['icao' => 'ENTO', 'name' => 'Sandefjord Airport, Torp', 'lat' => 59.19, 'lon' => 10.26, 'type' => 'medium_airport', 'iso_country' => 'NO', 'iso_region' => 'NO-39', 'continent' => 'EU', 'municipality' => 'Torp', 'elevation_ft' => 286], + ['icao' => 'ENSG', 'name' => 'Sogndal Airport, Haukåsen', 'lat' => 61.16, 'lon' => 7.14, 'type' => 'small_airport', 'iso_country' => 'NO', 'iso_region' => 'NO-46', 'continent' => 'EU', 'municipality' => 'Sogndal', 'elevation_ft' => 1633], + ['icao' => 'ENSD', 'name' => 'Sandane Airport, Anda', 'lat' => 61.83, 'lon' => 6.11, 'type' => 'small_airport', 'iso_country' => 'NO', 'iso_region' => 'NO-46', 'continent' => 'EU', 'municipality' => 'Sandane', 'elevation_ft' => 196], + // ENBO: unlighted runway — used to test destinationRunwayLights=-1 + ['icao' => 'ENBO', 'name' => 'Bodø Airport', 'lat' => 67.27, 'lon' => 14.37, 'type' => 'medium_airport', 'iso_country' => 'NO', 'iso_region' => 'NO-18', 'continent' => 'EU', 'municipality' => 'Bodø', 'elevation_ft' => 42, 'rwy_lighted' => false], + // ENHF: military airbase — used to test destinationAirbases=1 + ['icao' => 'ENHF', 'name' => 'Hammerfest Airport', 'lat' => 70.68, 'lon' => 23.67, 'type' => 'small_airport', 'iso_country' => 'NO', 'iso_region' => 'NO-54', 'continent' => 'EU', 'municipality' => 'Hammerfest', 'elevation_ft' => 266, 'w2f_airforcebase' => true], + ]; + + foreach ($airports as $data) { + $airport = Airport::updateOrCreate( + ['icao' => $data['icao']], + [ + 'local_code' => $data['icao'], + 'name' => $data['name'], + 'type' => $data['type'], + 'latitude_deg' => $data['lat'], + 'longitude_deg' => $data['lon'], + 'continent' => $data['continent'], + 'iso_country' => $data['iso_country'], + 'iso_region' => $data['iso_region'], + 'municipality' => $data['municipality'], + 'elevation_ft' => $data['elevation_ft'] ?? 100, + 'scheduled_service' => 'yes', + 'w2f_has_open_runway' => true, + 'w2f_airforcebase' => $data['w2f_airforcebase'] ?? false, + 'coordinates' => new Point($data['lat'], $data['lon'], Srid::WGS84->value), + ] + ); + + Runway::firstOrCreate( + ['airport_id' => $airport->id, 'le_ident' => '18'], + [ + 'airport_ident' => $data['icao'], + 'length_ft' => $data['rwy_length_ft'] ?? 9000, + 'width_ft' => 150, + 'surface' => 'ASP', + 'lighted' => $data['rwy_lighted'] ?? true, + 'closed' => false, + 'le_heading' => 180.0, + 'he_ident' => '36', + 'he_heading' => 360.0, + ] + ); + } + + $metars = [ + 'EDDF' => ['last_update' => now(), 'metar' => 'AUTO 03010KT 360V060 CAVOK 12/02 Q1024 NOSIG', 'wind_direction' => 30, 'wind_speed' => 10, 'wind_gusts' => 0, 'temperature' => 12], + 'EDDS' => ['last_update' => now(), 'metar' => 'AUTO VRB03KT CAVOK 14/M01 Q1022 NOSIG', 'wind_direction' => 0, 'wind_speed' => 0, 'wind_gusts' => 0, 'temperature' => 14], + 'EGLL' => ['last_update' => now(), 'metar' => 'COR AUTO 10006KT 010V140 9999 NCD 14/07 Q1026 NOSIG', 'wind_direction' => 100, 'wind_speed' => 6, 'wind_gusts' => 0, 'temperature' => 14], + 'EHAM' => ['last_update' => now(), 'metar' => '08006KT 010V130 9999 FEW027 11/03 Q1026 NOSIG', 'wind_direction' => 80, 'wind_speed' => 6, 'wind_gusts' => 0, 'temperature' => 11], + 'ENBR' => ['last_update' => now(), 'metar' => '36007KT 310V030 CAVOK 05/M07 Q1025 NOSIG RMK WIND 1200FT 02008KT', 'wind_direction' => 360, 'wind_speed' => 7, 'wind_gusts' => 0, 'temperature' => 5], + 'ENGM' => ['last_update' => now(), 'metar' => '01012KT 340V040 CAVOK 07/M11 Q1021 NOSIG', 'wind_direction' => 10, 'wind_speed' => 12, 'wind_gusts' => 0, 'temperature' => 7], + 'ENTC' => ['last_update' => '2020-01-01 00:00:00', 'metar' => '31003KT 270V330 1800 -SN VV011 01/M01 Q1010 TEMPO 0800 SHSN VV006 RMK WIND 2600FT 34024KT', 'wind_direction' => 310, 'wind_speed' => 3, 'wind_gusts' => 0, 'temperature' => 1], + 'ENTO' => ['last_update' => now(), 'metar' => '34006KT CAVOK 07/M10 Q1023 NOSIG', 'wind_direction' => 340, 'wind_speed' => 6, 'wind_gusts' => 0, 'temperature' => 7], + 'KLAX' => ['last_update' => now(), 'metar' => '23005KT 10SM FEW012 BKN025 OVC049 13/12 A2988 RMK AO2 RAE38 SLP117 P0018 60029 T01330117 58007', 'wind_direction' => 230, 'wind_speed' => 5, 'wind_gusts' => 0, 'temperature' => 13], + 'KORD' => ['last_update' => now(), 'metar' => '35003KT 10SM CLR 06/04 A3003 RMK AO2 SLP171 T00610039 53002', 'wind_direction' => 350, 'wind_speed' => 3, 'wind_gusts' => 0, 'temperature' => 6], + 'KSFO' => ['last_update' => now(), 'metar' => '19005KT 10SM FEW012 BKN023 OVC045 13/09 A2984 RMK AO2 SLP103 T01280094 56005 $', 'wind_direction' => 190, 'wind_speed' => 5, 'wind_gusts' => 0, 'temperature' => 13], + 'LFPG' => ['last_update' => now(), 'metar' => '07010KT 040V100 CAVOK 16/02 Q1023 NOSIG', 'wind_direction' => 70, 'wind_speed' => 10, 'wind_gusts' => 0, 'temperature' => 16], + 'RJTT' => ['last_update' => now(), 'metar' => '17009KT 9999 FEW030 BKN/// 19/11 Q1017 NOSIG', 'wind_direction' => 170, 'wind_speed' => 9, 'wind_gusts' => 0, 'temperature' => 19], + 'ENSG' => ['last_update' => now(), 'metar' => 'AUTO 21004KT 110V260 9999 BKN045/// OVC177/// 01/M07 Q1022 RMK WIND 3806FT 26007KT', 'wind_direction' => 210, 'wind_speed' => 4, 'wind_gusts' => 0, 'temperature' => 1], + 'ENSD' => ['last_update' => now(), 'metar' => 'AUTO 27003KT 9999 FEW025/// OVC057/// 04/M03 Q1025 RMK WIND RWY 26 26005KT WIND 1126FT 28004KT', 'wind_direction' => 270, 'wind_speed' => 3, 'wind_gusts' => 0, 'temperature' => 4], + 'EDDB' => ['last_update' => now(), 'metar' => 'AUTO 02007KT 340V070 CAVOK 11/00 Q1024 NOSIG', 'wind_direction' => 20, 'wind_speed' => 7, 'wind_gusts' => 0, 'temperature' => 11], + 'EDDM' => ['last_update' => now(), 'metar' => 'AUTO 06007KT 9999 FEW020 09/M02 Q1023 NOSIG', 'wind_direction' => 60, 'wind_speed' => 7, 'wind_gusts' => 0, 'temperature' => 9], + 'ENBO' => ['last_update' => now(), 'metar' => 'AUTO 02005KT 9999 FEW025 06/01 Q1015 NOSIG', 'wind_direction' => 20, 'wind_speed' => 5, 'wind_gusts' => 0, 'temperature' => 6], + 'ENHF' => ['last_update' => now(), 'metar' => 'AUTO 31004KT 9999 FEW018 03/M01 Q1012 NOSIG', 'wind_direction' => 310, 'wind_speed' => 4, 'wind_gusts' => 0, 'temperature' => 3], + ]; + + foreach ($metars as $icao => $data) { + $airport = Airport::where('icao', $icao)->first(); + if ($airport) { + Metar::updateOrCreate(['airport_id' => $airport->id], $data); + } + } + + // Seed airport scores — one unique score type per airport for clean filter tests + $scores = [ + 'ENBR' => ['METAR_WINDY'], + 'ENTO' => ['METAR_GUSTS'], + 'ENSG' => ['METAR_CROSSWIND'], + 'ENSD' => ['METAR_SIGHT'], + 'EDDB' => ['METAR_RVR'], + 'EHAM' => ['METAR_CEILING'], + 'ENTC' => ['METAR_FOGGY', 'VATSIM_POPULAR'], + 'EGLL' => ['METAR_HEAVY_RAIN'], + 'EDDF' => ['METAR_HEAVY_SNOW'], + 'EDDS' => ['METAR_THUNDERSTORM'], + 'LFPG' => ['VATSIM_ATC'], + 'EDDM' => ['VATSIM_EVENT'], + ]; + + foreach ($scores as $icao => $reasons) { + $airport = Airport::where('icao', $icao)->first(); + if ($airport) { + foreach ($reasons as $reason) { + AirportScore::firstOrCreate( + ['airport_id' => $airport->id, 'reason' => $reason], + ['score' => 1] + ); + } + } + } + } +} diff --git a/phpunit.xml b/phpunit.xml index 2ac86a18..bce62282 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,31 +1,28 @@ - - - - ./tests/Unit - - - ./tests/Feature - - - - - ./app - - - - - - - - - - - - - + + + + ./tests/Unit + + + ./tests/Feature + + + + + + + + + + + + + + + + + ./app + + diff --git a/resources/views/changelog.blade.php b/resources/views/changelog.blade.php index 66174ac1..93c79d01 100644 --- a/resources/views/changelog.blade.php +++ b/resources/views/changelog.blade.php @@ -12,6 +12,12 @@
+

v3.0.0

+ 2026-XX-XX +
    +
  • A lot changes under the hood for stability and performance
  • +
+

v2.8.2

2026-04-25
    diff --git a/routes/web.php b/routes/web.php index 99711438..a298b55c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -91,7 +91,7 @@ // Account pages Route::get('/account', 'show')->middleware(['auth', 'verified'])->name('user.account'); - Route::post('/account/delete', 'destroy')->name('user.delete'); + Route::post('/account/delete', 'destroy')->middleware('auth')->name('user.delete'); // Account email verification Route::get('/account/verify/', 'verifyNotice')->name('verification.notice'); diff --git a/tests/Feature/AdminTest.php b/tests/Feature/AdminTest.php new file mode 100644 index 00000000..95e19488 --- /dev/null +++ b/tests/Feature/AdminTest.php @@ -0,0 +1,59 @@ +get('/admin'); + + $response->assertRedirect(route('login')); + } + + public function test_non_admin_user_cannot_access_admin_page(): void + { + $user = User::factory()->create(['admin' => false]); + + $response = $this->actingAs($user)->get('/admin'); + + $response->assertForbidden(); + } + + public function test_admin_user_can_access_admin_page(): void + { + $admin = User::factory()->admin()->create(); + + $response = $this->actingAs($admin)->get('/admin'); + + $response->assertStatus(200); + } + + // ------------------------------------------------------------------------- + // UserPolicy reflects admin flag correctly + // ------------------------------------------------------------------------- + + public function test_admin_flag_is_false_for_regular_users(): void + { + $user = User::factory()->create(['admin' => false]); + + $this->assertFalse((bool) $user->admin); + } + + public function test_admin_flag_is_true_for_admin_users(): void + { + $admin = User::factory()->admin()->create(); + + $this->assertTrue((bool) $admin->admin); + } +} diff --git a/tests/Feature/ApiTest.php b/tests/Feature/ApiTest.php new file mode 100644 index 00000000..5863889c --- /dev/null +++ b/tests/Feature/ApiTest.php @@ -0,0 +1,198 @@ + 'test-api-key-' . uniqid(), + 'name' => 'Test Key', + 'ip_address' => '*', + 'disabled' => false, + ]); + } + + // ------------------------------------------------------------------------- + // Authentication check endpoint (no token required) + // ------------------------------------------------------------------------- + + public function test_authenticated_check_returns_false_for_guest(): void + { + $response = $this->getJson('/api/user/authenticated'); + + $response->assertStatus(200) + ->assertJson(['data' => false]); + } + + public function test_authenticated_check_returns_true_for_logged_in_user(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user, 'sanctum')->getJson('/api/user/authenticated'); + + $response->assertStatus(200) + ->assertJson(['data' => true]); + } + + // ------------------------------------------------------------------------- + // Airport map data endpoint (no token required) + // ------------------------------------------------------------------------- + + public function test_get_mapdata_from_known_icao_returns_success(): void + { + $response = $this->postJson('/api/mapdata/icao', ['icao' => 'KLAX']); + + $response->assertStatus(200) + ->assertJsonPath('message', 'Success'); + } + + public function test_get_mapdata_from_unknown_icao_returns_validation_error(): void + { + $response = $this->postJson('/api/mapdata/icao', ['icao' => 'ZZZZ']); + + // 'exists:airports,icao' validation fails → 422 + $response->assertStatus(422); + } + + public function test_get_mapdata_requires_icao_field(): void + { + $response = $this->postJson('/api/mapdata/icao', []); + + $response->assertStatus(422) + ->assertJsonValidationErrors('icao'); + } + + // ------------------------------------------------------------------------- + // Protected API search (requires bearer token) + // ------------------------------------------------------------------------- + + public function test_api_search_requires_valid_token(): void + { + $response = $this->postJson('/api/search', [ + 'codeletter' => 'JS', + 'departure' => 'KLAX', + ]); + + $response->assertStatus(401); + } + + public function test_api_search_is_rejected_with_disabled_token(): void + { + $key = $this->createApiKey(); + $key->disabled = true; + $key->save(); + + $response = $this->withToken($key->key)->postJson('/api/search', [ + 'codeletter' => 'JS', + 'departure' => 'KLAX', + ]); + + $response->assertStatus(403); + } + + public function test_api_search_fails_validation_without_codeletter(): void + { + $key = $this->createApiKey(); + + $response = $this->withToken($key->key)->postJson('/api/search', [ + 'departure' => 'KLAX', + ]); + + // Missing required 'codeletter' → 422 unprocessable + $response->assertStatus(422); + } + + public function test_api_search_returns_error_when_both_arrival_and_departure_given(): void + { + $key = $this->createApiKey(); + + $response = $this->withToken($key->key)->postJson('/api/search', [ + 'codeletter' => 'JS', + 'departure' => 'KLAX', + 'arrival' => 'KSFO', + ]); + + $response->assertStatus(400); + } + + public function test_api_search_returns_error_when_neither_arrival_nor_departure_given(): void + { + $key = $this->createApiKey(); + + $response = $this->withToken($key->key)->postJson('/api/search', [ + 'codeletter' => 'JS', + ]); + + $response->assertStatus(400); + } + + // ------------------------------------------------------------------------- + // Authenticated list airports endpoint + // ------------------------------------------------------------------------- + + public function test_list_airports_endpoint_requires_auth(): void + { + $response = $this->getJson('/api/lists/airports'); + + $response->assertStatus(401); + } + + public function test_list_airports_endpoint_returns_success_for_authenticated_user(): void + { + $user = User::factory()->create(); + $simulator = Simulator::first(); + UserList::create([ + 'name' => 'API Test List', + 'color' => '#FF0000', + 'simulator_id' => $simulator->id, + 'user_id' => $user->id, + 'public' => false, + 'hidden' => false, + ]); + + $response = $this->actingAs($user, 'sanctum')->getJson('/api/lists/airports'); + + $response->assertStatus(200) + ->assertJsonPath('message', 'Success'); + } + + // ------------------------------------------------------------------------- + // Fetching data fails gracefully + // ------------------------------------------------------------------------- + + public function test_airport_endpoint_returns_422_for_nonexistent_airport_id(): void + { + $response = $this->postJson('/api/airport', [ + 'secondaryAirport' => 999999, + ]); + + // 'exists:airports,id' fails → 422 + $response->assertStatus(422); + } + + public function test_airport_endpoint_returns_422_when_required_field_is_missing(): void + { + $response = $this->postJson('/api/airport', []); + + $response->assertStatus(422) + ->assertJsonValidationErrors('secondaryAirport'); + } +} diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ResponseTest.php similarity index 76% rename from tests/Feature/ExampleTest.php rename to tests/Feature/ResponseTest.php index 1eafba61..d98b468e 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ResponseTest.php @@ -2,10 +2,9 @@ namespace Tests\Feature; -// use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; -class ExampleTest extends TestCase +class ResponseTest extends TestCase { /** * A basic test example. diff --git a/tests/Feature/SceneryTest.php b/tests/Feature/SceneryTest.php new file mode 100644 index 00000000..4c1c8805 --- /dev/null +++ b/tests/Feature/SceneryTest.php @@ -0,0 +1,182 @@ +get('/scenery/create'); + + $response->assertRedirect(route('login')); + } + + public function test_authenticated_user_can_view_scenery_create_page(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->get('/scenery/create'); + + $response->assertStatus(200); + } + + public function test_scenery_create_page_shows_existing_developers_for_known_icao(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->get('/scenery/create?airport=EDDM'); + + $response->assertStatus(200); + } + + // ------------------------------------------------------------------------- + // Store suggestion + // ------------------------------------------------------------------------- + + public function test_user_can_submit_a_scenery_suggestion(): void + { + $user = User::factory()->create(); + $simulator = Simulator::first(); + + $response = $this->actingAs($user)->post('/scenery/create', [ + 'icao' => 'EDDM', + 'developer' => 'Aerosoft', + 'link' => 'https://www.aerosoft.com/eddm', + 'payware' => '1', + 'simulators' => [$simulator->id], + ]); + + $response->assertRedirect(route('scenery.create')); + $response->assertSessionHas('success'); + $this->assertDatabaseHas('scenery_developers', [ + 'icao' => 'EDDM', + 'developer' => 'Aerosoft', + ]); + } + + public function test_scenery_suggestion_is_stored_as_unpublished(): void + { + $user = User::factory()->create(); + $simulator = Simulator::first(); + + $this->actingAs($user)->post('/scenery/create', [ + 'icao' => 'EDDM', + 'developer' => 'Orbx', + 'link' => 'https://orbxdirect.com/eddm', + 'payware' => '1', + 'simulators' => [$simulator->id], + ]); + + $developer = SceneryDeveloper::where('developer', 'Orbx')->where('icao', 'EDDM')->first(); + $this->assertNotNull($developer); + + $scenery = Scenery::where('scenery_developer_id', $developer->id)->first(); + $this->assertNotNull($scenery); + $this->assertFalse((bool) $scenery->published); + } + + public function test_scenery_suggestion_fails_with_invalid_icao(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post('/scenery/create', [ + 'icao' => 'ZZZZ', // does not exist in airports table + 'developer' => 'SomeDev', + 'link' => 'https://example.com', + 'payware' => '0', + 'simulators' => [1], + ]); + + $response->assertSessionHasErrors('icao'); + } + + public function test_scenery_suggestion_fails_with_invalid_url(): void + { + $user = User::factory()->create(); + $simulator = Simulator::first(); + + $response = $this->actingAs($user)->post('/scenery/create', [ + 'icao' => 'EDDM', + 'developer' => 'BadLinkDev', + 'link' => 'not-a-url', + 'payware' => '0', + 'simulators' => [$simulator->id], + ]); + + $response->assertSessionHasErrors('link'); + } + + public function test_scenery_suggestion_requires_at_least_one_simulator(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post('/scenery/create', [ + 'icao' => 'EDDM', + 'developer' => 'NoSimDev', + 'link' => 'https://example.com', + 'payware' => '0', + // simulators intentionally omitted + ]); + + $response->assertSessionHasErrors('simulators'); + } + + public function test_scenery_suggestion_associates_suggested_by_user(): void + { + $user = User::factory()->create(); + $simulator = Simulator::first(); + + $this->actingAs($user)->post('/scenery/create', [ + 'icao' => 'EDDM', + 'developer' => 'UserSceneryDev', + 'link' => 'https://userscenery.com', + 'payware' => '0', + 'simulators' => [$simulator->id], + ]); + + $developer = SceneryDeveloper::where('developer', 'UserSceneryDev')->first(); + $scenery = Scenery::where('scenery_developer_id', $developer->id)->first(); + + $this->assertEquals($user->id, $scenery->suggested_by_user_id); + } + + public function test_duplicate_developer_is_reused_for_new_simulator(): void + { + $user = User::factory()->create(); + $simulators = Simulator::take(2)->get(); + + // First suggestion + $this->actingAs($user)->post('/scenery/create', [ + 'icao' => 'EDDM', + 'developer' => 'SharedDev', + 'link' => 'https://shared.com', + 'payware' => '0', + 'simulators' => [$simulators[0]->id], + ]); + + // Second suggestion with same developer – should not create a new SceneryDeveloper row + $this->actingAs($user)->post('/scenery/create', [ + 'icao' => 'EDDM', + 'developer' => 'SharedDev', + 'link' => 'https://shared.com/v2', + 'payware' => '0', + 'simulators' => [$simulators[1]->id], + ]); + + $count = SceneryDeveloper::where('developer', 'SharedDev')->where('icao', 'EDDM')->count(); + $this->assertEquals(1, $count); + } +} diff --git a/tests/Feature/SearchTest.php b/tests/Feature/SearchTest.php new file mode 100644 index 00000000..ef0e3b3d --- /dev/null +++ b/tests/Feature/SearchTest.php @@ -0,0 +1,568 @@ + 'ENGM', + 'direction' => 'departure', + 'destinations' => ['Anywhere'], + 'codeletter' => 'JM', + 'airtimeMin' => 0, + 'airtimeMax' => 5, + 'sortByWeather' => 1, + 'sortByATC' => 1, + 'scores' => [ + 'METAR_WINDY' => 0, + 'METAR_GUSTS' => 0, + 'METAR_CROSSWIND' => 0, + 'METAR_SIGHT' => 0, + 'METAR_RVR' => 0, + 'METAR_CEILING' => 0, + 'METAR_FOGGY' => 0, + 'METAR_HEAVY_RAIN' => 0, + 'METAR_HEAVY_SNOW' => 0, + 'METAR_THUNDERSTORM' => 0, + 'VATSIM_ATC' => 0, + 'VATSIM_EVENT' => 0, + 'VATSIM_POPULAR' => 0, + ], + 'metcondition' => 'ANY', + 'destinationWithRoutesOnly' => 0, + 'destinationRunwayLights' => 0, + 'destinationAirbases' => -1, + 'flightDirection' => 0, + 'destinationAirportSize' => ['small_airport', 'medium_airport', 'large_airport'], + 'temperatureMin' => -60, + 'temperatureMax' => 60, + 'elevationMin' => -2000, + 'elevationMax' => 18000, + 'rwyLengthMin' => 0, + 'rwyLengthMax' => 17000, + ]; + + // ------------------------------------------------------------------------- + // Page loading + // ------------------------------------------------------------------------- + + public function test_arrival_search_page_loads(): void + { + $response = $this->get('/'); + + $response->assertStatus(200); + } + + public function test_departure_search_page_loads(): void + { + $response = $this->get('/departures/'); + + $response->assertStatus(200); + } + + public function test_route_search_page_loads(): void + { + $response = $this->get('/routes/'); + + $response->assertStatus(200); + } + + // ------------------------------------------------------------------------- + // Search validation + // ------------------------------------------------------------------------- + + public function test_search_requires_direction(): void + { + $response = $this->get('/search?' . http_build_query(array_merge($this->validSearchParams, [ + 'direction' => null, + ]))); + + // Missing required fields → redirected back with errors + $response->assertRedirect(); + } + + public function test_search_fails_with_invalid_codeletter(): void + { + $response = $this->get('/search?' . http_build_query(array_merge($this->validSearchParams, [ + 'codeletter' => 'INVALID', + ]))); + + $response->assertSessionHasErrors('codeletter'); + } + + public function test_search_fails_with_invalid_metcondition(): void + { + $response = $this->get('/search?' . http_build_query(array_merge($this->validSearchParams, [ + 'metcondition' => 'INVALID', + ]))); + + $response->assertSessionHasErrors('metcondition'); + } + + // ------------------------------------------------------------------------- + // Search edit + // ------------------------------------------------------------------------- + + public function test_search_edit_redirects_to_arrival_page_for_departure_direction(): void + { + $response = $this->post('/search/edit', [ + 'direction' => 'departure', + ]); + + $response->assertRedirect(route('front')); + } + + public function test_search_edit_redirects_to_departure_page_for_arrival_direction(): void + { + $response = $this->post('/search/edit', [ + 'direction' => 'arrival', + ]); + + $response->assertRedirect(route('front.departures')); + } + + // ------------------------------------------------------------------------- + // Search results + // ------------------------------------------------------------------------- + + public function test_search_fails_with_unrealistic_flight_length_and_destinations(): void + { + $response = $this->get('/search?' . http_build_query(array_merge($this->validSearchParams, [ + 'icao' => 'ENGM', + 'airtimeMin' => '0', + 'airtimeMax' => '2', + 'destinations' => ['C-AS'], + ]))); + + $response->assertSessionHasErrors('airportNotFound'); + } + + public function test_search_doesnt_show_airport_without_metar(): void + { + $response = $this->get('/search?' . http_build_query(array_merge($this->validSearchParams, [ + 'icao' => 'ENBR', + 'destinations' => ['Domestic'], + 'airtimeMin' => '0', + 'airtimeMax' => '5', + ]))); + + $response->assertOk(); + $response->assertViewHas('suggestedAirports', function ($airports) { + return $airports->pluck('icao')->doesntContain('ENSO'); + }); + } + + public function test_search_only_shows_domestic_when_searching_for_domestic(): void + { + $response = $this->get('/search?' . http_build_query(array_merge($this->validSearchParams, [ + 'icao' => 'ENBR', + 'destinations' => ['Domestic'], + 'airtimeMin' => '0', + 'airtimeMax' => '5', + ]))); + + $response->assertOk(); + $response->assertViewHas('suggestedAirports', function ($airports) { + return $airports->pluck('iso_country')->unique()->count() === 1 + && $airports->first()->iso_country === 'NO'; + }); + } + + public function test_default_search_returns_at_least_3_results(): void + { + $response = $this->get('/search?' . http_build_query($this->validSearchParams)); + + $response->assertOk(); + $response->assertViewHas('suggestedAirports', function ($airports) { + return $airports->count() >= 3; + }); + } + + public function test_whitelist_restricts_results_to_whitelisted_airports(): void + { + $user = User::factory()->create(); + $enbr = Airport::where('icao', 'ENBR')->first(); + $list = UserList::create(['name' => 'Test Whitelist', 'user_id' => $user->id, 'public' => true]); + $list->airports()->attach($enbr); + + $response = $this->get('/search?' . http_build_query(array_merge($this->validSearchParams, [ + 'whitelists' => [$list->id], + ]))); + + $response->assertOk(); + $response->assertViewHas('suggestedAirports', function ($airports) { + return $airports->isNotEmpty() + && $airports->pluck('icao')->every(fn ($icao) => $icao === 'ENBR'); + }); + } + + public function test_search_airtime_is_within_searched_bounds(): void + { + $response = $this->get('/search?' . http_build_query(array_merge($this->validSearchParams, [ + 'icao' => 'ENGM', + 'airtimeMin' => '0', + 'airtimeMax' => '2', + ]))); + + $response->assertOk(); + $response->assertViewHas('suggestedAirports', function ($airports) { + return $airports->isNotEmpty() + && $airports->every(fn ($a) => $a->airtime >= 0.0 && $a->airtime <= 2.0); + }); + } + + public function test_search_foggy_score_filter_returns_only_foggy_airports(): void + { + $response = $this->get('/search?' . http_build_query(array_merge($this->validSearchParams, [ + 'icao' => 'ENGM', + 'scores' => array_merge($this->validSearchParams['scores'], ['METAR_FOGGY' => 1]), + ]))); + + $response->assertOk(); + $response->assertViewHas('suggestedAirports', function ($airports) { + return $airports->isNotEmpty() + && $airports->pluck('icao')->every(fn ($icao) => $icao === 'ENTC'); + }); + } + + // ------------------------------------------------------------------------- + // Weather conditions (metcondition) + // ------------------------------------------------------------------------- + + public function test_metcondition_vfr_excludes_ifr_airports(): void + { + $response = $this->get('/search?' . http_build_query(array_merge($this->validSearchParams, [ + 'metcondition' => 'VFR', + ]))); + + $response->assertOk(); + $response->assertViewHas('suggestedAirports', function ($airports) { + // ENTC has 1800m visibility (IFR) — must not appear in VFR results + return $airports->isNotEmpty() + && $airports->pluck('icao')->doesntContain('ENTC'); + }); + } + + public function test_metcondition_ifr_only_returns_ifr_airports(): void + { + $response = $this->get('/search?' . http_build_query(array_merge($this->validSearchParams, [ + 'metcondition' => 'IFR', + ]))); + + $response->assertOk(); + $response->assertViewHas('suggestedAirports', function ($airports) { + // ENTC is the only IFR airport in the seed data within 5h JM range + return $airports->isNotEmpty() + && $airports->pluck('icao')->every(fn ($icao) => $icao === 'ENTC'); + }); + } + + // ------------------------------------------------------------------------- + // Score filters — include (=1): each score type assigned to one airport + // ------------------------------------------------------------------------- + + public function test_score_windy_only_shows_windy_airports(): void + { + $response = $this->get('/search?' . http_build_query($this->paramsWithScore('METAR_WINDY', 1))); + + $response->assertOk(); + $response->assertViewHas('suggestedAirports', function ($airports) { + return $airports->isNotEmpty() + && $airports->pluck('icao')->every(fn ($icao) => $icao === 'ENBR'); + }); + } + + public function test_score_gusts_only_shows_gusty_airports(): void + { + $response = $this->get('/search?' . http_build_query($this->paramsWithScore('METAR_GUSTS', 1))); + + $response->assertOk(); + $response->assertViewHas('suggestedAirports', function ($airports) { + return $airports->isNotEmpty() + && $airports->pluck('icao')->every(fn ($icao) => $icao === 'ENTO'); + }); + } + + public function test_score_crosswind_only_shows_crosswind_airports(): void + { + $response = $this->get('/search?' . http_build_query($this->paramsWithScore('METAR_CROSSWIND', 1))); + + $response->assertOk(); + $response->assertViewHas('suggestedAirports', function ($airports) { + return $airports->isNotEmpty() + && $airports->pluck('icao')->every(fn ($icao) => $icao === 'ENSG'); + }); + } + + public function test_score_sight_only_shows_reduced_sight_airports(): void + { + $response = $this->get('/search?' . http_build_query($this->paramsWithScore('METAR_SIGHT', 1))); + + $response->assertOk(); + $response->assertViewHas('suggestedAirports', function ($airports) { + return $airports->isNotEmpty() + && $airports->pluck('icao')->every(fn ($icao) => $icao === 'ENSD'); + }); + } + + public function test_score_rvr_only_shows_rvr_airports(): void + { + $response = $this->get('/search?' . http_build_query($this->paramsWithScore('METAR_RVR', 1))); + + $response->assertOk(); + $response->assertViewHas('suggestedAirports', function ($airports) { + return $airports->isNotEmpty() + && $airports->pluck('icao')->every(fn ($icao) => $icao === 'EDDB'); + }); + } + + public function test_score_ceiling_only_shows_low_ceiling_airports(): void + { + $response = $this->get('/search?' . http_build_query($this->paramsWithScore('METAR_CEILING', 1))); + + $response->assertOk(); + $response->assertViewHas('suggestedAirports', function ($airports) { + return $airports->isNotEmpty() + && $airports->pluck('icao')->every(fn ($icao) => $icao === 'EHAM'); + }); + } + + public function test_score_heavy_rain_only_shows_heavy_rain_airports(): void + { + $response = $this->get('/search?' . http_build_query($this->paramsWithScore('METAR_HEAVY_RAIN', 1))); + + $response->assertOk(); + $response->assertViewHas('suggestedAirports', function ($airports) { + return $airports->isNotEmpty() + && $airports->pluck('icao')->every(fn ($icao) => $icao === 'EGLL'); + }); + } + + public function test_score_heavy_snow_only_shows_heavy_snow_airports(): void + { + $response = $this->get('/search?' . http_build_query($this->paramsWithScore('METAR_HEAVY_SNOW', 1))); + + $response->assertOk(); + $response->assertViewHas('suggestedAirports', function ($airports) { + return $airports->isNotEmpty() + && $airports->pluck('icao')->every(fn ($icao) => $icao === 'EDDF'); + }); + } + + public function test_score_thunderstorm_only_shows_thunderstorm_airports(): void + { + $response = $this->get('/search?' . http_build_query($this->paramsWithScore('METAR_THUNDERSTORM', 1))); + + $response->assertOk(); + $response->assertViewHas('suggestedAirports', function ($airports) { + return $airports->isNotEmpty() + && $airports->pluck('icao')->every(fn ($icao) => $icao === 'EDDS'); + }); + } + + public function test_score_vatsim_atc_only_shows_atc_airports(): void + { + $response = $this->get('/search?' . http_build_query($this->paramsWithScore('VATSIM_ATC', 1))); + + $response->assertOk(); + $response->assertViewHas('suggestedAirports', function ($airports) { + return $airports->isNotEmpty() + && $airports->pluck('icao')->every(fn ($icao) => $icao === 'LFPG'); + }); + } + + public function test_score_vatsim_event_only_shows_event_airports(): void + { + $response = $this->get('/search?' . http_build_query($this->paramsWithScore('VATSIM_EVENT', 1))); + + $response->assertOk(); + $response->assertViewHas('suggestedAirports', function ($airports) { + return $airports->isNotEmpty() + && $airports->pluck('icao')->every(fn ($icao) => $icao === 'EDDM'); + }); + } + + public function test_score_vatsim_popular_only_shows_popular_airports(): void + { + $response = $this->get('/search?' . http_build_query($this->paramsWithScore('VATSIM_POPULAR', 1))); + + $response->assertOk(); + $response->assertViewHas('suggestedAirports', function ($airports) { + return $airports->isNotEmpty() + && $airports->pluck('icao')->every(fn ($icao) => $icao === 'ENTC'); + }); + } + + // ------------------------------------------------------------------------- + // Score filters — exclude (=-1) + // ------------------------------------------------------------------------- + + public function test_score_foggy_exclusion_hides_scored_airports(): void + { + $response = $this->get('/search?' . http_build_query($this->paramsWithScore('METAR_FOGGY', -1))); + + $response->assertOk(); + $response->assertViewHas('suggestedAirports', function ($airports) { + return $airports->isNotEmpty() + && $airports->pluck('icao')->doesntContain('ENTC'); + }); + } + + public function test_score_vatsim_popular_exclusion_hides_scored_airports(): void + { + $response = $this->get('/search?' . http_build_query($this->paramsWithScore('VATSIM_POPULAR', -1))); + + $response->assertOk(); + $response->assertViewHas('suggestedAirports', function ($airports) { + return $airports->isNotEmpty() + && $airports->pluck('icao')->doesntContain('ENTC'); + }); + } + + // ------------------------------------------------------------------------- + // Destination filters + // ------------------------------------------------------------------------- + + public function test_destination_small_airports_only_returns_small_airports(): void + { + $response = $this->get('/search?' . http_build_query(array_merge($this->validSearchParams, [ + 'destinationAirportSize' => ['small_airport'], + ]))); + + $response->assertOk(); + $response->assertViewHas('suggestedAirports', function ($airports) { + return $airports->isNotEmpty() + && $airports->pluck('type')->every(fn ($t) => $t === 'small_airport'); + }); + } + + public function test_destination_large_airports_only_excludes_smaller_types(): void + { + $response = $this->get('/search?' . http_build_query(array_merge($this->validSearchParams, [ + 'destinationAirportSize' => ['large_airport'], + ]))); + + $response->assertOk(); + $response->assertViewHas('suggestedAirports', function ($airports) { + return $airports->isNotEmpty() + && $airports->pluck('type')->every(fn ($t) => $t === 'large_airport'); + }); + } + + public function test_destination_country_filter_returns_only_matching_country(): void + { + $response = $this->get('/search?' . http_build_query(array_merge($this->validSearchParams, [ + 'destinations' => ['DE'], + ]))); + + $response->assertOk(); + $response->assertViewHas('suggestedAirports', function ($airports) { + return $airports->isNotEmpty() + && $airports->pluck('iso_country')->every(fn ($c) => $c === 'DE'); + }); + } + + public function test_destination_continent_filter_returns_only_matching_continent(): void + { + // From KLAX with C-NA and 0–2h JM only KSFO (~330 nm) is reachable in the seed data + $response = $this->get('/search?' . http_build_query(array_merge($this->validSearchParams, [ + 'icao' => 'KLAX', + 'destinations' => ['C-NA'], + 'airtimeMax' => '2', + ]))); + + $response->assertOk(); + $response->assertViewHas('suggestedAirports', function ($airports) { + return $airports->isNotEmpty() + && $airports->pluck('continent')->every(fn ($c) => $c === 'NA'); + }); + } + + public function test_destination_lighted_runways_only_excludes_unlighted_airports(): void + { + // ENBO has an unlighted runway and must not appear + $response = $this->get('/search?' . http_build_query(array_merge($this->validSearchParams, [ + 'destinationRunwayLights' => 1, + ]))); + + $response->assertOk(); + $response->assertViewHas('suggestedAirports', function ($airports) { + return $airports->isNotEmpty() + && $airports->pluck('icao')->doesntContain('ENBO'); + }); + } + + public function test_destination_unlighted_runways_only_returns_unlighted_airports(): void + { + // ENBO is the only seeded airport with an unlighted runway + $response = $this->get('/search?' . http_build_query(array_merge($this->validSearchParams, [ + 'destinationRunwayLights' => -1, + ]))); + + $response->assertOk(); + $response->assertViewHas('suggestedAirports', function ($airports) { + return $airports->isNotEmpty() + && $airports->pluck('icao')->every(fn ($icao) => $icao === 'ENBO'); + }); + } + + public function test_destination_airbases_only_returns_airbase_airports(): void + { + // ENHF is the only seeded airport with w2f_airforcebase=true + $response = $this->get('/search?' . http_build_query(array_merge($this->validSearchParams, [ + 'destinationAirbases' => 1, + ]))); + + $response->assertOk(); + $response->assertViewHas('suggestedAirports', function ($airports) { + return $airports->isNotEmpty() + && $airports->pluck('icao')->every(fn ($icao) => $icao === 'ENHF'); + }); + } + + public function test_destination_exclude_airbases_hides_airbase_airports(): void + { + $response = $this->get('/search?' . http_build_query(array_merge($this->validSearchParams, [ + 'destinationAirbases' => -1, + ]))); + + $response->assertOk(); + $response->assertViewHas('suggestedAirports', function ($airports) { + return $airports->isNotEmpty() + && $airports->pluck('icao')->doesntContain('ENHF'); + }); + } + + public function test_destination_flight_direction_south_excludes_northern_airports(): void + { + // From ENGM, flying South: ENTC (69°N, north of ENGM) must not appear + $response = $this->get('/search?' . http_build_query(array_merge($this->validSearchParams, [ + 'flightDirection' => 'S', + ]))); + + $response->assertOk(); + $response->assertViewHas('suggestedAirports', function ($airports) { + return $airports->isNotEmpty() + && $airports->pluck('icao')->doesntContain('ENTC'); + }); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function paramsWithScore(string $score, int $value): array + { + return array_merge($this->validSearchParams, [ + 'scores' => array_merge($this->validSearchParams['scores'], [$score => $value]), + ]); + } +} diff --git a/tests/Feature/UserAccountTest.php b/tests/Feature/UserAccountTest.php new file mode 100644 index 00000000..f238f4d8 --- /dev/null +++ b/tests/Feature/UserAccountTest.php @@ -0,0 +1,309 @@ + Http::response([ + 'success' => true, + 'error-codes' => [], + ], 200), + ]); + } + + // ------------------------------------------------------------------------- + // Registration page + // ------------------------------------------------------------------------- + + public function test_registration_page_loads(): void + { + $response = $this->get('/register'); + + $response->assertStatus(200); + } + + // ------------------------------------------------------------------------- + // User registration + // ------------------------------------------------------------------------- + + public function test_user_can_register(): void + { + $this->fakeTurnstile(); + + $response = $this->post('/register', [ + 'username' => 'testuser', + 'email' => 'testuser@example.com', + 'password' => 'Password123!', + 'password_confirmation' => 'Password123!', + 'cf-turnstile-response' => 'DUMMY', + 'privacy_policy' => '1', + ]); + + $response->assertRedirect(route('front')); + $this->assertDatabaseHas('users', ['username' => 'testuser']); + } + + public function test_registration_fails_with_short_password(): void + { + $this->fakeTurnstile(); + + $response = $this->post('/register', [ + 'username' => 'testuser', + 'email' => 'testuser@example.com', + 'password' => 'short', + 'password_confirmation' => 'short', + 'cf-turnstile-response' => 'DUMMY', + 'privacy_policy' => '1', + ]); + + $response->assertSessionHasErrors('password'); + $this->assertDatabaseMissing('users', ['username' => 'testuser']); + } + + public function test_registration_fails_with_mismatched_passwords(): void + { + $this->fakeTurnstile(); + + $response = $this->post('/register', [ + 'username' => 'testuser', + 'email' => 'testuser@example.com', + 'password' => 'Password123!', + 'password_confirmation' => 'DifferentPassword!', + 'cf-turnstile-response' => 'DUMMY', + 'privacy_policy' => '1', + ]); + + $response->assertSessionHasErrors('password'); + $this->assertDatabaseMissing('users', ['username' => 'testuser']); + } + + public function test_registration_fails_with_duplicate_username(): void + { + $this->fakeTurnstile(); + + User::factory()->create(['username' => 'existinguser']); + + $response = $this->post('/register', [ + 'username' => 'existinguser', + 'email' => 'new@example.com', + 'password' => 'Password123!', + 'password_confirmation' => 'Password123!', + 'cf-turnstile-response' => 'DUMMY', + 'privacy_policy' => '1', + ]); + + $response->assertSessionHasErrors('username'); + } + + public function test_registration_fails_with_duplicate_email(): void + { + $this->fakeTurnstile(); + + User::factory()->create(['email' => 'taken@example.com']); + + $response = $this->post('/register', [ + 'username' => 'brandnewuser', + 'email' => 'taken@example.com', + 'password' => 'Password123!', + 'password_confirmation' => 'Password123!', + 'cf-turnstile-response' => 'DUMMY', + 'privacy_policy' => '1', + ]); + + $response->assertSessionHasErrors('email'); + } + + public function test_registration_fails_without_privacy_policy_acceptance(): void + { + $this->fakeTurnstile(); + + $response = $this->post('/register', [ + 'username' => 'testuser', + 'email' => 'testuser@example.com', + 'password' => 'Password123!', + 'password_confirmation' => 'Password123!', + 'cf-turnstile-response' => 'DUMMY', + // privacy_policy intentionally omitted + ]); + + $response->assertSessionHasErrors('privacy_policy'); + } + + public function test_registration_is_blocked_for_authenticated_users(): void + { + $this->fakeTurnstile(); + + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post('/register', [ + 'username' => 'anotheruser', + 'email' => 'another@example.com', + 'password' => 'Password123!', + 'password_confirmation' => 'Password123!', + 'cf-turnstile-response' => 'DUMMY', + 'privacy_policy' => '1', + ]); + + // The route has 'guest' middleware, so authenticated users are redirected + $response->assertRedirect(); + $this->assertDatabaseMissing('users', ['username' => 'anotheruser']); + } + + // ------------------------------------------------------------------------- + // Login page + // ------------------------------------------------------------------------- + + public function test_login_page_loads(): void + { + $response = $this->get('/login'); + + $response->assertStatus(200); + } + + // ------------------------------------------------------------------------- + // Login + // ------------------------------------------------------------------------- + + public function test_user_can_login(): void + { + $this->fakeTurnstile(); + + $user = User::factory()->create([ + 'username' => 'loginuser', + 'password' => bcrypt('Password123!'), + ]); + + $response = $this->post('/login', [ + 'username' => 'loginuser', + 'password' => 'Password123!', + 'cf-turnstile-response' => 'DUMMY', + ]); + + $response->assertRedirect(route('front')); + $this->assertAuthenticatedAs($user); + } + + public function test_user_can_login_with_email(): void + { + $this->fakeTurnstile(); + + $user = User::factory()->create([ + 'email' => 'emaillogin@example.com', + 'password' => bcrypt('Password123!'), + ]); + + $response = $this->post('/login', [ + 'username' => 'emaillogin@example.com', + 'password' => 'Password123!', + 'cf-turnstile-response' => 'DUMMY', + ]); + + $response->assertRedirect(route('front')); + $this->assertAuthenticatedAs($user); + } + + public function test_login_fails_with_wrong_password(): void + { + $this->fakeTurnstile(); + + User::factory()->create([ + 'username' => 'loginuser', + 'password' => bcrypt('CorrectPassword!'), + ]); + + $response = $this->post('/login', [ + 'username' => 'loginuser', + 'password' => 'WrongPassword!', + 'cf-turnstile-response' => 'DUMMY', + ]); + + $response->assertSessionHas('error'); + $this->assertGuest(); + } + + public function test_login_fails_with_nonexistent_username(): void + { + $this->fakeTurnstile(); + + $response = $this->post('/login', [ + 'username' => 'nobody', + 'password' => 'Password123!', + 'cf-turnstile-response' => 'DUMMY', + ]); + + $response->assertSessionHas('error'); + $this->assertGuest(); + } + + // ------------------------------------------------------------------------- + // Logout + // ------------------------------------------------------------------------- + + public function test_user_can_logout(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->get('/logout'); + + $response->assertRedirect(route('front')); + $this->assertGuest(); + } + + // ------------------------------------------------------------------------- + // Account deletion + // ------------------------------------------------------------------------- + + public function test_user_can_delete_own_account(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post('/account/delete'); + + $response->assertRedirect(route('front')); + $this->assertDatabaseMissing('users', ['id' => $user->id]); + } + + public function test_guest_cannot_access_delete_account_route(): void + { + $response = $this->post('/account/delete'); + + // Unauthenticated users are redirected to login + $response->assertRedirect(); + } + + // ------------------------------------------------------------------------- + // Account settings page + // ------------------------------------------------------------------------- + + public function test_account_settings_requires_auth(): void + { + $response = $this->get('/account'); + + $response->assertRedirect(route('login')); + } + + public function test_authenticated_user_can_view_account_settings(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->withSession(['url.intended' => null]) + ->get('/account'); + + $response->assertStatus(200); + } +} diff --git a/tests/Feature/UserListTest.php b/tests/Feature/UserListTest.php new file mode 100644 index 00000000..aba45330 --- /dev/null +++ b/tests/Feature/UserListTest.php @@ -0,0 +1,402 @@ +get('/lists'); + + $response->assertRedirect(route('login')); + } + + public function test_authenticated_user_can_view_list_index(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->get('/lists'); + + $response->assertStatus(200); + } + + // ------------------------------------------------------------------------- + // Create page + // ------------------------------------------------------------------------- + + public function test_list_create_page_requires_authentication(): void + { + $response = $this->get('/lists/create'); + + $response->assertRedirect(route('login')); + } + + public function test_authenticated_user_can_view_create_list_page(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->get('/lists/create'); + + $response->assertStatus(200); + } + + // ------------------------------------------------------------------------- + // Store list + // ------------------------------------------------------------------------- + + public function test_user_can_create_a_list(): void + { + $user = User::factory()->create(); + $simulator = Simulator::first(); + + $response = $this->actingAs($user)->post('/lists/create', [ + 'name' => 'My Test List', + 'color' => '#FF5733', + 'simulator' => $simulator->id, + 'airports' => 'KLAX', + ]); + + $response->assertRedirect(route('list.index')); + $this->assertDatabaseHas('user_lists', [ + 'name' => 'My Test List', + 'user_id' => $user->id, + ]); + } + + public function test_user_can_create_a_list_with_airports(): void + { + $user = User::factory()->create(); + $simulator = Simulator::first(); + $response = $this->actingAs($user)->post('/lists/create', [ + 'name' => 'My Airport List', + 'color' => '#123456', + 'simulator' => $simulator->id, + 'airports' => "KLAX\r\nKSFO", + ]); + + $response->assertRedirect(route('list.index')); + + $list = UserList::where('name', 'My Airport List')->where('user_id', $user->id)->first(); + $this->assertNotNull($list); + $this->assertCount(2, $list->airports); + } + + public function test_creating_list_with_unknown_airport_shows_warning(): void + { + $user = User::factory()->create(); + $simulator = Simulator::first(); + + $response = $this->actingAs($user)->post('/lists/create', [ + 'name' => 'List With Unknown', + 'color' => '#AABBCC', + 'simulator' => $simulator->id, + 'airports' => 'ZZZZ', + ]); + + $response->assertRedirect(route('list.index')); + $response->assertSessionHas('warning'); + } + + public function test_list_creation_requires_name(): void + { + $user = User::factory()->create(); + $simulator = Simulator::first(); + + $response = $this->actingAs($user)->post('/lists/create', [ + 'color' => '#FF5733', + 'simulator' => $simulator->id, + 'airports' => 'KLAX', + ]); + + $response->assertSessionHasErrors('name'); + } + + public function test_list_creation_requires_valid_color(): void + { + $user = User::factory()->create(); + $simulator = Simulator::first(); + + $response = $this->actingAs($user)->post('/lists/create', [ + 'name' => 'Bad Color', + 'color' => 'not-a-color', + 'simulator' => $simulator->id, + 'airports' => 'KLAX', + ]); + + $response->assertSessionHasErrors('color'); + } + + public function test_list_creation_requires_valid_simulator(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post('/lists/create', [ + 'name' => 'Bad Sim', + 'color' => '#FF5733', + 'simulator' => 99999, + 'airports' => 'KLAX', + ]); + + $response->assertSessionHasErrors('simulator'); + } + + // ------------------------------------------------------------------------- + // Edit list + // ------------------------------------------------------------------------- + + public function test_user_can_edit_own_list(): void + { + $user = User::factory()->create(); + $simulator = Simulator::first(); + $list = UserList::create([ + 'name' => 'Original Name', + 'color' => '#111111', + 'simulator_id' => $simulator->id, + 'user_id' => $user->id, + 'public' => false, + ]); + + $response = $this->actingAs($user)->post("/lists/{$list->id}/edit", [ + 'name' => 'Updated Name', + 'color' => '#222222', + 'simulator' => $simulator->id, + 'airports' => 'KLAX', + ]); + + $response->assertRedirect(route('list.index')); + $this->assertDatabaseHas('user_lists', [ + 'id' => $list->id, + 'name' => 'Updated Name', + ]); + } + + public function test_user_cannot_edit_another_users_list(): void + { + $owner = User::factory()->create(); + $other = User::factory()->create(); + $simulator = Simulator::first(); + $list = UserList::create([ + 'name' => 'Owner List', + 'color' => '#111111', + 'simulator_id' => $simulator->id, + 'user_id' => $owner->id, + 'public' => false, + ]); + + $response = $this->actingAs($other)->post("/lists/{$list->id}/edit", [ + 'name' => 'Hijacked Name', + 'color' => '#222222', + 'simulator' => $simulator->id, + 'airports' => 'KLAX', + ]); + + $response->assertForbidden(); + $this->assertDatabaseMissing('user_lists', ['name' => 'Hijacked Name']); + } + + // ------------------------------------------------------------------------- + // Add / remove airports via update + // ------------------------------------------------------------------------- + + public function test_user_can_add_airports_to_existing_list(): void + { + $user = User::factory()->create(); + $simulator = Simulator::first(); + $list = UserList::create([ + 'name' => 'Growing List', + 'color' => '#ABCDEF', + 'simulator_id' => $simulator->id, + 'user_id' => $user->id, + 'public' => false, + ]); + + $response = $this->actingAs($user)->post("/lists/{$list->id}/edit", [ + 'name' => 'Growing List', + 'color' => '#ABCDEF', + 'simulator' => $simulator->id, + 'airports' => 'EGLL', + ]); + + $response->assertRedirect(route('list.index')); + $list->refresh(); + $this->assertCount(1, $list->airports); + $this->assertEquals('EGLL', $list->airports->first()->icao); + } + + public function test_user_can_remove_airports_from_list(): void + { + $user = User::factory()->create(); + $simulator = Simulator::first(); + $list = UserList::create([ + 'name' => 'Shrinking List', + 'color' => '#FEDCBA', + 'simulator_id' => $simulator->id, + 'user_id' => $user->id, + 'public' => false, + ]); + $list->airports()->attach([ + $this->airports['EDDF']->id, + $this->airports['KLAX']->id, + ]); + + // Update list with only KLAX, dropping EDDF + $response = $this->actingAs($user)->post("/lists/{$list->id}/edit", [ + 'name' => 'Shrinking List', + 'color' => '#FEDCBA', + 'simulator' => $simulator->id, + 'airports' => 'KLAX', + ]); + + $response->assertRedirect(route('list.index')); + $list->refresh(); + $this->assertCount(1, $list->airports); + $this->assertEquals('KLAX', $list->airports->first()->icao); + } + + // ------------------------------------------------------------------------- + // Delete list + // ------------------------------------------------------------------------- + + public function test_user_can_delete_own_list(): void + { + $user = User::factory()->create(); + $simulator = Simulator::first(); + $list = UserList::create([ + 'name' => 'Doomed List', + 'color' => '#000000', + 'simulator_id' => $simulator->id, + 'user_id' => $user->id, + 'public' => false, + ]); + + $response = $this->actingAs($user)->get("/lists/{$list->id}/delete"); + + $response->assertRedirect(route('list.index')); + $this->assertDatabaseMissing('user_lists', ['id' => $list->id]); + } + + public function test_user_cannot_delete_another_users_list(): void + { + $owner = User::factory()->create(); + $other = User::factory()->create(); + $simulator = Simulator::first(); + $list = UserList::create([ + 'name' => 'Protected List', + 'color' => '#FFFFFF', + 'simulator_id' => $simulator->id, + 'user_id' => $owner->id, + 'public' => false, + ]); + + $response = $this->actingAs($other)->get("/lists/{$list->id}/delete"); + + $response->assertForbidden(); + $this->assertDatabaseHas('user_lists', ['id' => $list->id]); + } + + // ------------------------------------------------------------------------- + // Toggle hidden + // ------------------------------------------------------------------------- + + public function test_user_can_toggle_list_visibility(): void + { + $user = User::factory()->create(); + $simulator = Simulator::first(); + $list = UserList::create([ + 'name' => 'Toggle List', + 'color' => '#123123', + 'simulator_id' => $simulator->id, + 'user_id' => $user->id, + 'public' => false, + 'hidden' => false, + ]); + + $this->actingAs($user)->get("/lists/{$list->id}/toggle"); + + $list->refresh(); + $this->assertTrue((bool) $list->hidden); + } + + // ------------------------------------------------------------------------- + // Unauthorized public flag change should not be applied + // ------------------------------------------------------------------------- + + public function test_non_admin_cannot_make_list_public_when_creating(): void + { + $user = User::factory()->create(['admin' => false]); + $simulator = Simulator::first(); + + $response = $this->actingAs($user)->post('/lists/create', [ + 'name' => 'Sneaky Public List', + 'color' => '#AAAAAA', + 'simulator' => $simulator->id, + 'airports' => 'KLAX', + 'public' => true, + ]); + + $response->assertForbidden(); + $this->assertDatabaseMissing('user_lists', ['name' => 'Sneaky Public List']); + } + + public function test_non_admin_cannot_make_list_public_when_editing(): void + { + $user = User::factory()->create(['admin' => false]); + $simulator = Simulator::first(); + $list = UserList::create([ + 'name' => 'Private List', + 'color' => '#BBBBBB', + 'simulator_id' => $simulator->id, + 'user_id' => $user->id, + 'public' => false, + ]); + + $response = $this->actingAs($user)->post("/lists/{$list->id}/edit", [ + 'name' => 'Private List', + 'color' => '#BBBBBB', + 'simulator' => $simulator->id, + 'airports' => 'KLAX', + 'public' => true, + ]); + + $response->assertForbidden(); + $list->refresh(); + $this->assertFalse((bool) $list->public); + } + + public function test_admin_can_make_list_public_when_editing(): void + { + $admin = User::factory()->admin()->create(); + $simulator = Simulator::first(); + $list = UserList::create([ + 'name' => 'To Be Public', + 'color' => '#CCCCCC', + 'simulator_id' => $simulator->id, + 'user_id' => $admin->id, + 'public' => false, + ]); + + $response = $this->actingAs($admin)->post("/lists/{$list->id}/edit", [ + 'name' => 'To Be Public', + 'color' => '#CCCCCC', + 'simulator' => $simulator->id, + 'airports' => 'KLAX', + 'public' => true, + ]); + + $response->assertRedirect(route('list.index')); + $list->refresh(); + $this->assertTrue((bool) $list->public); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 2932d4a6..3e27ff97 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,9 +2,43 @@ namespace Tests; +use App\Models\Airport; +use Database\Seeders\TestAirportSeeder; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; abstract class TestCase extends BaseTestCase { use CreatesApplication; + + /** + * Run TestAirportSeeder once after migrate:fresh, before per-test transactions begin. + * This means airports are committed to the DB and survive transaction rollbacks. + */ + protected bool $seed = true; + + protected string $seeder = TestAirportSeeder::class; + + /** + * Keyed by ICAO — available in every test after setUp(). + * + * @var array + */ + protected array $airports = []; + + private const SEED_ICAOS = ['KLAX', 'KSFO', 'KJFK', 'KORD', 'EGLL', 'EDDM', 'EDDF', 'EHAM', 'LFPG', 'RJTT']; + + protected function setUp(): void + { + parent::setUp(); + + if (! \Schema::hasTable('airports')) { + return; + } + + // Single query — seeder already committed these rows before our transaction. + $this->airports = Airport::whereIn('icao', self::SEED_ICAOS) + ->get() + ->keyBy('icao') + ->all(); + } } diff --git a/tests/Unit/.gitkeep b/tests/Unit/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php deleted file mode 100644 index e5c5fef7..00000000 --- a/tests/Unit/ExampleTest.php +++ /dev/null @@ -1,18 +0,0 @@ -assertTrue(true); - } -} From 28d390ce8aaf6289a7d9601328a1dfd599da2e75 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 29 May 2026 22:57:30 +0200 Subject: [PATCH 02/46] refactor: laravel cleanup (#232) * fix: cleanup Cleaning up functions and usage to make code simpler. * fix: throttle user activity property * fix: delete unused robots * fix: cleaned up unused parameters in filterWithCriteria * refactor: commands and helpers * refactor: password hasing * refactor: model casts * refactor: artisan calls * fix: tests * refactor: model creation of UserList * refactor: isProduction() use * refactor: useless collect() * refactor: DB * refactor: sliming the app * fix: lint * fix: throwable * fix: metar column * fix: correct Metar cast key and allow nullable reg_number in FetchFlights Agent-Logs-Url: https://github.com/blt950/where2fly/sessions/d680a866-fa1d-4680-beec-9a4e70e802b2 Co-authored-by: blt950 <2505044+blt950@users.noreply.github.com> * fix: throwable * Revert "Merge branch 'fix/laravel-cleanup' of https://github.com/blt950/where2fly into fix/laravel-cleanup" This reverts commit 1d4bee6d1733629f6c3680446028d9b72581cad6, reversing changes made to 0c39a1152b6013905d856bdcce40085ee9a08a0d. * refactor: TrustHosts no longer needed * refactor: BroadcastServiceProvider no longer needed * refactor: casting * refactor: api middleware * refactor: providers * refactor: new scoping convention --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: blt950 <2505044+blt950@users.noreply.github.com> --- .gitignore | 1 + .../Commands/AccountClearUnverified.php | 2 +- app/Console/Commands/CalcScores.php | 14 +- app/Console/Commands/CreateApiKey.php | 4 +- app/Console/Commands/EnrichAirports.php | 11 +- app/Console/Commands/FetchFlights.php | 8 +- app/Console/Commands/FetchGithubIssues.php | 2 +- app/Console/Commands/FetchMetars.php | 26 ++-- app/Console/Commands/FetchVatsim.php | 4 +- app/Console/Commands/UpdateData.php | 7 +- app/Console/Kernel.php | 58 -------- app/Exceptions/Handler.php | 58 -------- app/Helpers/AirportCallsignHelper.php | 6 +- app/Helpers/AirportFilterHelper.php | 8 +- app/Helpers/CalculationHelper.php | 137 +++++------------- app/Helpers/SceneryHelper.php | 7 +- app/Helpers/helpers.php | 5 +- app/Http/Controllers/API/SearchController.php | 81 ++++------- app/Http/Controllers/API/TopController.php | 35 +---- app/Http/Controllers/Auth/LoginController.php | 8 +- app/Http/Controllers/Controller.php | 10 +- app/Http/Controllers/FeedbackController.php | 12 +- app/Http/Controllers/SceneryController.php | 12 +- app/Http/Controllers/SearchController.php | 60 +++----- app/Http/Controllers/TopController.php | 6 +- app/Http/Controllers/UserController.php | 3 - app/Http/Controllers/UserListController.php | 30 ++-- app/Http/Kernel.php | 97 ------------- app/Http/Middleware/Authenticate.php | 22 --- app/Http/Middleware/EncryptCookies.php | 17 --- .../PreventRequestsDuringMaintenance.php | 17 --- .../Middleware/RedirectIfAuthenticated.php | 32 ---- app/Http/Middleware/TrimStrings.php | 19 --- app/Http/Middleware/TrustHosts.php | 20 --- app/Http/Middleware/TrustProxies.php | 52 ------- app/Http/Middleware/UserActive.php | 13 +- app/Http/Middleware/VerifyCsrfToken.php | 17 --- app/Http/Resources/AirportResource.php | 24 +++ .../Resources/SuggestedAirportResource.php | 18 +++ app/Mixins/CollectionAirportFilter.php | 6 +- app/Models/Airport.php | 112 +++++++------- app/Models/AirportScore.php | 5 +- app/Models/ApiKey.php | 9 +- app/Models/Controller.php | 9 +- app/Models/Event.php | 11 +- app/Models/Flight.php | 11 +- app/Models/FlightAircraft.php | 11 +- app/Models/Metar.php | 9 +- app/Models/Runway.php | 11 +- app/Models/User.php | 31 ++-- app/Providers/AppServiceProvider.php | 8 +- app/Providers/AuthServiceProvider.php | 30 ---- app/Providers/BroadcastServiceProvider.php | 21 --- app/Providers/EventServiceProvider.php | 42 ------ app/Providers/RouteServiceProvider.php | 52 ------- bootstrap/app.php | 129 ++++++++++------- bootstrap/providers.php | 7 + config/app.php | 99 ------------- database/factories/UserFactory.php | 2 +- resources/views/layouts/app.blade.php | 2 +- resources/views/layouts/menu.blade.php | 2 +- resources/views/layouts/title.blade.php | 4 +- resources/views/layouts/tracking.blade.php | 2 +- resources/views/scenery.blade.php | 2 +- resources/views/scenery/edit.blade.php | 6 +- robots.txt | 2 - routes/api.php | 2 +- routes/console.php | 41 ++++-- 68 files changed, 460 insertions(+), 1181 deletions(-) delete mode 100644 app/Console/Kernel.php delete mode 100644 app/Exceptions/Handler.php delete mode 100644 app/Http/Kernel.php delete mode 100644 app/Http/Middleware/Authenticate.php delete mode 100644 app/Http/Middleware/EncryptCookies.php delete mode 100644 app/Http/Middleware/PreventRequestsDuringMaintenance.php delete mode 100644 app/Http/Middleware/RedirectIfAuthenticated.php delete mode 100644 app/Http/Middleware/TrimStrings.php delete mode 100644 app/Http/Middleware/TrustHosts.php delete mode 100644 app/Http/Middleware/TrustProxies.php delete mode 100644 app/Http/Middleware/VerifyCsrfToken.php create mode 100644 app/Http/Resources/AirportResource.php create mode 100644 app/Http/Resources/SuggestedAirportResource.php delete mode 100644 app/Providers/AuthServiceProvider.php delete mode 100644 app/Providers/BroadcastServiceProvider.php delete mode 100644 app/Providers/EventServiceProvider.php delete mode 100644 app/Providers/RouteServiceProvider.php create mode 100644 bootstrap/providers.php delete mode 100644 robots.txt diff --git a/.gitignore b/.gitignore index b81db143..b61f4ca8 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,4 @@ apache/* /resources/fonts/fa-v4compatibility.ttf /resources/fonts/fa-v4compatibility.woff2 .phpunit.cache/test-results +.abacusai/config.json diff --git a/app/Console/Commands/AccountClearUnverified.php b/app/Console/Commands/AccountClearUnverified.php index f7783c11..1db7bf74 100644 --- a/app/Console/Commands/AccountClearUnverified.php +++ b/app/Console/Commands/AccountClearUnverified.php @@ -27,7 +27,7 @@ class AccountClearUnverified extends Command public function handle() { // Delete users who haven't verified their email address for 7 days - $users = User::where('email_verified_at', null) + $users = User::whereNull('email_verified_at') ->where('created_at', '<', now()->subDays(7)) ->get(); diff --git a/app/Console/Commands/CalcScores.php b/app/Console/Commands/CalcScores.php index 61364cd0..74943727 100644 --- a/app/Console/Commands/CalcScores.php +++ b/app/Console/Commands/CalcScores.php @@ -40,7 +40,7 @@ public function handle() $vatsimRequest = Http::get('https://data.vatsim.net/v3/vatsim-data.json'); $vatsimPilots = null; if ($vatsimRequest->successful()) { - $vatsimPilots = json_decode($vatsimRequest->body(), false)->pilots; + $vatsimPilots = $vatsimRequest->object()->pilots; } // Grab relevant aerodromes for calculations @@ -50,7 +50,7 @@ public function handle() foreach ($airports as $airport) { // Skip airport with old metars - if (Carbon::now()->gt(Carbon::parse($airport->metar->last_update)->addHour(1))) { + if (now()->gt(Carbon::parse($airport->metar->last_update)->addHour(1))) { continue; } @@ -88,11 +88,11 @@ public function handle() } // Set the components - $headwindComponentLe = abs($airport->metar->wind_speed * cos(($airport->metar->wind_direction - $runway->le_heading) / 180 * 3.14)); - $crosswindComponentLe = abs($airport->metar->wind_speed * sin(($airport->metar->wind_direction - $runway->le_heading) / 180 * pi())); + $headwindComponentLe = abs($airport->metar->wind_speed * cos(deg2rad($airport->metar->wind_direction - $runway->le_heading))); + $crosswindComponentLe = abs($airport->metar->wind_speed * sin(deg2rad($airport->metar->wind_direction - $runway->le_heading))); - $headwindComponentHe = abs($airport->metar->wind_speed * cos(($airport->metar->wind_direction - $runway->he_heading) / 180 * 3.14)); - $crosswindComponentHe = abs($airport->metar->wind_speed * sin(($airport->metar->wind_direction - $runway->he_heading) / 180 * pi())); + $headwindComponentHe = abs($airport->metar->wind_speed * cos(deg2rad($airport->metar->wind_direction - $runway->he_heading))); + $crosswindComponentHe = abs($airport->metar->wind_speed * sin(deg2rad($airport->metar->wind_direction - $runway->he_heading))); if ($activeRunwayComponents['headwind'] < $headwindComponentLe) { $activeRunwayComponents['headwind'] = $headwindComponentLe; @@ -172,7 +172,7 @@ public function handle() // Check if ongoing VATSIM event foreach ($airport->events as $event) { - if (Carbon::now()->gt($event->start_time) && Carbon::now()->lt($event->end_time)) { + if (now()->gt($event->start_time) && now()->lt($event->end_time)) { $airportScoreInsert[] = ['airport_id' => $airport->id, 'reason' => 'VATSIM_EVENT', 'score' => 1, 'data' => $event->event . ' until ' . Carbon::parse($event->end_time)->format('H:i\z')]; } diff --git a/app/Console/Commands/CreateApiKey.php b/app/Console/Commands/CreateApiKey.php index 6bc00266..f117c6f3 100644 --- a/app/Console/Commands/CreateApiKey.php +++ b/app/Console/Commands/CreateApiKey.php @@ -4,7 +4,7 @@ use App\Models\ApiKey; use Illuminate\Console\Command; -use Ramsey\Uuid\Uuid; +use Illuminate\Support\Str; class CreateApiKey extends Command { @@ -33,7 +33,7 @@ public function handle() $ip = $this->ask('Which IP address can use this key?'); // Generate key - $secret = Uuid::uuid4(); + $secret = Str::uuid(); ApiKey::create([ 'key' => $secret, 'name' => $name, diff --git a/app/Console/Commands/EnrichAirports.php b/app/Console/Commands/EnrichAirports.php index e21f03f2..0a236106 100644 --- a/app/Console/Commands/EnrichAirports.php +++ b/app/Console/Commands/EnrichAirports.php @@ -4,6 +4,7 @@ use App\Models\Airport; use Illuminate\Console\Command; +use Illuminate\Support\Facades\DB; class EnrichAirports extends Command { @@ -30,15 +31,13 @@ public function handle() { $processTime = microtime(true); - $count = 0; - $this->info('> Enriching airports with additional data...'); // Create booleans of airports that has airline service - $airlineQuery = Airport::where('scheduled_service', 'yes')->update(['w2f_scheduled_service' => true]); + Airport::where('scheduled_service', 'yes')->update(['w2f_scheduled_service' => true]); // Create booleans of airports that is an airforce base - $airforceAirports = Airport::where('name', 'like', '% RAF %') + Airport::where('name', 'like', '% RAF %') ->orWhere('name', 'like', 'RAF %') ->orWhere('name', 'like', '%Air Base%') ->orWhere('name', 'like', '%airbase%') @@ -54,9 +53,9 @@ public function handle() ->update(['w2f_airforcebase' => true]); // Transfer the gps_code to icao to make sure newest icao codes are used by default - $gpsCodeToIcao = Airport::where('gps_code', '!=', '') + Airport::where('gps_code', '!=', '') ->whereColumn('gps_code', '!=', 'icao') - ->update(['icao' => \DB::raw('gps_code')]); + ->update(['icao' => DB::raw('gps_code')]); // Set the airport as closed if no runways are open Airport::query()->update(['w2f_has_open_runway' => false]); diff --git a/app/Console/Commands/FetchFlights.php b/app/Console/Commands/FetchFlights.php index d4dee095..95c47406 100644 --- a/app/Console/Commands/FetchFlights.php +++ b/app/Console/Commands/FetchFlights.php @@ -4,7 +4,6 @@ use App\Models\Flight; use Illuminate\Console\Command; -use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Http; class FetchFlights extends Command @@ -39,7 +38,7 @@ public function handle() $response = Http::timeout(60)->retry(3, 1000)->get('https://airlabs.co/api/v9/flights?api_key=' . $apiKey); if ($response->successful()) { - $flights = collect(json_decode($response->body(), false)->response); + $flights = collect($response->object()->response); $upsertData = []; foreach ($flights as $flight) { @@ -60,7 +59,6 @@ public function handle() continue; } - isset($flight->reg_number) ? $regNumber = $flight->reg_number : $regNumber = null; $upsertData[] = [ 'airline_icao' => $flight->airline_icao, 'airline_iata' => $flight->airline_iata, @@ -69,7 +67,7 @@ public function handle() 'dep_icao' => $flight->dep_icao, 'arr_icao' => $flight->arr_icao, 'last_aircraft_icao' => $flight->aircraft_icao, - 'reg_number' => $regNumber, + 'reg_number' => $flight->reg_number, 'last_seen_at' => now(), 'lock_counter' => false, ]; @@ -105,7 +103,7 @@ public function handle() Flight::where('last_seen_at', '<', now()->subHours(6))->where('lock_counter', false)->update(['seen_counter' => \DB::raw('seen_counter + 1'), 'lock_counter' => true]); // Enrich the flights with aircraft types - Artisan::call('enrich:flights'); + $this->call('enrich:flights'); } else { $this->error('Failed to fetch flights. API response not successful.'); diff --git a/app/Console/Commands/FetchGithubIssues.php b/app/Console/Commands/FetchGithubIssues.php index db2e1e4a..a9360b51 100644 --- a/app/Console/Commands/FetchGithubIssues.php +++ b/app/Console/Commands/FetchGithubIssues.php @@ -37,6 +37,6 @@ public function handle() $this->info("No new issues. Highest issue: #{$highestFetched}"); } - $this->info('Fetched ' . count($data) . ' issues.'); + $this->info('Fetched ' . $data->count() . ' issues.'); } } diff --git a/app/Console/Commands/FetchMetars.php b/app/Console/Commands/FetchMetars.php index 486e9564..2963ff66 100644 --- a/app/Console/Commands/FetchMetars.php +++ b/app/Console/Commands/FetchMetars.php @@ -4,7 +4,6 @@ use App\Models\Airport; use App\Models\Metar; -use Carbon\Carbon; use Illuminate\Console\Command; use Illuminate\Support\Facades\Http; @@ -39,27 +38,24 @@ public function handle() if ($response->successful()) { $data = collect(preg_split("/\r\n|\n|\r/", $response->body())); - // Fetch all airports - $airportsFilter = []; - $airportsData = []; - foreach ($data as $d) { - $icao = substr($d, 0, 4); - $airportsFilter[] = $icao; - $airportsData[$icao] = $d; - } + // Index METAR lines by ICAO code + $airportsData = $data->keyBy(fn ($d) => substr($d, 0, 4)); // Get the relevant airports $upsertData = []; - foreach (Airport::whereIn('icao', $airportsFilter)->get() as $airport) { + foreach (Airport::whereIn('icao', $airportsData->keys()->all())->get() as $airport) { $d = $airportsData[strtoupper($airport->icao)]; + // Reset temperature to avoid carrying over a value from a previous iteration + unset($temperature); + // Don't add the METAR if it's not from today $metarDate = (int) substr($d, 5, 2); if ($metarDate != date('d')) { continue; } - $time = Carbon::now()->setDay($metarDate)->setHour((int) substr($d, 7, 2))->setMinute((int) substr($d, 9, 2))->setSeconds(0); + $time = now()->setDay($metarDate)->setHour((int) substr($d, 7, 2))->setMinute((int) substr($d, 9, 2))->setSeconds(0); $metar = substr($d, 13, null); // Fetch the wind direction and speed @@ -80,11 +76,9 @@ public function handle() } if (preg_match('/(M?\d\d)\/(M?\d\d)/', $metar, $temperatureResult)) { - if (substr($temperatureResult[1], 0, 1) == 'M') { - $temperature = (int) substr($temperatureResult[1], 1) * -1; - } else { - $temperature = (int) $temperatureResult[1]; - } + $temperature = str_starts_with($temperatureResult[1], 'M') + ? -(int) substr($temperatureResult[1], 1) + : (int) $temperatureResult[1]; } // Check for missing data diff --git a/app/Console/Commands/FetchVatsim.php b/app/Console/Commands/FetchVatsim.php index 9275fc9f..92c5c071 100644 --- a/app/Console/Commands/FetchVatsim.php +++ b/app/Console/Commands/FetchVatsim.php @@ -44,7 +44,7 @@ public function handle() $this->info('Fetching events...'); $response = Http::timeout(60)->retry(3, 1000)->get('https://my.vatsim.net/api/v2/events/latest'); if ($response->successful()) { - $data = json_decode($response->body(), false)->data; + $data = $response->object()->data; foreach ($data as $event) { if (count($event->airports)) { @@ -66,7 +66,7 @@ public function handle() $response = Http::get('https://data.vatsim.net/v3/vatsim-data.json'); if ($response->successful()) { - $data = json_decode($response->body(), false)->controllers; + $data = $response->object()->controllers; foreach ($data as $controller) { diff --git a/app/Console/Commands/UpdateData.php b/app/Console/Commands/UpdateData.php index 08ae968f..43bfdd7a 100644 --- a/app/Console/Commands/UpdateData.php +++ b/app/Console/Commands/UpdateData.php @@ -3,7 +3,6 @@ namespace App\Console\Commands; use Illuminate\Console\Command; -use Illuminate\Support\Facades\Artisan; class UpdateData extends Command { @@ -33,13 +32,13 @@ public function handle() $this->info('> Calling all relevant data update commands...'); $this->info('>> fetch:metars running'); - Artisan::call('fetch:metars'); + $this->call('fetch:metars'); $this->info('>> fetch:vatsim running'); - Artisan::call('fetch:vatsim'); + $this->call('fetch:vatsim'); $this->info('>> calc:scores running'); - Artisan::call('calc:scores'); + $this->call('calc:scores'); $this->info('> Done with all commands in ' . round(microtime(true) - $processTime) . ' seconds!'); diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php deleted file mode 100644 index 1ce88c69..00000000 --- a/app/Console/Kernel.php +++ /dev/null @@ -1,58 +0,0 @@ -command('update:data')->hourlyAt(15); - $schedule->command('update:data')->hourlyAt(40); - - // Fetch flights - $schedule->command('fetch:flights')->everyThirtyMinutes(); - - // Update if airlines have flights - $schedule->command('calc:flights')->daily(); - - // Fetch Github Issues cache - $schedule->command('fetch:github')->everyTenMinutes(); - - // Cleanup sceneries without attached simulators - $schedule->command('cleanup:sceneries')->daily(); - - // Backups - $schedule->command('backup:clean')->daily()->at('01:00'); - $schedule->command('backup:run')->daily()->at('01:30'); - - // Delete users who haven't verified their email address - $schedule->command('account:clear-unverified')->daily(); - - // Clear expired password reset tokens - $schedule->command('auth:clear-resets')->everyFifteenMinutes(); - - // Fetch new disposable domains - $schedule->command('disposable:update')->daily(); - } - - /** - * Register the commands for the application. - * - * @return void - */ - protected function commands() - { - $this->load(__DIR__ . '/Commands'); - - require base_path('routes/console.php'); - } -} diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php deleted file mode 100644 index c2e8debc..00000000 --- a/app/Exceptions/Handler.php +++ /dev/null @@ -1,58 +0,0 @@ -, LogLevel::*> - */ - protected $levels = [ - // - ]; - - /** - * A list of the exception types that are not reported. - * - * @var array> - */ - protected $dontReport = [ - // - ]; - - /** - * A list of the inputs that are never flashed to the session on validation exceptions. - * - * @var array - */ - protected $dontFlash = [ - 'current_password', - 'password', - 'password_confirmation', - ]; - - /** - * Register the exception handling callbacks for the application. - * - * @return void - */ - public function register() - { - $this->reportable(function (Throwable $e) { - if (app()->bound('sentry') && $this->shouldReport($e)) { - app('sentry')->configureScope(function (Scope $scope): void { - $scope->setUser(['id' => Auth::id()]); - }); - app('sentry')->captureException($e); - } - }); - } -} diff --git a/app/Helpers/AirportCallsignHelper.php b/app/Helpers/AirportCallsignHelper.php index 7721522b..b43a6552 100644 --- a/app/Helpers/AirportCallsignHelper.php +++ b/app/Helpers/AirportCallsignHelper.php @@ -8,11 +8,7 @@ public static function returnAustralianAirport($string) { $australianCallsigns = ['AD' => 'YPAD', 'BN' => 'YBBN', 'CS' => 'YBCS', 'CB' => 'YSCB', 'DN' => 'YPDN', 'EN' => 'YMEN', 'CG' => 'YBCG', 'ML' => 'YMML', 'PH' => 'YPPH', 'SY' => 'YSSY', 'TL' => 'YBTL', 'WLM' => 'YWLM', 'AMB' => 'YAMB', 'CIN' => 'YCIN', 'AF' => 'YBAF', 'AV' => 'YMAV', 'BK' => 'YSBK', 'CN' => 'YSCN', 'HB' => 'YMHB', 'JT' => 'YPJT', 'LT' => 'YMLT', 'MK' => 'YBMK', 'MB' => 'YMMB', 'PF' => 'YPPF', 'RK' => 'YBRK', 'AY' => 'YMAY', 'AS' => 'YBAS', 'BRM' => 'YBRM', 'CFS' => 'YCFS', 'HM' => 'YBHM', 'KA' => 'YPKA', 'SU' => 'YBSU', 'TW' => 'YSTW', 'WR' => 'YPWR']; - if (isset($australianCallsigns[$string])) { - return $australianCallsigns[$string]; - } - - return false; + return $australianCallsigns[$string] ?? false; } public static function returnAmericanIcao($string) diff --git a/app/Helpers/AirportFilterHelper.php b/app/Helpers/AirportFilterHelper.php index d38c76b7..f6b0da00 100644 --- a/app/Helpers/AirportFilterHelper.php +++ b/app/Helpers/AirportFilterHelper.php @@ -6,11 +6,11 @@ class AirportFilterHelper { public static function hasCorrectMetcon($metcon, $airport) { - if ($metcon == 'VFR' && ! $airport->hasVisualCondition()) { - return false; + if ($metcon === 'VFR') { + return $airport->hasVisualCondition(); } - if ($metcon == 'IFR' && $airport->hasVisualCondition()) { - return false; + if ($metcon === 'IFR') { + return ! $airport->hasVisualCondition(); } return true; diff --git a/app/Helpers/CalculationHelper.php b/app/Helpers/CalculationHelper.php index f720b675..86e528ba 100644 --- a/app/Helpers/CalculationHelper.php +++ b/app/Helpers/CalculationHelper.php @@ -16,26 +16,17 @@ class CalculationHelper */ public static function minimumRequiredRunwayLength(string $code) { - switch ($code) { - case 'GA': - return 100; - case 'GAT': - return 2000; - case 'GTP': - return 2500; - case 'JS': - return 4000; - case 'JM': - return 5000; - case 'JML': - return 6000; - case 'JL': - return 7000; - case 'JXL': - return 8000; - default: - return 0; - } + return match ($code) { + 'GA' => 100, + 'GAT' => 2000, + 'GTP' => 2500, + 'JS' => 4000, + 'JM' => 5000, + 'JML' => 6000, + 'JL' => 7000, + 'JXL' => 8000, + default => 0, + }; } /** @@ -47,37 +38,17 @@ public static function minimumRequiredRunwayLength(string $code) public static function aircraftNmPerHour(string $actCode) { - $crzSpeed = 0; - switch ($actCode) { - case 'GA': - $crzSpeed = 115; - break; - case 'GAT': - $crzSpeed = 190; - break; - case 'GTP': - $crzSpeed = 280; - break; - case 'JS': - $crzSpeed = 340; - break; - case 'JM': - $crzSpeed = 460; - break; - case 'JML': - $crzSpeed = 480; - break; - case 'JL': - $crzSpeed = 510; - break; - case 'JXL': - $crzSpeed = 520; - break; - default: - $crzSpeed = 0; - } - - return $crzSpeed; + return match ($actCode) { + 'GA' => 115, + 'GAT' => 190, + 'GTP' => 280, + 'JS' => 340, + 'JM' => 460, + 'JML' => 480, + 'JL' => 510, + 'JXL' => 520, + default => 0, + }; } /** @@ -89,37 +60,17 @@ public static function aircraftNmPerHour(string $actCode) public static function timeClimbDescend(string $actCode) { - $addMinutes = 0; - switch ($actCode) { - case 'GA': - $addMinutes = 0.13; - break; - case 'GAT': - $addMinutes = 0.20; - break; - case 'GTP': - $addMinutes = 0.25; - break; - case 'JS': - $addMinutes = 0.33; - break; - case 'JM': - $addMinutes = 0.42; - break; - case 'JML': - $addMinutes = 0.47; - break; - case 'JL': - $addMinutes = 0.50; - break; - case 'JXL': - $addMinutes = 0.58; - break; - default: - $addMinutes = 0; - } - - return $addMinutes; + return match ($actCode) { + 'GA' => 0.13, + 'GAT' => 0.20, + 'GTP' => 0.25, + 'JS' => 0.33, + 'JM' => 0.42, + 'JML' => 0.47, + 'JL' => 0.50, + 'JXL' => 0.58, + default => 0, + }; } @@ -133,12 +84,11 @@ public static function timeClimbDescend(string $actCode) */ public static function aircraftNmPerHourRange(string $actCode, int $minHours, int $maxHours) { - $minDistance = self::aircraftNmPerHour($actCode); - $maxDistance = self::aircraftNmPerHour($actCode); + $speed = self::aircraftNmPerHour($actCode); // Convert to nm and multiply by hours - $minDistance = ($minDistance) * $minHours; - $maxDistance = ($maxDistance) * $maxHours; + $minDistance = $speed * $minHours; + $maxDistance = $speed * $maxHours; if ($minDistance !== 0) { $minDistance += self::timeClimbDescend($actCode); @@ -169,19 +119,8 @@ public static function calculateSphericalDestination(Coordinate $point, float $b $Φ = rad2deg($Φ); $Λ = rad2deg($Λ); - if ($Φ > 90) { - $Φ = 90; - } - if ($Φ < -90) { - $Φ = -90; - } - - if ($Λ > 180) { - $Λ = 180; - } - if ($Λ < -180) { - $Λ = -180; - } + $Φ = max(-90.0, min(90.0, $Φ)); + $Λ = max(-180.0, min(180.0, $Λ)); return new Coordinate($Φ, $Λ); } diff --git a/app/Helpers/SceneryHelper.php b/app/Helpers/SceneryHelper.php index 335018ae..4e03c587 100644 --- a/app/Helpers/SceneryHelper.php +++ b/app/Helpers/SceneryHelper.php @@ -53,10 +53,7 @@ public static function findCheapestStore($stores, $compatibleSimulator) } if (in_array($compatibleSimulator, $store->simulatorVersions)) { - if ($cheapestStore == null) { - $cheapestStore = $store; - } - if ($store->currencyPrice->EUR < $cheapestStore->currencyPrice->EUR) { + if ($cheapestStore === null || $store->currencyPrice->EUR < $cheapestStore->currencyPrice->EUR) { $cheapestStore = $store; } } @@ -129,7 +126,7 @@ public static function getEmbeddedUrl($fullUrl) parse_str($urlComponents['query'], $queryParams); // Retrieve the 'url' parameter value - $embeddedUrl = isset($queryParams['url']) ? $queryParams['url'] : null; + $embeddedUrl = $queryParams['url'] ?? null; // Strip 'www.' and 'secure.' and addoncompare from the URL if ($embeddedUrl) { diff --git a/app/Helpers/helpers.php b/app/Helpers/helpers.php index 3bf83b10..2ace711e 100644 --- a/app/Helpers/helpers.php +++ b/app/Helpers/helpers.php @@ -17,11 +17,8 @@ function correctHeading(float $heading) function getCountryName($countryCode) { $countries = ['AF' => 'Afghanistan', 'AL' => 'Albania', 'DZ' => 'Algeria', 'AS' => 'American Samoa', 'AD' => 'Andorra', 'AO' => 'Angola', 'AI' => 'Anguilla', 'AQ' => 'Antarctica', 'AG' => 'Antigua And Barbuda', 'AR' => 'Argentina', 'AM' => 'Armenia', 'AW' => 'Aruba', 'AU' => 'Australia', 'AT' => 'Austria', 'AZ' => 'Azerbaijan', 'BS' => 'Bahamas', 'BH' => 'Bahrain', 'BD' => 'Bangladesh', 'BB' => 'Barbados', 'BY' => 'Belarus', 'BE' => 'Belgium', 'BZ' => 'Belize', 'BJ' => 'Benin', 'BM' => 'Bermuda', 'BT' => 'Bhutan', 'BO' => 'Bolivia', 'BA' => 'Bosnia And Herzegovina', 'BW' => 'Botswana', 'BV' => 'Bouvet Island', 'BR' => 'Brazil', 'IO' => 'British Indian Ocean Territory', 'BN' => 'Brunei Darussalam', 'BG' => 'Bulgaria', 'BF' => 'Burkina Faso', 'BI' => 'Burundi', 'KH' => 'Cambodia', 'CM' => 'Cameroon', 'CA' => 'Canada', 'CV' => 'Cape Verde', 'KY' => 'Cayman Islands', 'CF' => 'Central African Republic', 'TD' => 'Chad', 'CL' => 'Chile', 'CN' => 'China', 'CX' => 'Christmas Island', 'CC' => 'Cocos (Keeling) Islands', 'CO' => 'Colombia', 'KM' => 'Comoros', 'CG' => 'Congo', 'CD' => 'Congo, Democratic Republic', 'CK' => 'Cook Islands', 'CR' => 'Costa Rica', 'CI' => 'Cote D\'Ivoire', 'HR' => 'Croatia', 'CU' => 'Cuba', 'CY' => 'Cyprus', 'CZ' => 'Czech Republic', 'DK' => 'Denmark', 'DJ' => 'Djibouti', 'DM' => 'Dominica', 'DO' => 'Dominican Republic', 'EC' => 'Ecuador', 'EG' => 'Egypt', 'SV' => 'El Salvador', 'GQ' => 'Equatorial Guinea', 'ER' => 'Eritrea', 'EE' => 'Estonia', 'ET' => 'Ethiopia', 'FK' => 'Falkland Islands (Malvinas)', 'FO' => 'Faroe Islands', 'FJ' => 'Fiji', 'FI' => 'Finland', 'FR' => 'France', 'GF' => 'French Guiana', 'PF' => 'French Polynesia', 'TF' => 'French Southern Territories', 'GA' => 'Gabon', 'GM' => 'Gambia', 'GE' => 'Georgia', 'DE' => 'Germany', 'GH' => 'Ghana', 'GI' => 'Gibraltar', 'GR' => 'Greece', 'GL' => 'Greenland', 'GD' => 'Grenada', 'GP' => 'Guadeloupe', 'GU' => 'Guam', 'GT' => 'Guatemala', 'GG' => 'Guernsey', 'GN' => 'Guinea', 'GW' => 'Guinea-Bissau', 'GY' => 'Guyana', 'HT' => 'Haiti', 'HM' => 'Heard Island & Mcdonald Islands', 'VA' => 'Holy See (Vatican City State)', 'HN' => 'Honduras', 'HK' => 'Hong Kong', 'HU' => 'Hungary', 'IS' => 'Iceland', 'IN' => 'India', 'ID' => 'Indonesia', 'IR' => 'Iran, Islamic Republic Of', 'IQ' => 'Iraq', 'IE' => 'Ireland', 'IM' => 'Isle Of Man', 'IL' => 'Israel', 'IT' => 'Italy', 'JM' => 'Jamaica', 'JP' => 'Japan', 'JE' => 'Jersey', 'JO' => 'Jordan', 'KZ' => 'Kazakhstan', 'KE' => 'Kenya', 'KI' => 'Kiribati', 'KR' => 'Korea', 'KW' => 'Kuwait', 'KG' => 'Kyrgyzstan', 'LA' => 'Lao People\'s Democratic Republic', 'LV' => 'Latvia', 'LB' => 'Lebanon', 'LS' => 'Lesotho', 'LR' => 'Liberia', 'LY' => 'Libyan Arab Jamahiriya', 'LI' => 'Liechtenstein', 'LT' => 'Lithuania', 'LU' => 'Luxembourg', 'MO' => 'Macao', 'MK' => 'Macedonia', 'MG' => 'Madagascar', 'MW' => 'Malawi', 'MY' => 'Malaysia', 'MV' => 'Maldives', 'ML' => 'Mali', 'MT' => 'Malta', 'MH' => 'Marshall Islands', 'MQ' => 'Martinique', 'MR' => 'Mauritania', 'MU' => 'Mauritius', 'YT' => 'Mayotte', 'MX' => 'Mexico', 'FM' => 'Micronesia, Federated States Of', 'MD' => 'Moldova', 'MC' => 'Monaco', 'MN' => 'Mongolia', 'ME' => 'Montenegro', 'MS' => 'Montserrat', 'MA' => 'Morocco', 'MZ' => 'Mozambique', 'MM' => 'Myanmar', 'NA' => 'Namibia', 'NR' => 'Nauru', 'NP' => 'Nepal', 'NL' => 'Netherlands', 'AN' => 'Netherlands Antilles', 'NC' => 'New Caledonia', 'NZ' => 'New Zealand', 'NI' => 'Nicaragua', 'NE' => 'Niger', 'NG' => 'Nigeria', 'NU' => 'Niue', 'NF' => 'Norfolk Island', 'MP' => 'Northern Mariana Islands', 'NO' => 'Norway', 'OM' => 'Oman', 'PK' => 'Pakistan', 'PW' => 'Palau', 'PS' => 'Palestinian Territory, Occupied', 'PA' => 'Panama', 'PG' => 'Papua New Guinea', 'PY' => 'Paraguay', 'PE' => 'Peru', 'PH' => 'Philippines', 'PN' => 'Pitcairn', 'PL' => 'Poland', 'PT' => 'Portugal', 'PR' => 'Puerto Rico', 'QA' => 'Qatar', 'RE' => 'Reunion', 'RO' => 'Romania', 'RU' => 'Russian Federation', 'RW' => 'Rwanda', 'BL' => 'Saint Barthelemy', 'SH' => 'Saint Helena', 'KN' => 'Saint Kitts And Nevis', 'LC' => 'Saint Lucia', 'MF' => 'Saint Martin', 'PM' => 'Saint Pierre And Miquelon', 'VC' => 'Saint Vincent And Grenadines', 'WS' => 'Samoa', 'SM' => 'San Marino', 'ST' => 'Sao Tome And Principe', 'SA' => 'Saudi Arabia', 'SN' => 'Senegal', 'RS' => 'Serbia', 'SC' => 'Seychelles', 'SL' => 'Sierra Leone', 'SG' => 'Singapore', 'SK' => 'Slovakia', 'SI' => 'Slovenia', 'SB' => 'Solomon Islands', 'SO' => 'Somalia', 'ZA' => 'South Africa', 'GS' => 'South Georgia And Sandwich Isl.', 'ES' => 'Spain', 'LK' => 'Sri Lanka', 'SD' => 'Sudan', 'SR' => 'Suriname', 'SJ' => 'Svalbard And Jan Mayen', 'SZ' => 'Swaziland', 'SE' => 'Sweden', 'CH' => 'Switzerland', 'SY' => 'Syrian Arab Republic', 'TW' => 'Taiwan', 'TJ' => 'Tajikistan', 'TZ' => 'Tanzania', 'TH' => 'Thailand', 'TL' => 'Timor-Leste', 'TG' => 'Togo', 'TK' => 'Tokelau', 'TO' => 'Tonga', 'TT' => 'Trinidad And Tobago', 'TN' => 'Tunisia', 'TR' => 'Turkey', 'TM' => 'Turkmenistan', 'TC' => 'Turks And Caicos Islands', 'TV' => 'Tuvalu', 'UG' => 'Uganda', 'UA' => 'Ukraine', 'AE' => 'United Arab Emirates', 'GB' => 'United Kingdom', 'US' => 'United States', 'UM' => 'United States Outlying Islands', 'UY' => 'Uruguay', 'UZ' => 'Uzbekistan', 'VU' => 'Vanuatu', 'VE' => 'Venezuela', 'VN' => 'Viet Nam', 'VG' => 'Virgin Islands, British', 'VI' => 'Virgin Islands, U.S.', 'WF' => 'Wallis And Futuna', 'EH' => 'Western Sahara', 'XK' => 'Kosovo', 'YE' => 'Yemen', 'ZM' => 'Zambia', 'ZW' => 'Zimbabwe']; - if (isset($countries[$countryCode])) { - return $countries[$countryCode]; - } - return 'Unknown Country'; + return $countries[$countryCode] ?? 'Unknown Country'; } } diff --git a/app/Http/Controllers/API/SearchController.php b/app/Http/Controllers/API/SearchController.php index bd857ce7..6ab73106 100644 --- a/app/Http/Controllers/API/SearchController.php +++ b/app/Http/Controllers/API/SearchController.php @@ -5,6 +5,8 @@ use App\Helpers\CalculationHelper; use App\Http\Controllers\Controller; use App\Http\Controllers\ScoreController; +use App\Http\Resources\AirportResource; +use App\Http\Resources\SuggestedAirportResource; use App\Models\Airport; use App\Rules\AirportExists; use App\Rules\ValidDestinations; @@ -42,26 +44,25 @@ public function search(Request $request) 'limit' => ['sometimes', 'integer', 'between:1,30'], ]); - isset($data['departure']) ? $departure = $data['departure'] : $departure = null; - isset($data['arrival']) ? $arrival = $data['arrival'] : $arrival = null; - isset($data['destinations']) ? $destinations = $data['destinations'] : $destinations = ['continents' => null, 'countries' => null, 'states' => null]; + $departure = $data['departure'] ?? null; + $arrival = $data['arrival'] ?? null; + $destinations = $data['destinations'] ?? ['continents' => null, 'countries' => null, 'states' => null]; $codeletter = $data['codeletter']; - isset($data['airtimeMin']) ? $airtimeMin = $data['airtimeMin'] : $airtimeMin = 0; - isset($data['airtimeMax']) ? $airtimeMax = $data['airtimeMax'] : $airtimeMax = 24; - isset($data['scores']) ? $filterByScores = array_map('intval', $data['scores']) : $filterByScores = null; - isset($data['metcondition']) ? $metcon = $data['metcondition'] : $metcon = null; - isset($data['destinationRunwayLights']) ? $destinationRunwayLights = (int) $data['destinationRunwayLights'] : $destinationRunwayLights = 0; - isset($data['destinationAirbases']) ? $destinationAirbases = (int) $data['destinationAirbases'] : $destinationAirbases = -1; - (isset($data['destinationAirportSize']) && ! empty($data['destinationAirportSize'])) ? $destinationAirportSize = $data['destinationAirportSize'] : $destinationAirportSize = ['small_airport', 'medium_airport', 'large_airport']; - isset($data['temperatureMin']) ? $temperatureMin = $data['temperatureMin'] : $temperatureMin = -60; - isset($data['temperatureMax']) ? $temperatureMax = $data['temperatureMax'] : $temperatureMax = 60; - isset($data['elevationMin']) ? $elevationMin = $data['elevationMin'] : $elevationMin = -2000; - isset($data['elevationMax']) ? $elevationMax = $data['elevationMax'] : $elevationMax = 18000; - isset($data['rwyLengthMin']) ? $rwyLengthMin = $data['rwyLengthMin'] : $rwyLengthMin = 0; - isset($data['rwyLengthMax']) ? $rwyLengthMax = $data['rwyLengthMax'] : $rwyLengthMax = 17000; - - isset($data['arrivalWhitelist']) ? $arrivalWhitelist = $data['arrivalWhitelist'] : $arrivalWhitelist = null; - isset($data['limit']) ? $resultLimit = $data['limit'] : $resultLimit = 10; + $airtimeMin = $data['airtimeMin'] ?? 0; + $airtimeMax = $data['airtimeMax'] ?? 24; + $filterByScores = isset($data['scores']) ? array_map('intval', $data['scores']) : null; + $metcon = $data['metcondition'] ?? null; + $destinationRunwayLights = (int) ($data['destinationRunwayLights'] ?? 0); + $destinationAirbases = (int) ($data['destinationAirbases'] ?? -1); + $destinationAirportSize = ! empty($data['destinationAirportSize'] ?? []) ? $data['destinationAirportSize'] : ['small_airport', 'medium_airport', 'large_airport']; + $temperatureMin = $data['temperatureMin'] ?? -60; + $temperatureMax = $data['temperatureMax'] ?? 60; + $elevationMin = $data['elevationMin'] ?? -2000; + $elevationMax = $data['elevationMax'] ?? 18000; + $rwyLengthMin = $data['rwyLengthMin'] ?? 0; + $rwyLengthMax = $data['rwyLengthMax'] ?? 17000; + $arrivalWhitelist = $data['arrivalWhitelist'] ?? null; + $resultLimit = $data['limit'] ?? 10; [$minDistance, $maxDistance] = CalculationHelper::aircraftNmPerHourRange($codeletter, $airtimeMin, $airtimeMax); @@ -106,12 +107,11 @@ public function search(Request $request) * Fetch the requested data */ if ($departure) { - $airport = Airport::where('icao', $departure)->orWhere('local_code', $departure)->get()->first(); + $airport = Airport::where('icao', $departure)->orWhere('local_code', $departure)->first(); } else { - $airport = Airport::where('icao', $arrival)->orWhere('local_code', $arrival)->get()->first(); + $airport = Airport::where('icao', $arrival)->orWhere('local_code', $arrival)->first(); } - $airports = collect(); $airports = Airport::airportOpen()->notIcao($airport->icao)->isAirportSize($destinationAirportSize) ->inContinent($destinations)->inCountry($destinations, $airport->iso_country)->inState($destinations) ->withinDistance($airport, $minDistance, $maxDistance, $airport->icao) @@ -127,7 +127,7 @@ public function search(Request $request) return $group->shuffle(); })->flatten(1)->take(20); - $suggestedAirports = $airports->filterWithCriteria($airport, $codeletter, $airtimeMin, $airtimeMax, $metcon, $temperatureMin, $temperatureMax, $rwyLengthMin, $rwyLengthMax, $elevationMin, $elevationMax); + $suggestedAirports = $airports->filterWithCriteria($airport, $codeletter, $metcon, $temperatureMin, $temperatureMax, $elevationMin, $elevationMax); /** * Prepare the data for the response @@ -153,38 +153,9 @@ public function search(Request $request) public function prepareAirportData($airport, $suggestedAirports) { - $airportData = [ - 'name' => $airport->name, - 'icao' => $airport->icao, - 'iata' => $airport->iata_code ? $airport->iata_code : null, - 'contient' => $airport->continent, - 'country' => $airport->iso_country, - 'region' => $airport->iso_region, - 'metar' => (config('app.env') == 'production') ? $airport->metar->metar : 'TEST-DATA ' . $airport->metar->metar, - 'longestRwyFt' => $airport->longestRunway(), - 'scores' => $airport->scores->pluck('reason'), + return [ + new AirportResource($airport), + SuggestedAirportResource::collection($suggestedAirports), ]; - - $suggestedData = collect(); - foreach ($suggestedAirports as $suggestedAirport) { - $scores = $suggestedAirport->scores->pluck('reason'); - $suggestedData->push([ - 'name' => $suggestedAirport->name, - 'icao' => $suggestedAirport->icao, - 'iata' => $suggestedAirport->iata_code ? $suggestedAirport->iata_code : null, - 'contient' => $suggestedAirport->continent, - 'country' => $suggestedAirport->iso_country, - 'region' => $suggestedAirport->iso_region, - 'metar' => (config('app.env') == 'production') ? $suggestedAirport->metar->metar : 'TEST-DATA ' . $suggestedAirport->metar->metar, - 'longestRwyFt' => $suggestedAirport->longestRunway(), - 'scores' => $scores, - 'airtime' => $suggestedAirport->airtime, - 'distanceNm' => $suggestedAirport->distance, - 'isAirforcebase' => $suggestedAirport->w2f_airforcebase, - 'hasAirlineService' => $suggestedAirport->w2f_scheduled_service, - ]); - } - - return [$airportData, $suggestedData]; } } diff --git a/app/Http/Controllers/API/TopController.php b/app/Http/Controllers/API/TopController.php index f1275a7e..a54334e4 100644 --- a/app/Http/Controllers/API/TopController.php +++ b/app/Http/Controllers/API/TopController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\API; use App\Http\Controllers\Controller; +use App\Http\Resources\AirportResource; use App\Models\AirportScore; use Illuminate\Http\Request; @@ -16,10 +17,10 @@ public function index(Request $request) 'limit' => 'sometimes|integer|between:1,30', ]); $continent = $data['continent'] ?? null; - isset($data['limit']) ? $resultLimit = $data['limit'] : $resultLimit = 10; + $resultLimit = $data['limit'] ?? 10; $airportScores = AirportScore::getTopAirports($continent, null, $resultLimit); - $airports = $this->prepareResponse($airportScores); + $airports = AirportResource::collection($airportScores->pluck('airport')); return response()->json([ 'message' => 'Success', @@ -36,10 +37,10 @@ public function indexWhitelist(Request $request) 'limit' => 'sometimes|integer|between:1,30', ]); - isset($data['limit']) ? $resultLimit = $data['limit'] : $resultLimit = 10; + $resultLimit = $data['limit'] ?? 10; $airportScores = AirportScore::getTopAirports(null, $data['whitelist'], $resultLimit); - $airports = $this->prepareResponse($airportScores); + $airports = AirportResource::collection($airportScores->pluck('airport')); return response()->json([ 'message' => 'Success', @@ -47,30 +48,4 @@ public function indexWhitelist(Request $request) ], 200); } - - private function prepareResponse($airportScores) - { - $result = collect(); - - foreach ($airportScores as $as) { - - $scores = $as->airport->scores->pluck('reason'); - - $result->push([ - - 'name' => $as->airport->name, - 'icao' => $as->airport->icao, - 'iata' => $as->airport->iata_code ? $as->airport->iata_code : null, - 'contient' => $as->airport->continent, - 'country' => $as->airport->iso_country, - 'region' => $as->airport->iso_region, - 'metar' => (config('app.env') == 'production') ? $as->airport->metar->metar : 'TEST-DATA ' . $as->airport->metar->metar, - 'longestRwyFt' => $as->airport->longestRunway(), - 'scores' => $scores, - - ]); - } - - return $result; - } } diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index 7a2c3bc1..9791beac 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -27,16 +27,12 @@ public function login(Request $request, User $user) ]); $user = User::where('username', $request->username)->orWhere('email', $request->username)->first(); - $remember = ($request->remember) ? true : false; + $remember = (bool) $request->remember; // Check if the user exists and the password is correct if ($user && Hash::check($request->password, $user->password)) { - if ($remember) { - Auth::login($user, true); - } else { - Auth::login($user); - } + Auth::login($user, $remember); // Route to the original url the user was trying to access before promoted with login if ($request->session()->has('url.intended')) { diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 6154418c..e7f7c94b 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -3,14 +3,8 @@ namespace App\Http\Controllers; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Foundation\Bus\DispatchesJobs; -use Illuminate\Foundation\Validation\ValidatesRequests; -use Illuminate\Routing\Controller as BaseController; -/** - * Some standard controller extending base. - */ -class Controller extends BaseController +abstract class Controller { - use AuthorizesRequests, DispatchesJobs, ValidatesRequests; + use AuthorizesRequests; } diff --git a/app/Http/Controllers/FeedbackController.php b/app/Http/Controllers/FeedbackController.php index 147efc3d..35989c59 100644 --- a/app/Http/Controllers/FeedbackController.php +++ b/app/Http/Controllers/FeedbackController.php @@ -67,7 +67,7 @@ public function storeVote() ]); // Check if the user has already voted for this issue - $existingVote = FeedbackVote::where('user_id', auth()->user()->id) + $existingVote = FeedbackVote::where('user_id', auth()->id()) ->where('github_issue_number', $data['github_issue_number']) ->first(); @@ -77,7 +77,7 @@ public function storeVote() // Create the vote FeedbackVote::create([ - 'user_id' => auth()->user()->id, + 'user_id' => auth()->id(), 'github_issue_number' => $data['github_issue_number'], ]); @@ -90,7 +90,7 @@ public function storeVote() public function destroyVote(string $id) { // Check if the vote exists - $vote = FeedbackVote::where('user_id', auth()->user()->id) + $vote = FeedbackVote::where('user_id', auth()->id()) ->where('github_issue_number', $id) ->first(); @@ -170,12 +170,10 @@ private function fetchVotes() $allVotes = FeedbackVote::all(); // Group votes by issue number and count them - $groupedVotes = $allVotes->groupBy('github_issue_number')->map(function ($votes) { - return $votes->count(); - }); + $groupedVotes = $allVotes->groupBy('github_issue_number')->map->count(); // Get the votes of the current user - $userVotes = auth()->user() ? $allVotes->where('user_id', auth()->user()->id)->pluck('github_issue_number')->toArray() : null; + $userVotes = auth()->check() ? $allVotes->where('user_id', auth()->id())->pluck('github_issue_number')->toArray() : null; return [$groupedVotes, $userVotes]; } diff --git a/app/Http/Controllers/SceneryController.php b/app/Http/Controllers/SceneryController.php index 45f4dcfc..e854ec1a 100644 --- a/app/Http/Controllers/SceneryController.php +++ b/app/Http/Controllers/SceneryController.php @@ -48,7 +48,7 @@ public function store(Request $request) $scenery = new SceneryDeveloper(); $scenery->icao = strtoupper($request->icao); $scenery->developer = $request->developer; - $scenery->airport_id = Airport::where('icao', $request->icao)->get()->first()->id; + $scenery->airport_id = Airport::where('icao', $request->icao)->first()->id; $scenery->save(); } @@ -59,7 +59,7 @@ public function store(Request $request) 'scenery_developer_id' => $scenery->id, 'simulator_id' => $simulatorId, 'link' => $request->link, - 'payware' => $request->payware ? true : false, + 'payware' => (bool) $request->payware, 'published' => false, 'source' => 'user_contribution', 'suggested_by_user_id' => Auth::id(), @@ -110,8 +110,8 @@ public function update(Request $request, Scenery $scenery) $sceneryDeveloper->save(); $scenery->link = $request->link; - $scenery->payware = $request->payware ? true : false; - $scenery->published = $request->published ? true : false; + $scenery->payware = (bool) $request->payware; + $scenery->published = (bool) $request->published; $scenery->source = 'user_contribution'; $scenery->suggested_by_user_id = ($request->suggested_by_user_id) ? $request->suggested_by_user_id : Auth::id(); $scenery->save(); @@ -139,9 +139,9 @@ public function indexAirports(Request $request, ?string $filteredSim = null) $filteredSimulator = $availableSimulators->where('shortened_name', $filteredSim)->first(); if ($filteredSimulator) { - $airports = Airport::whereHasPublishedSceneries(true, $filteredSimulator->id)->get(); + $airports = Airport::publishedSceneries(true, $filteredSimulator->id)->get(); } else { - $airports = Airport::whereHasPublishedSceneries(true)->get(); + $airports = Airport::publishedSceneries(true)->get(); } $airportMapData = json_encode(MapHelper::generateAirportMapDataFromAirports($airports)); diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index 15debe87..822ea406 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -33,30 +33,21 @@ class SearchController extends Controller */ public function indexArrivalSearch() { - $airlines = Airline::where('has_flights', true)->orderBy('name')->get(); - $aircrafts = Aircraft::all()->pluck('icao')->sort(); - $prefilledIcao = request()->input('icao'); - $destinationInputs = $this->getDestinationInputs(); - $whitelistDatabase = null; - - if (Auth::check()) { - $lists = UserList::where('user_id', Auth::id())->orWhere('public', true)->get(); - } else { - $lists = UserList::where('public', true)->get(); - } - - // Get whitelist table if present in old input - if (old('whitelists') !== null) { - $whitelistDatabase = $this->getWhitelistsFromInput(old('whitelists')); - } - - return view('front.arrivals', compact('airlines', 'aircrafts', 'prefilledIcao', 'lists', 'destinationInputs', 'whitelistDatabase')); + return $this->buildSearchView('front.arrivals'); } /** * Display a listing of the resource. */ public function indexDepartureSearch() + { + return $this->buildSearchView('front.departures'); + } + + /** + * Build the shared view data for the arrival/departure search forms. + */ + private function buildSearchView(string $view): View { $airlines = Airline::where('has_flights', true)->orderBy('name')->get(); $aircrafts = Aircraft::all()->pluck('icao')->sort(); @@ -64,17 +55,15 @@ public function indexDepartureSearch() $destinationInputs = $this->getDestinationInputs(); $whitelistDatabase = null; - if (Auth::check()) { - $lists = UserList::where('user_id', Auth::id())->orWhere('public', true)->get(); - } else { - $lists = UserList::where('public', true)->get(); - } + $lists = UserList::where('public', true) + ->when(Auth::check(), fn ($q) => $q->orWhere('user_id', Auth::id())) + ->get(); if (old('whitelists') !== null) { $whitelistDatabase = $this->getWhitelistsFromInput(old('whitelists')); } - return view('front.departures', compact('airlines', 'aircrafts', 'prefilledIcao', 'lists', 'destinationInputs', 'whitelistDatabase')); + return view($view, compact('airlines', 'aircrafts', 'prefilledIcao', 'lists', 'destinationInputs', 'whitelistDatabase')); } /** @@ -178,8 +167,8 @@ public function search(Request $request) $rwyLengthMin = (int) $data['rwyLengthMin']; $rwyLengthMax = (int) $data['rwyLengthMax']; - isset($data['airlines']) ? $filterByAirlines = $data['airlines'] : $filterByAirlines = null; - isset($data['aircrafts']) ? $filterByAircrafts = $data['aircrafts'] : $filterByAircrafts = null; + $filterByAirlines = $data['airlines'] ?? null; + $filterByAircrafts = $data['aircrafts'] ?? null; [$minDistance, $maxDistance] = CalculationHelper::aircraftNmPerHourRange($codeletter, $airtimeMin, $airtimeMax); @@ -194,7 +183,7 @@ public function search(Request $request) // Use the supplied departure or select a random airport $suggestedAirport = false; if (isset($data['icao'])) { - $primaryAirport = Airport::where('icao', $data['icao'])->orWhere('local_code', $data['icao'])->get()->first(); + $primaryAirport = Airport::where('icao', $data['icao'])->orWhere('local_code', $data['icao'])->first(); } else { // Select primary airport based on the criteria $primaryAirport = Airport::airportOpen()->isAirportSize($destinationAirportSize) @@ -218,7 +207,6 @@ public function search(Request $request) } // Get airports according to filter - $airports = collect(); $airports = Airport::airportOpen()->notIcao($primaryAirport->icao)->isAirportSize($destinationAirportSize) ->inContinent($destinations)->inCountry($destinations, $primaryAirport->iso_country)->inState($destinations) ->notInContinent($destinationExclusions)->notInCountry($destinationExclusions, $primaryAirport->iso_country)->notInState($destinationExclusions) @@ -246,7 +234,7 @@ public function search(Request $request) })->flatten(1)->take(20); // Filter the eligible airports - $suggestedAirports = $airports->filterWithCriteria($primaryAirport, $codeletter, $airtimeMin, $airtimeMax, $metcon, $temperatureMin, $temperatureMax, $rwyLengthMin, $rwyLengthMax, $elevationMin, $elevationMax); + $suggestedAirports = $airports->filterWithCriteria($primaryAirport, $codeletter, $metcon, $temperatureMin, $temperatureMax, $elevationMin, $elevationMax); // If max distance is over 1600 and bearing is enabled -> give user warning about inaccuracy $bearingWarning = false; @@ -285,11 +273,7 @@ public function search(Request $request) } - if ($direction == 'departure') { - return redirect(route('front'))->withErrors(['airportNotFound' => 'No suitable arrival airport could be found with given criteria', 'bearingWarning' => $bearingWarning])->withInput(); - } else { - return redirect(route('front'))->withErrors(['airportNotFound' => 'No suitable arrival airport could be found with given criteria', 'bearingWarning' => $bearingWarning])->withInput(); - } + return redirect(route('front'))->withErrors(['airportNotFound' => 'No suitable airport could be found with given criteria', 'bearingWarning' => $bearingWarning])->withInput(); } /** @@ -306,8 +290,8 @@ public function searchRoutes(Request $request) 'sort' => ['required', 'in:flight,airline,timestamp'], ]); - $departure = Airport::where('icao', $data['departure'])->orWhere('local_code', $data['departure'])->get()->first(); - $arrival = Airport::where('icao', $data['arrival'])->orWhere('local_code', $data['arrival'])->get()->first(); + $departure = Airport::where('icao', $data['departure'])->orWhere('local_code', $data['departure'])->first(); + $arrival = Airport::where('icao', $data['arrival'])->orWhere('local_code', $data['arrival'])->first(); $routes = Flight::where('airport_dep_id', $departure->id)->where('airport_arr_id', $arrival->id)->whereHas('airline')->with('airline', 'aircrafts')->get(); @@ -427,9 +411,9 @@ private function filterDestinations(array $destinations) 'countries' => 'Domestic', 'states' => null, ]; - } elseif (strpos($destination, 'C-') === 0) { + } elseif (str_starts_with($destination, 'C-')) { $continents[] = substr($destination, 2); - } elseif (strpos($destination, 'US-') === 0) { + } elseif (str_starts_with($destination, 'US-')) { $states[] = $destination; } else { $countries[] = $destination; diff --git a/app/Http/Controllers/TopController.php b/app/Http/Controllers/TopController.php index 0b855511..394f1f35 100644 --- a/app/Http/Controllers/TopController.php +++ b/app/Http/Controllers/TopController.php @@ -4,17 +4,15 @@ use App\Helpers\MapHelper; use App\Models\AirportScore; -use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\View\View; class TopController extends Controller { /** * List all top airports - * - * @return RedirectResponse */ - public function index(Request $request, ?string $continent = null) + public function index(Request $request, ?string $continent = null): View { $exclude = $request->input('exclude'); $airportScores = AirportScore::getTopAirports($continent, null, 30, $exclude); diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 7759379b..c8519a9f 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -34,9 +34,6 @@ public function store(Request $request) 'privacy_policy' => ['accepted'], ]); - // Hash the password - $data['password'] = Hash::make($data['password']); - $user = User::create($data); if ($user) { event(new Registered($user)); diff --git a/app/Http/Controllers/UserListController.php b/app/Http/Controllers/UserListController.php index b17d9578..e347b125 100644 --- a/app/Http/Controllers/UserListController.php +++ b/app/Http/Controllers/UserListController.php @@ -56,21 +56,21 @@ public function store(Request $request) 'public' => 'boolean', ]); - $request->public = $request->public ? true : false; + $request->public = (bool) $request->public; if ($request->public) { $this->authorize('public', UserList::class); } [$airportIds, $notFoundAirports] = $this->resolveAirports($request->airports); - $list = DB::transaction(function () use ($request, $airportIds) { - $list = new UserList(); - $list->color = $request->color; - $list->name = $request->name; - $list->simulator_id = $request->simulator; - $list->user_id = Auth::id(); - $list->public = $request->public; - $list->save(); + DB::transaction(function () use ($request, $airportIds) { + $list = UserList::create([ + 'color' => $request->color, + 'name' => $request->name, + 'simulator_id' => $request->simulator, + 'user_id' => Auth::id(), + 'public' => $request->boolean('public'), + ]); $list->airports()->sync($airportIds); @@ -114,7 +114,7 @@ public function update(Request $request, UserList $list) 'public' => 'boolean', ]); - $request->public = $request->public ? true : false; + $request->public = (bool) $request->public; if ($request->public) { $this->authorize('public', UserList::class); } @@ -145,12 +145,10 @@ public function update(Request $request, UserList $list) */ private function resolveAirports(string $input): array { - $airportsInput = explode("\r\n", $input); - $airportsInput = array_map('trim', $airportsInput); - $airportsInput = array_map('strtoupper', $airportsInput); - $airportsInput = array_filter($airportsInput, function ($value) { - return ! empty($value); - }); + $airportsInput = collect(explode("\r\n", $input)) + ->map(fn ($v) => strtoupper(trim($v))) + ->filter() + ->all(); $airportModels = Airport::whereIn('icao', $airportsInput) ->orWhereIn('local_code', $airportsInput) diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php deleted file mode 100644 index d8e6fde6..00000000 --- a/app/Http/Kernel.php +++ /dev/null @@ -1,97 +0,0 @@ - - */ - protected $middleware = [ - TrustProxies::class, - HandleCors::class, - PreventRequestsDuringMaintenance::class, - ValidatePostSize::class, - TrimStrings::class, - ConvertEmptyStringsToNull::class, - ]; - - /** - * The application's route middleware groups. - * - * @var array> - */ - protected $middlewareGroups = [ - 'web' => [ - EncryptCookies::class, - AddQueuedCookiesToResponse::class, - StartSession::class, - ShareErrorsFromSession::class, - PreventRequestForgery::class, - SubstituteBindings::class, - UserActive::class, - AdminVariables::class, - FeedbackVariables::class, - ], - - 'api' => [ - 'throttle:50,1', - SubstituteBindings::class, - EnsureFrontendRequestsAreStateful::class, - ], - ]; - - /** - * The application's route middleware. - * - * These middleware may be assigned to groups or used individually. - * - * @var array - */ - protected $routeMiddleware = [ - 'auth' => Authenticate::class, - 'auth.basic' => AuthenticateWithBasicAuth::class, - 'auth.session' => AuthenticateSession::class, - 'cache.headers' => SetCacheHeaders::class, - 'can' => Authorize::class, - 'guest' => RedirectIfAuthenticated::class, - 'password.confirm' => RequirePassword::class, - 'signed' => ValidateSignature::class, - 'throttle' => ThrottleRequests::class, - 'verified' => EnsureEmailIsVerified::class, - 'api-token' => ApiToken::class, - ]; -} diff --git a/app/Http/Middleware/Authenticate.php b/app/Http/Middleware/Authenticate.php deleted file mode 100644 index 4e8922a5..00000000 --- a/app/Http/Middleware/Authenticate.php +++ /dev/null @@ -1,22 +0,0 @@ -expectsJson()) { - return route('login'); - } - } -} diff --git a/app/Http/Middleware/EncryptCookies.php b/app/Http/Middleware/EncryptCookies.php deleted file mode 100644 index 867695bd..00000000 --- a/app/Http/Middleware/EncryptCookies.php +++ /dev/null @@ -1,17 +0,0 @@ - - */ - protected $except = [ - // - ]; -} diff --git a/app/Http/Middleware/PreventRequestsDuringMaintenance.php b/app/Http/Middleware/PreventRequestsDuringMaintenance.php deleted file mode 100644 index 74cbd9a9..00000000 --- a/app/Http/Middleware/PreventRequestsDuringMaintenance.php +++ /dev/null @@ -1,17 +0,0 @@ - - */ - protected $except = [ - // - ]; -} diff --git a/app/Http/Middleware/RedirectIfAuthenticated.php b/app/Http/Middleware/RedirectIfAuthenticated.php deleted file mode 100644 index 2959501e..00000000 --- a/app/Http/Middleware/RedirectIfAuthenticated.php +++ /dev/null @@ -1,32 +0,0 @@ -check()) { - return redirect()->route('front'); - } - } - - return $next($request); - } -} diff --git a/app/Http/Middleware/TrimStrings.php b/app/Http/Middleware/TrimStrings.php deleted file mode 100644 index 88cadcaa..00000000 --- a/app/Http/Middleware/TrimStrings.php +++ /dev/null @@ -1,19 +0,0 @@ - - */ - protected $except = [ - 'current_password', - 'password', - 'password_confirmation', - ]; -} diff --git a/app/Http/Middleware/TrustHosts.php b/app/Http/Middleware/TrustHosts.php deleted file mode 100644 index 7186414c..00000000 --- a/app/Http/Middleware/TrustHosts.php +++ /dev/null @@ -1,20 +0,0 @@ - - */ - public function hosts() - { - return [ - $this->allSubdomainsOfApplicationUrl(), - ]; - } -} diff --git a/app/Http/Middleware/TrustProxies.php b/app/Http/Middleware/TrustProxies.php deleted file mode 100644 index f0b7dc0f..00000000 --- a/app/Http/Middleware/TrustProxies.php +++ /dev/null @@ -1,52 +0,0 @@ -|string|null - */ - protected $proxies = [ - '173.245.48.0/20', // Cloudflare as of 2025-04-20 - '103.21.244.0/22', - '103.22.200.0/22', - '103.31.4.0/22', - '141.101.64.0/18', - '108.162.192.0/18', - '190.93.240.0/20', - '188.114.96.0/20', - '197.234.240.0/22', - '198.41.128.0/17', - '162.158.0.0/15', - '104.16.0.0/13', - '104.24.0.0/14', - '172.64.0.0/13', - '131.0.72.0/22', - '2400:cb00::/32', - '2606:4700::/32', - '2803:f800::/32', - '2405:b500::/32', - '2405:8100::/32', - '2a06:98c0::/29', - '2c0f:f248::/32', - '172.16.0.0/12', // Docker - ]; - - /** - * The headers that should be used to detect proxies. - * - * @var int - */ - protected $headers = - Request::HEADER_X_FORWARDED_FOR | - Request::HEADER_X_FORWARDED_HOST | - Request::HEADER_X_FORWARDED_PORT | - Request::HEADER_X_FORWARDED_PROTO | - Request::HEADER_X_FORWARDED_AWS_ELB; -} diff --git a/app/Http/Middleware/UserActive.php b/app/Http/Middleware/UserActive.php index 74f922ef..f0f58f50 100644 --- a/app/Http/Middleware/UserActive.php +++ b/app/Http/Middleware/UserActive.php @@ -2,7 +2,6 @@ namespace App\Http\Middleware; -use Carbon\Carbon; use Closure; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -18,10 +17,14 @@ class UserActive */ public function handle(Request $request, Closure $next) { - if (\Auth::check()) { - $user = \Auth::user(); - $user->last_activity_at = Carbon::now(); - $user->save(); + if (auth()->check()) { + $user = auth()->user(); + + if ($user->last_activity_at === null || $user->last_activity_at->lt(now()->subMinutes(5))) { + $user->timestamps = false; + $user->last_activity_at = now(); + $user->save(); + } } return $next($request); diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php deleted file mode 100644 index 9e865217..00000000 --- a/app/Http/Middleware/VerifyCsrfToken.php +++ /dev/null @@ -1,17 +0,0 @@ - - */ - protected $except = [ - // - ]; -} diff --git a/app/Http/Resources/AirportResource.php b/app/Http/Resources/AirportResource.php new file mode 100644 index 00000000..7d2a242d --- /dev/null +++ b/app/Http/Resources/AirportResource.php @@ -0,0 +1,24 @@ + $this->name, + 'icao' => $this->icao, + 'iata' => $this->iata_code ?: null, + 'continent' => $this->continent, + 'country' => $this->iso_country, + 'region' => $this->iso_region, + 'metar' => app()->isProduction() ? $this->metar->metar : 'TEST-DATA ' . $this->metar->metar, + 'longestRwyFt' => $this->longestRunway(), + 'scores' => $this->scores->pluck('reason'), + ]; + } +} diff --git a/app/Http/Resources/SuggestedAirportResource.php b/app/Http/Resources/SuggestedAirportResource.php new file mode 100644 index 00000000..0920dbb8 --- /dev/null +++ b/app/Http/Resources/SuggestedAirportResource.php @@ -0,0 +1,18 @@ + $this->airtime, + 'distanceNm' => $this->distance, + 'isAirforcebase' => $this->w2f_airforcebase, + 'hasAirlineService' => $this->w2f_scheduled_service, + ]); + } +} diff --git a/app/Mixins/CollectionAirportFilter.php b/app/Mixins/CollectionAirportFilter.php index f4619b53..79917a89 100644 --- a/app/Mixins/CollectionAirportFilter.php +++ b/app/Mixins/CollectionAirportFilter.php @@ -9,9 +9,9 @@ class CollectionAirportFilter { public function filterWithCriteria() { - return function ($departureAirport, $codeletter, $airtimeMin, $airtimeMax, $requiredMetcon = null, $temperatureMin = null, $temperatureMax = null, $runwayLengthMin = null, $runwayLengthMax = null, $airportElevationMin = null, $airportElevationMax = null) { + return function ($departureAirport, $codeletter, $requiredMetcon = null, $temperatureMin = null, $temperatureMax = null, $airportElevationMin = null, $airportElevationMax = null) { - $returnCollection = $this + return $this ->transform(function ($arrivalAirport) use ($departureAirport, $codeletter) { // Insert the calculated distance and airtime into the collection $distance = distance($departureAirport->latitude_deg, $departureAirport->longitude_deg, $arrivalAirport->latitude_deg, $arrivalAirport->longitude_deg, 'N'); @@ -26,8 +26,6 @@ public function filterWithCriteria() ->filter(fn ($a) => AirportFilterHelper::hasRequiredTemperature($temperatureMin, $temperatureMax, $a)) ->filter(fn ($a) => AirportFilterHelper::hasRequiredAirportElevation($airportElevationMin, $airportElevationMax, $a)); - return $returnCollection; - }; } diff --git a/app/Models/Airport.php b/app/Models/Airport.php index 35ef6200..0300ba9e 100644 --- a/app/Models/Airport.php +++ b/app/Models/Airport.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Helpers\CalculationHelper; +use Illuminate\Database\Eloquent\Attributes\Scope; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -22,9 +23,12 @@ class Airport extends Model protected $guarded = []; - protected $casts = [ - 'coordinates' => Point::class, - ]; + protected function casts(): array + { + return [ + 'coordinates' => Point::class, + ]; + } public function metar() { @@ -71,9 +75,10 @@ public function sceneryDevelopers() return $this->hasMany(SceneryDeveloper::class); } - public static function whereHasPublishedSceneries($published, $filterSimulatorId = null) + #[Scope] + protected function publishedSceneries(Builder $query, $published, $filterSimulatorId = null): void { - return Airport::whereHas('sceneryDevelopers', function ($query) use ($published, $filterSimulatorId) { + $query->whereHas('sceneryDevelopers', function ($query) use ($published, $filterSimulatorId) { $query->whereHas('sceneries', function ($query) use ($published, $filterSimulatorId) { $query->where('published', $published); if ($filterSimulatorId) { @@ -85,60 +90,27 @@ public static function whereHasPublishedSceneries($published, $filterSimulatorId public function hasWeatherScore() { - foreach ($this->scores as $s) { - if ($s->isWeatherScore()) { - return true; - } - } - - return false; + return $this->scores->contains(fn ($s) => $s->isWeatherScore()); } public function weatherScore() { - $score = 0; - foreach ($this->scores as $s) { - if ($s->isWeatherScore()) { - $score++; - } - } - - return $score; + return $this->scores->filter(fn ($s) => $s->isWeatherScore())->count(); } public function hasVatsimScore() { - foreach ($this->scores as $s) { - if ($s->isVatsimScore()) { - return true; - } - } - - return false; + return $this->scores->contains(fn ($s) => $s->isVatsimScore()); } public function vatsimScore() { - $score = 0; - foreach ($this->scores as $s) { - if ($s->isVatsimScore()) { - $score++; - } - } - - return $score; + return $this->scores->filter(fn ($s) => $s->isVatsimScore())->count(); } public function longestRunway() { - $length = 0; - foreach ($this->runways as $rwy) { - if ($rwy->closed == false && $rwy->length_ft > $length) { - $length = $rwy->length_ft; - } - } - - return $length; + return $this->runways->where('closed', false)->max('length_ft') ?? 0; } public function hasVisualCondition() @@ -155,7 +127,8 @@ public function hasVisualCondition() /** * Scope a query to only include airports that are considered open and have open runways */ - public function scopeAirportOpen(Builder $query) + #[Scope] + protected function airportOpen(Builder $query): void { $query->where('type', '!=', 'closed')->where('w2f_has_open_runway', true); } @@ -163,7 +136,8 @@ public function scopeAirportOpen(Builder $query) /** * Scope a query to only include airports that are not the departure airport */ - public function scopeNotIcao(Builder $query, ?string $icao = null) + #[Scope] + protected function notIcao(Builder $query, ?string $icao = null): void { if (isset($icao)) { $query->where('icao', '!=', $icao); @@ -173,7 +147,8 @@ public function scopeNotIcao(Builder $query, ?string $icao = null) /** * Scope a query to only include airports that are of the given size */ - public function scopeIsAirportSize(Builder $query, ?array $destinationAirportSize = null) + #[Scope] + protected function isAirportSize(Builder $query, ?array $destinationAirportSize = null): void { if (isset($destinationAirportSize)) { $query->whereIn('type', $destinationAirportSize); @@ -185,7 +160,8 @@ public function scopeIsAirportSize(Builder $query, ?array $destinationAirportSiz /** * Scope a query to only include airports in the given continent */ - public function scopeInContinent(Builder $query, array $destinations) + #[Scope] + protected function inContinent(Builder $query, array $destinations): void { if (isset($destinations['continents'])) { $continents = $destinations['continents']; @@ -215,7 +191,8 @@ public function scopeInContinent(Builder $query, array $destinations) /** * Scope a query to exclude airports in the given continents */ - public function scopeNotInContinent(Builder $query, array $destinations) + #[Scope] + protected function notInContinent(Builder $query, array $destinations): void { if (isset($destinations['continents'])) { $continents = $destinations['continents']; @@ -245,7 +222,8 @@ public function scopeNotInContinent(Builder $query, array $destinations) /** * Scope a query to only include airports in the given country */ - public function scopeInCountry(Builder $query, array $destinations, ?string $country = null) + #[Scope] + protected function inCountry(Builder $query, array $destinations, ?string $country = null): void { // If filter is domestic, that should override all other country filters @@ -264,7 +242,8 @@ public function scopeInCountry(Builder $query, array $destinations, ?string $cou /** * Scope a query to only include airports not in the given country */ - public function scopeNotInCountry(Builder $query, array $destinations, ?string $country = null) + #[Scope] + protected function notInCountry(Builder $query, array $destinations, ?string $country = null): void { // If filter is domestic, that should override all other country filters if (isset($destinations['countries']) && $destinations['countries'] == 'Domestic') { @@ -282,7 +261,8 @@ public function scopeNotInCountry(Builder $query, array $destinations, ?string $ /** * Scope a query to only include airports in the US state */ - public function scopeInState(Builder $query, array $destinations) + #[Scope] + protected function inState(Builder $query, array $destinations): void { if (isset($destinations['states'])) { $query->whereIn('iso_region', $destinations['states']); @@ -292,7 +272,8 @@ public function scopeInState(Builder $query, array $destinations) /** * Scope a query to only include airports not in the given US state */ - public function scopeNotInState(Builder $query, array $destinations) + #[Scope] + protected function notInState(Builder $query, array $destinations): void { if (isset($destinations['states'])) { $query->whereNotIn('iso_region', $destinations['states']); @@ -302,7 +283,8 @@ public function scopeNotInState(Builder $query, array $destinations) /** * Scope a query to only include airports within the given distance */ - public function scopeWithinDistance(Builder $query, Airport $departureAirport, float $minDistance, float $maxDistance, string $departureIcao) + #[Scope] + protected function withinDistance(Builder $query, Airport $departureAirport, float $minDistance, float $maxDistance, string $departureIcao): void { if (isset($departureIcao)) { $query->whereDistanceSphere('coordinates', $departureAirport->coordinates, '<=', $maxDistance * 1852)->whereDistanceSphere('coordinates', $departureAirport->coordinates, '>=', $minDistance * 1852); @@ -312,7 +294,8 @@ public function scopeWithinDistance(Builder $query, Airport $departureAirport, f /** * Scope a query to only include airports that are in the given direction */ - public function scopeWithinBearing(Builder $query, Airport $departureAirport, ?string $direction, float $minDistance, float $maxDistance) + #[Scope] + protected function withinBearing(Builder $query, Airport $departureAirport, ?string $direction, float $minDistance, float $maxDistance): void { // Ignore this scope if direction is not set @@ -396,7 +379,8 @@ public function scopeWithinBearing(Builder $query, Airport $departureAirport, ?s }); } - public function scopeFilterRunwayLengths(Builder $query, int $rwyLengthMin, int $rwyLengthMax, string $codeletter) + #[Scope] + protected function filterRunwayLengths(Builder $query, int $rwyLengthMin, int $rwyLengthMax, string $codeletter): void { // Set minimum according to aircraft code unless it's already higher @@ -415,7 +399,8 @@ public function scopeFilterRunwayLengths(Builder $query, int $rwyLengthMin, int /** * Scope a query to only include airports that have runways with lights */ - public function scopeFilterRunwayLights(Builder $query, ?int $destinationRunwayLights = null) + #[Scope] + protected function filterRunwayLights(Builder $query, ?int $destinationRunwayLights = null): void { if (isset($destinationRunwayLights) && $destinationRunwayLights !== 0) { @@ -435,7 +420,8 @@ public function scopeFilterRunwayLights(Builder $query, ?int $destinationRunwayL /** * Scope a query to only include airports that are airbases */ - public function scopeFilterAirbases(Builder $query, ?int $destinationAirbases = null) + #[Scope] + protected function filterAirbases(Builder $query, ?int $destinationAirbases = null): void { if (isset($destinationAirbases) && $destinationAirbases !== 0) { @@ -451,7 +437,8 @@ public function scopeFilterAirbases(Builder $query, ?int $destinationAirbases = /** * Scope a query to only include airports that have scores */ - public function scopeFilterByScores(Builder $query, ?array $filterByScores = null) + #[Scope] + protected function filterByScores(Builder $query, ?array $filterByScores = null): void { if (isset($filterByScores) && ! empty($filterByScores)) { @@ -475,7 +462,8 @@ public function scopeFilterByScores(Builder $query, ?array $filterByScores = nul /** * Scope a query to only include airports that have routes and airlines */ - public function scopeFilterRoutesAndAirlines(Builder $query, ?string $departureIcao = null, ?array $filterByAirlines = null, ?array $filterByAircrafts = null, ?int $destinationWithRoutesOnly = null, string $flightDirection = 'arrivalFlights') + #[Scope] + protected function filterRoutesAndAirlines(Builder $query, ?string $departureIcao = null, ?array $filterByAirlines = null, ?array $filterByAircrafts = null, ?int $destinationWithRoutesOnly = null, string $flightDirection = 'arrivalFlights'): void { if (isset($destinationWithRoutesOnly) && $destinationWithRoutesOnly !== 0) { @@ -560,7 +548,8 @@ public function scopeFilterRoutesAndAirlines(Builder $query, ?string $departureI /** * Scope a query to only include airports that have the given scores */ - public function scopeReturnOnlyWhitelistedIcao(Builder $query, ?array $whitelistedArrivals = null) + #[Scope] + protected function returnOnlyWhitelistedIcao(Builder $query, ?array $whitelistedArrivals = null): void { if (isset($whitelistedArrivals)) { $query->whereIn('icao', $whitelistedArrivals); @@ -570,7 +559,8 @@ public function scopeReturnOnlyWhitelistedIcao(Builder $query, ?array $whitelist /** * Scope a query to only include airports that have the given scores */ - public function scopeSortByScores(Builder $query, $filterByScores) + #[Scope] + protected function sortByScores(Builder $query, $filterByScores) { if (isset($filterByScores) && ! empty($filterByScores)) { return $query->leftJoin('airport_scores', 'airports.id', '=', 'airport_scores.airport_id') diff --git a/app/Models/AirportScore.php b/app/Models/AirportScore.php index 50221644..b6b51933 100644 --- a/app/Models/AirportScore.php +++ b/app/Models/AirportScore.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\DB; class AirportScore extends Model { @@ -32,14 +33,14 @@ public static function getTopAirports($continent = null, $whitelist = null, $lim { // Establish the return query - $returnQuery = AirportScore::select('airport_id', \DB::raw('count(airport_scores.id) as id_count')) + $returnQuery = AirportScore::select('airport_id', DB::raw('count(airport_scores.id) as id_count')) ->groupBy('airport_id') ->orderByDesc('id_count') ->join('airports', 'airport_scores.airport_id', '=', 'airports.id'); // Filter out VATSIM scores if requested if ($exclude) { - if ($exclude = 'vatsim') { + if ($exclude == 'vatsim') { $returnQuery = $returnQuery->where('airport_scores.reason', 'NOT LIKE', 'VATSIM_%'); } } diff --git a/app/Models/ApiKey.php b/app/Models/ApiKey.php index ca758008..190f3acb 100644 --- a/app/Models/ApiKey.php +++ b/app/Models/ApiKey.php @@ -17,9 +17,12 @@ class ApiKey extends Model 'key', 'name', 'ip_address', 'last_used_at', ]; - public $casts = [ - 'disabled' => 'boolean', - ]; + protected function casts(): array + { + return [ + 'disabled' => 'boolean', + ]; + } public function logs() { diff --git a/app/Models/Controller.php b/app/Models/Controller.php index 4b6cfceb..8a8e6d47 100644 --- a/app/Models/Controller.php +++ b/app/Models/Controller.php @@ -13,9 +13,12 @@ class Controller extends Model protected $guarded = []; - protected $casts = [ - 'logon_time' => 'datetime', - ]; + protected function casts(): array + { + return [ + 'logon_time' => 'datetime', + ]; + } public function airport() { diff --git a/app/Models/Event.php b/app/Models/Event.php index b35867a8..7ffbf664 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -13,10 +13,13 @@ class Event extends Model protected $guarded = []; - protected $casts = [ - 'start_time' => 'datetime', - 'end_time' => 'datetime', - ]; + protected function casts(): array + { + return [ + 'start_time' => 'datetime', + 'end_time' => 'datetime', + ]; + } public function airport() { diff --git a/app/Models/Flight.php b/app/Models/Flight.php index 01a6f885..18d22f1d 100644 --- a/app/Models/Flight.php +++ b/app/Models/Flight.php @@ -11,10 +11,13 @@ class Flight extends Model public $timestamps = false; - protected $casts = [ - 'last_seen_at' => 'datetime', - 'first_seen_at' => 'datetime', - ]; + protected function casts(): array + { + return [ + 'last_seen_at' => 'datetime', + 'first_seen_at' => 'datetime', + ]; + } public function departureAirport() { diff --git a/app/Models/FlightAircraft.php b/app/Models/FlightAircraft.php index 3e8efddb..502e0162 100644 --- a/app/Models/FlightAircraft.php +++ b/app/Models/FlightAircraft.php @@ -11,10 +11,13 @@ class FlightAircraft extends Model public $timestamps = false; - protected $casts = [ - 'last_seen_at' => 'datetime', - 'first_seen_at' => 'datetime', - ]; + protected function casts(): array + { + return [ + 'last_seen_at' => 'datetime', + 'first_seen_at' => 'datetime', + ]; + } public function flight() { diff --git a/app/Models/Metar.php b/app/Models/Metar.php index 4f249e67..b4bea546 100644 --- a/app/Models/Metar.php +++ b/app/Models/Metar.php @@ -11,9 +11,12 @@ class Metar extends Model public $timestamps = false; - protected $cats = [ - 'last_updated' => 'datetime', - ]; + protected function casts(): array + { + return [ + 'last_update' => 'datetime', + ]; + } protected $guarded = []; diff --git a/app/Models/Runway.php b/app/Models/Runway.php index 5a78db80..31253620 100644 --- a/app/Models/Runway.php +++ b/app/Models/Runway.php @@ -13,10 +13,13 @@ class Runway extends Model protected $guarded = []; - protected $casts = [ - 'lighted' => 'boolean', - 'closed' => 'boolean', - ]; + protected function casts(): array + { + return [ + 'lighted' => 'boolean', + 'closed' => 'boolean', + ]; + } public function airport() { diff --git a/app/Models/User.php b/app/Models/User.php index ac97f0ac..fdedc108 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -35,27 +35,26 @@ class User extends Authenticatable implements MustVerifyEmail ]; /** - * The attributes that should be cast. + * Get the attributes that should be cast. * - * @var array + * @return array */ - protected $casts = [ - 'email_verified_at' => 'datetime', - ]; + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'last_activity_at' => 'datetime', + 'password' => 'hashed', + ]; + } public function getAirportsFromLists() { - - $airports = []; - $userLists = UserList::where('user_id', $this->id)->with('airports', 'airports.metar', 'airports.runways')->get(); - - foreach ($userLists as $list) { - foreach ($list->airports as $airport) { - $airports[] = $airport; - } - } - - return $airports; + return $this->lists()->with('airports', 'airports.metar', 'airports.runways') + ->get() + ->pluck('airports') + ->flatten() + ->all(); } public function lists() diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 4363d3f5..492c3305 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -12,20 +12,16 @@ class AppServiceProvider extends ServiceProvider { /** * Register any application services. - * - * @return void */ - public function register() + public function register(): void { // } /** * Bootstrap any application services. - * - * @return void */ - public function boot() + public function boot(): void { Collection::mixin(new CollectionAirportFilter); diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php deleted file mode 100644 index 33b83f56..00000000 --- a/app/Providers/AuthServiceProvider.php +++ /dev/null @@ -1,30 +0,0 @@ - - */ - protected $policies = [ - // 'App\Models\Model' => 'App\Policies\ModelPolicy', - ]; - - /** - * Register any authentication / authorization services. - * - * @return void - */ - public function boot() - { - $this->registerPolicies(); - - // - } -} diff --git a/app/Providers/BroadcastServiceProvider.php b/app/Providers/BroadcastServiceProvider.php deleted file mode 100644 index 395c518b..00000000 --- a/app/Providers/BroadcastServiceProvider.php +++ /dev/null @@ -1,21 +0,0 @@ -> - */ - protected $listen = [ - Registered::class => [ - SendEmailVerificationNotification::class, - ], - ]; - - /** - * Register any events for your application. - * - * @return void - */ - public function boot() - { - // - } - - /** - * Determine if events and listeners should be automatically discovered. - * - * @return bool - */ - public function shouldDiscoverEvents() - { - return false; - } -} diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php deleted file mode 100644 index ea87f2e5..00000000 --- a/app/Providers/RouteServiceProvider.php +++ /dev/null @@ -1,52 +0,0 @@ -configureRateLimiting(); - - $this->routes(function () { - Route::middleware('api') - ->prefix('api') - ->group(base_path('routes/api.php')); - - Route::middleware('web') - ->group(base_path('routes/web.php')); - }); - } - - /** - * Configure the rate limiters for the application. - * - * @return void - */ - protected function configureRateLimiting() - { - RateLimiter::for('api', function (Request $request) { - return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); - }); - } -} diff --git a/bootstrap/app.php b/bootstrap/app.php index b4a5fc36..ffeb12e8 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,60 +1,87 @@ withRouting( + web: __DIR__ . '/../routes/web.php', + api: __DIR__ . '/../routes/api.php', + commands: __DIR__ . '/../routes/console.php', + ) + ->withMiddleware(function (Middleware $middleware) { + $middleware->trustProxies( + at: [ + '173.245.48.0/20', // Cloudflare as of 2025-04-20 + '103.21.244.0/22', + '103.22.200.0/22', + '103.31.4.0/22', + '141.101.64.0/18', + '108.162.192.0/18', + '190.93.240.0/20', + '188.114.96.0/20', + '197.234.240.0/22', + '198.41.128.0/17', + '162.158.0.0/15', + '104.16.0.0/13', + '104.24.0.0/14', + '172.64.0.0/13', + '131.0.72.0/22', + '2400:cb00::/32', + '2606:4700::/32', + '2803:f800::/32', + '2405:b500::/32', + '2405:8100::/32', + '2a06:98c0::/29', + '2c0f:f248::/32', + '172.16.0.0/12', // Docker + ], + headers: Request::HEADER_X_FORWARDED_FOR | + Request::HEADER_X_FORWARDED_HOST | + Request::HEADER_X_FORWARDED_PORT | + Request::HEADER_X_FORWARDED_PROTO | + Request::HEADER_X_FORWARDED_AWS_ELB, + ); -$app = new Application( - $_ENV['APP_BASE_PATH'] ?? dirname(__DIR__) -); + $middleware->trimStrings(except: [ + 'current_password', + 'password', + 'password_confirmation', + ]); -/* -|-------------------------------------------------------------------------- -| Bind Important Interfaces -|-------------------------------------------------------------------------- -| -| Next, we need to bind some important interfaces into the container so -| we will be able to resolve them when needed. The kernels serve the -| incoming requests to this application from both the web and CLI. -| -*/ + $middleware->web(append: [ + UserActive::class, + AdminVariables::class, + FeedbackVariables::class, + ]); -$app->singleton( - Illuminate\Contracts\Http\Kernel::class, - Kernel::class -); + $middleware->statefulApi(); + $middleware->throttleApi('50,1'); -$app->singleton( - Illuminate\Contracts\Console\Kernel::class, - App\Console\Kernel::class -); + $middleware->alias([ + 'api-token' => ApiToken::class, + ]); -$app->singleton( - ExceptionHandler::class, - Handler::class -); - -/* -|-------------------------------------------------------------------------- -| Return The Application -|-------------------------------------------------------------------------- -| -| This script returns the application instance. The instance is given to -| the calling script so we can separate the building of the instances -| from the actual running of the application and sending responses. -| -*/ - -return $app; + $middleware->redirectGuestsTo(fn (Request $request) => route('login')); + $middleware->redirectUsersTo(fn () => route('front')); + }) + ->withExceptions(function (Exceptions $exceptions) { + $exceptions->reportable(function (Throwable $e) { + if (app()->bound('sentry')) { + app('sentry')->configureScope(function (Scope $scope): void { + $scope->setUser(['id' => Auth::id()]); + }); + app('sentry')->captureException($e); + } + }); + }) + ->withCommands() + ->create(); diff --git a/bootstrap/providers.php b/bootstrap/providers.php new file mode 100644 index 00000000..fc94ae60 --- /dev/null +++ b/bootstrap/providers.php @@ -0,0 +1,7 @@ + 'redis', ], - /* - |-------------------------------------------------------------------------- - | Autoloaded Service Providers - |-------------------------------------------------------------------------- - | - | The service providers listed here will be automatically loaded on the - | request to your application. Feel free to add your own services to - | this array to grant expanded functionality to your applications. - | - */ - - 'providers' => [ - - /* - * Laravel Framework Service Providers... - */ - AuthServiceProvider::class, - BroadcastServiceProvider::class, - BusServiceProvider::class, - CacheServiceProvider::class, - ConsoleSupportServiceProvider::class, - CookieServiceProvider::class, - DatabaseServiceProvider::class, - EncryptionServiceProvider::class, - FilesystemServiceProvider::class, - FoundationServiceProvider::class, - HashServiceProvider::class, - MailServiceProvider::class, - NotificationServiceProvider::class, - PaginationServiceProvider::class, - PipelineServiceProvider::class, - QueueServiceProvider::class, - RedisServiceProvider::class, - PasswordResetServiceProvider::class, - SessionServiceProvider::class, - TranslationServiceProvider::class, - ValidationServiceProvider::class, - ViewServiceProvider::class, - - /* - * Package Service Providers... - */ - - /* - * Application Service Providers... - */ - AppServiceProvider::class, - App\Providers\AuthServiceProvider::class, - // App\Providers\BroadcastServiceProvider::class, - EventServiceProvider::class, - RouteServiceProvider::class, - - MarkdownServiceProvider::class, - - ], - - /* - |-------------------------------------------------------------------------- - | Class Aliases - |-------------------------------------------------------------------------- - | - | This array of class aliases will be registered when this application - | is started. However, feel free to register as many as you wish as - | the aliases are "lazy" loaded so they don't hinder performance. - | - */ - - 'aliases' => Facade::defaultAliases()->merge([ - // 'ExampleClass' => App\Example\ExampleClass::class, - ])->toArray(), - ]; diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 896855bf..a85cc41c 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -22,7 +22,7 @@ public function definition() 'username' => fake()->unique()->userName(), 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), - 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password + 'password' => 'password', 'remember_token' => Str::random(10), 'admin' => false, ]; diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 88d9982c..47edf9b2 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -31,7 +31,7 @@
@else @auth - @if(auth()->user()->email_verified_at == null) + @if(!auth()->user()->hasVerifiedEmail())