From 62729213d730eca1edbf0d1ab64f41589f767e15 Mon Sep 17 00:00:00 2001 From: Trey T Date: Tue, 26 May 2026 14:12:19 -0500 Subject: [PATCH] Add FlightsTests target + fix AA load fetcher (Android UA version bump) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AA was silently returning nil because the server now rejects User-Agent "Android/2025.31" with HTTP 403 ("Please update your version of the American Airlines app"). Bumped to "2026.14" (matches the APK in airlines/) and centralized to a constant so the next bump is one line. Added comprehensive logging to fetchAmericanLoad (was zero) so the next breakage won't be silent — including an explicit ⚠️ when the server returns the "update your version" payload. New FlightsTests target with AirlineLoadIntegrationTests — hits live airline APIs to verify each fetcher still returns data. Per-airline strategy: - Try route-explorer /departures from carrier hubs for a flight in the next 24h (works for AA/UA/AS/B6). - Fall back to a known-good daily flight when route-explorer doesn't have the carrier in its data (NK/EK/KE — ULCC + some intl carriers). - B6/EK/NK are status-only by design (no standby data without a PNR); asserted as non-nil only. - XE (JSX) skipped: needs WKWebView host. Retries on route-explorer 429 by parsing the `retryAfter` field and sleeping the indicated number of seconds. Static-shared client+services across tests so the token cache survives. Results 2026-05-26 (xcodebuild test -scheme Flights): ✅ AA, AS, B6, EK, KE, UA ❌ NK ⏭️ XE NK (Spirit) is now broken: GetFlightInfoBI returns HTTP 403 with {"getFlightInfoBIResult":null}. APIM key still accepted (401 without it), but the call itself is rejected. Documented in AIRLINE_INTEGRATION_GUIDE.md as a known regression to fix; likely needs reverse-engineering against the current Spirit APK in airlines/. Also: enable shared schemes in .gitignore so `xcodebuild test` works out of the box for anyone cloning the repo. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 3 +- AIRLINE_INTEGRATION_GUIDE.md | 15 +- Flights.xcodeproj/project.pbxproj | 111 ++++++++ .../xcshareddata/xcschemes/Flights.xcscheme | 102 +++++++ Flights/Services/AirlineLoadService.swift | 38 ++- .../AirlineLoadIntegrationTests.swift | 260 ++++++++++++++++++ 6 files changed, 514 insertions(+), 15 deletions(-) create mode 100644 Flights.xcodeproj/xcshareddata/xcschemes/Flights.xcscheme create mode 100644 FlightsTests/AirlineLoadIntegrationTests.swift diff --git a/.gitignore b/.gitignore index 7f3551a..65bb6d0 100644 --- a/.gitignore +++ b/.gitignore @@ -15,8 +15,9 @@ xcuserdata/ *.perspectivev3 !default.perspectivev3 -# Xcode Scheme +# Xcode Scheme — keep shared schemes (so `xcodebuild test` works for everyone) *.xcscheme +!*.xcodeproj/xcshareddata/xcschemes/*.xcscheme # Swift Package Manager .build/ diff --git a/AIRLINE_INTEGRATION_GUIDE.md b/AIRLINE_INTEGRATION_GUIDE.md index d424bbc..fad9fb7 100644 --- a/AIRLINE_INTEGRATION_GUIDE.md +++ b/AIRLINE_INTEGRATION_GUIDE.md @@ -1,6 +1,19 @@ # Airline API Integration Guide -Drop-in reference for integrating flight load / seat availability data from 11 airlines. Each section tells you **what works today, how to call it, what you get back, and what's blocked**. Verified 2026-04-12. +Drop-in reference for integrating flight load / seat availability data from 11 airlines. Each section tells you **what works today, how to call it, what you get back, and what's blocked**. Verified 2026-04-12, with regression-test runs 2026-05-26. + +## Quick status (run `xcodebuild test -scheme Flights` to re-verify) + +| Carrier | Status | Notes | +|---|---|---| +| AA | ✅ Working | UA version gate — bump `aaAppVersion` in `AirlineLoadService.swift` when AA rejects with "Please update your version" | +| UA | ✅ Working | Anonymous token, 30min TTL | +| AS | ✅ Working | Static APIM key | +| B6 | ✅ Status-only | Confirms flight exists; no load data without check-in session | +| EK | ✅ Status-only | Confirms flight exists; load data requires PNR | +| KE | ✅ Working | Returns seat count only (no capacity) | +| NK | ❌ Broken (2026-05-26) | `GetFlightInfoBI` returns HTTP 403 with `{"getFlightInfoBIResult":null}`. APIM key is still accepted (401 without it), but the call itself is now rejected. Likely endpoint deprecation or new auth requirement. Spirit APK in `airlines/com.spirit.*` may have the new shape. | +| XE | Manual only | WKWebView path; unit tests can't exercise it | --- diff --git a/Flights.xcodeproj/project.pbxproj b/Flights.xcodeproj/project.pbxproj index b3740a4..83054c6 100644 --- a/Flights.xcodeproj/project.pbxproj +++ b/Flights.xcodeproj/project.pbxproj @@ -45,8 +45,19 @@ RE6600006666000066660001 /* ConnectionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE6600006666000066660002 /* ConnectionRow.swift */; }; RE7700007777000077770001 /* ConnectionLoadDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE7700007777000077770002 /* ConnectionLoadDetailView.swift */; }; RE8800008888000088880001 /* SearchRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE8800008888000088880002 /* SearchRoute.swift */; }; + T1000000000000000000001A /* AirlineLoadIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1000000000000000000001B /* AirlineLoadIntegrationTests.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + T1000000000000000000002A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5418BEEAEFF644ADA7240CEA /* Project object */; + proxyType = 1; + remoteGlobalIDString = E373C48C497D48D388BF7657; + remoteInfo = Flights; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 04AC23D8748D42C9A7115FAC /* Airline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Airline.swift; sourceTree = ""; }; 0CD303E3EDCC4BF2BCF31722 /* AirportSearchField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirportSearchField.swift; sourceTree = ""; }; @@ -87,6 +98,8 @@ RE6600006666000066660002 /* ConnectionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionRow.swift; sourceTree = ""; }; RE7700007777000077770002 /* ConnectionLoadDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionLoadDetailView.swift; sourceTree = ""; }; RE8800008888000088880002 /* SearchRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRoute.swift; sourceTree = ""; }; + T1000000000000000000001B /* AirlineLoadIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirlineLoadIntegrationTests.swift; sourceTree = ""; }; + T1000000000000000000003A /* FlightsTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FlightsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -97,6 +110,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + T1000000000000000000004A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -155,10 +175,19 @@ isa = PBXGroup; children = ( 8A3CB0CCC2524542AFB0D1D2 /* Flights.app */, + T1000000000000000000003A /* FlightsTests.xctest */, ); name = Products; sourceTree = ""; }; + T1000000000000000000005A /* FlightsTests */ = { + isa = PBXGroup; + children = ( + T1000000000000000000001B /* AirlineLoadIntegrationTests.swift */, + ); + path = FlightsTests; + sourceTree = ""; + }; 6E94DB5F9EB345948E2D5E2A /* ViewModels */ = { isa = PBXGroup; children = ( @@ -187,6 +216,7 @@ isa = PBXGroup; children = ( 1D5A2C06B99046F3934D2E59 /* Flights */, + T1000000000000000000005A /* FlightsTests */, 517CC07B82D949359C6CD4F5 /* Products */, ); sourceTree = ""; @@ -229,6 +259,23 @@ productReference = 8A3CB0CCC2524542AFB0D1D2 /* Flights.app */; productType = "com.apple.product-type.application"; }; + T1000000000000000000006A /* FlightsTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = T1000000000000000000007A /* Build configuration list for PBXNativeTarget "FlightsTests" */; + buildPhases = ( + T1000000000000000000008A /* Sources */, + T1000000000000000000004A /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + T1000000000000000000009A /* PBXTargetDependency */, + ); + name = FlightsTests; + productName = FlightsTests; + productReference = T1000000000000000000003A /* FlightsTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -253,10 +300,19 @@ projectRoot = ""; targets = ( E373C48C497D48D388BF7657 /* Flights */, + T1000000000000000000006A /* FlightsTests */, ); }; /* End PBXProject section */ +/* Begin PBXTargetDependency section */ + T1000000000000000000009A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = E373C48C497D48D388BF7657 /* Flights */; + targetProxy = T1000000000000000000002A /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXResourcesBuildPhase section */ 6B9FCA84AAAA44529A95D7AC /* Resources */ = { isa = PBXResourcesBuildPhase; @@ -313,6 +369,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + T1000000000000000000008A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + T1000000000000000000001A /* AirlineLoadIntegrationTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ @@ -423,6 +487,44 @@ }; name = Debug; }; + T100000000000000000000BA /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = V3PF3M6B6U; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flights.app.FlightsTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Flights.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Flights"; + }; + name = Debug; + }; + T100000000000000000000CA /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = V3PF3M6B6U; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flights.app.FlightsTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Flights.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Flights"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -444,6 +546,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + T1000000000000000000007A /* Build configuration list for PBXNativeTarget "FlightsTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + T100000000000000000000BA /* Debug */, + T100000000000000000000CA /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 5418BEEAEFF644ADA7240CEA /* Project object */; diff --git a/Flights.xcodeproj/xcshareddata/xcschemes/Flights.xcscheme b/Flights.xcodeproj/xcshareddata/xcschemes/Flights.xcscheme new file mode 100644 index 0000000..912399a --- /dev/null +++ b/Flights.xcodeproj/xcshareddata/xcschemes/Flights.xcscheme @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Flights/Services/AirlineLoadService.swift b/Flights/Services/AirlineLoadService.swift index 058ae78..3b0d683 100644 --- a/Flights/Services/AirlineLoadService.swift +++ b/Flights/Services/AirlineLoadService.swift @@ -284,6 +284,11 @@ actor AirlineLoadService { // MARK: - American Airlines + /// AA gates the waitlist API on User-Agent version. Bump this when + /// `airlines/com.aa.android_*.apkm` is refreshed — stale versions get + /// HTTP 403 with `{"alert":{"message":"Please update your version..."}}`. + private static let aaAppVersion = "2026.14" + private func fetchAmericanLoad( flightNumber: String, date: Date, @@ -307,11 +312,12 @@ actor AirlineLoadService { return nil } - print("[AA] GET \(url)") + print("[AA] GET \(url.absoluteString)") do { var request = URLRequest(url: url) - request.setValue("Android/2025.31 Pixel 7|14|1080|2400|1.0|AmericanAirlines", forHTTPHeaderField: "User-Agent") + request.setValue("Android/\(Self.aaAppVersion) Pixel 7|14|1080|2400|1.0|AmericanAirlines", + forHTTPHeaderField: "User-Agent") request.setValue("MOBILE", forHTTPHeaderField: "x-clientid") request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("application/json", forHTTPHeaderField: "Content-Type") @@ -320,18 +326,22 @@ actor AirlineLoadService { let (data, response) = try await session.data(for: request) let http = response as? HTTPURLResponse - print("[AA] HTTP status: \(http?.statusCode ?? -1), \(data.count) bytes") - if let bodyStr = String(data: data, encoding: .utf8) { - print("[AA] body (first 1000): \(bodyStr.prefix(1000))") - } + let status = http?.statusCode ?? -1 + print("[AA] HTTP status: \(status), \(data.count) bytes") - guard http?.statusCode == 200 else { - print("[AA] Non-200; giving up") + if status != 200 { + if let bodyStr = String(data: data, encoding: .utf8) { + print("[AA] body (first 500): \(bodyStr.prefix(500))") + // Server hints when the UA version has aged out — surface it. + if status == 403, bodyStr.contains("update your version") { + print("[AA] ⚠️ User-Agent version (\(Self.aaAppVersion)) is rejected — bump aaAppVersion to match the latest APK in airlines/") + } + } return nil } guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { - print("[AA] JSON parse failed") + print("[AA] JSON parse failed; body (first 500): \(String(data: data, encoding: .utf8)?.prefix(500) ?? "")") return nil } print("[AA] top-level keys: \(json.keys.sorted())") @@ -339,12 +349,12 @@ actor AirlineLoadService { guard let waitListArray = json["waitList"] as? [[String: Any]] else { // 200 OK but no `waitList` — typical for AA Eagle 4-digit // regional flights (marketed as AA but the mobile waitlist - // endpoint doesn't track them). The keys logged above will - // tell us if the response actually carries data under a - // different name worth parsing. - print("[AA] no 'waitList' in response") + // endpoint doesn't track them), or for flights whose waitlist + // hasn't opened yet (usually opens T-24h before departure). + print("[AA] No 'waitList' array in response — likely no waitlist open yet for this flight") return nil } + print("[AA] waitList entries: \(waitListArray.count)") var seatAvailability: [SeatAvailability] = [] var standbyList: [StandbyPassenger] = [] @@ -388,6 +398,7 @@ actor AirlineLoadService { } } + print("[AA] parsed seatAvailability=\(seatAvailability.count) standby=\(standbyList.count) upgrade=\(upgradeList.count)") return FlightLoad( airlineCode: "AA", flightNumber: "AA\(num)", @@ -397,6 +408,7 @@ actor AirlineLoadService { seatAvailability: seatAvailability ) } catch { + print("[AA] error: \(error)") return nil } } diff --git a/FlightsTests/AirlineLoadIntegrationTests.swift b/FlightsTests/AirlineLoadIntegrationTests.swift new file mode 100644 index 0000000..89e7aad --- /dev/null +++ b/FlightsTests/AirlineLoadIntegrationTests.swift @@ -0,0 +1,260 @@ +import XCTest +@testable import Flights + +/// Integration tests for the airline load fetchers in `AirlineLoadService`. +/// +/// These tests hit **live airline APIs**. They will: +/// - Take 10-30s each (network) +/// - Fail loudly when an airline rotates auth, gates on a new app version, +/// or otherwise changes their API shape. That's by design — this is the +/// regression net for "does X airline still work?" +/// +/// For each carrier, the test: +/// 1. Uses `RouteExplorerClient` to find a real flight on that carrier +/// departing within the next 24 hours from one of its hubs. +/// 2. Calls `AirlineLoadService.fetchLoad(...)` for that specific flight. +/// 3. Asserts the response is meaningful (non-nil and has at least one +/// of: cabins / standby list / upgrade list / seat availability). +/// +/// Pre-existing limitations (NOT bugs in these tests): +/// - JSX (XE) uses a WKWebView path and can't run from unit tests on the +/// simulator without a host scene. Skipped with a `XCTSkip`. +/// - Spirit (NK) intentionally returns an empty FlightLoad — they have no +/// standby/load program. Test just asserts non-nil. +/// - Some carriers (notably AA, AS waitlist) only open the load endpoint +/// close to departure. Tests prefer flights leaving < 24h out and skip +/// with a helpful message if nothing's findable. +final class AirlineLoadIntegrationTests: XCTestCase { + + // Static so the token cache + URLSession survive across tests in + // a single run, and so the route-explorer rate limit applies once + // per suite rather than per test. + private static let routeExplorer = RouteExplorerClient() + private static let airportDatabase = AirportDatabase() + private static let loadService = AirlineLoadService(airportDatabase: airportDatabase) + + private var routeExplorer: RouteExplorerClient { Self.routeExplorer } + private var loadService: AirlineLoadService { Self.loadService } + + /// Airlines whose load endpoint deliberately returns only flight + /// status (no seat/standby data). We assert non-nil for these and + /// stop short of the "must have data" check. + private static let statusOnlyAirlines: Set = ["NK", "B6", "EK"] + + /// Hardcoded daily flights used as fallbacks when route-explorer's + /// `/departures` data doesn't include the carrier we're looking for + /// (notably ULCCs like NK and some international carriers like EK/KE + /// that aren't in route-explorer's schedule feed). Each entry is a + /// well-known daily operation that's been stable over time; if any + /// of these stop operating, update the entry. + private static let knownDailyFlights: [String: (flightNumber: String, origin: String, destination: String)] = [ + "NK": ("401", "LAS", "FLL"), // Spirit Las Vegas → Fort Lauderdale, daily + "EK": ("201", "JFK", "DXB"), // Emirates JFK → Dubai, daily flagship + "KE": ("82", "JFK", "ICN"), // Korean Air JFK → Incheon, daily + ] + + // MARK: - Per-airline tests + + func test_AA_americanAirlines() async throws { + try await runAirlineLoadTest( + carrier: "AA", + hubs: ["DFW", "CLT", "PHL", "ORD", "MIA", "PHX"] + ) + } + + func test_UA_united() async throws { + try await runAirlineLoadTest( + carrier: "UA", + hubs: ["EWR", "IAH", "DEN", "ORD", "SFO", "IAD", "LAX"] + ) + } + + func test_AS_alaska() async throws { + try await runAirlineLoadTest( + carrier: "AS", + hubs: ["SEA", "PDX", "ANC", "SAN", "LAX"] + ) + } + + func test_B6_jetBlue() async throws { + try await runAirlineLoadTest( + carrier: "B6", + hubs: ["JFK", "BOS", "FLL", "MCO", "LAX"] + ) + } + + func test_NK_spirit() async throws { + try await runAirlineLoadTest( + carrier: "NK", + hubs: ["FLL", "MCO", "LAS", "DTW", "ORD"] + ) + } + + func test_KE_koreanAir() async throws { + try await runAirlineLoadTest( + carrier: "KE", + hubs: ["ICN", "LAX", "JFK", "SFO", "ATL"] + ) + } + + func test_EK_emirates() async throws { + try await runAirlineLoadTest( + carrier: "EK", + hubs: ["DXB", "JFK", "LAX", "ORD", "IAD", "SFO", "BOS"] + ) + } + + func test_XE_jsx() async throws { + // JSX uses a WKWebView path that needs a host scene / main thread. + // Skipped here; manual verification via the app remains. + throw XCTSkip("JSX uses WKWebView and cannot run from a unit-test bundle.") + } + + // MARK: - Helpers + + /// Pulls departures from `hubs` for `carrier`, picks the first flight + /// leaving in (now, now+24h), and runs the airline-specific fetcher. + /// XCTSkips (rather than fails) if no flight can be found at all — + /// that's a route-explorer / schedule problem, not a load-fetcher bug. + private func runAirlineLoadTest( + carrier: String, + hubs: [String], + file: StaticString = #file, + line: UInt = #line + ) async throws { + let now = Date() + let cutoff = now.addingTimeInterval(24 * 3600) + + var pickedFlight: RouteFlight? + var pickedHub: String? + + for hub in hubs { + let candidate = await departuresWithRetry(from: hub, after: now, before: cutoff, carrier: carrier) + if let candidate { + pickedFlight = candidate + pickedHub = hub + break + } + } + + // Fallback path: route-explorer didn't return any flights for + // this carrier (typical for ULCCs and some international ops). + // Use a known-good daily flight if we have one configured. + if pickedFlight == nil, let known = Self.knownDailyFlights[carrier] { + NSLog("[\(carrier)Test] No \(carrier) flight in route-explorer data; using known daily \(carrier)\(known.flightNumber) \(known.origin)→\(known.destination)") + let load = await loadService.fetchLoad( + airlineCode: carrier, + flightNumber: known.flightNumber, + date: Date(), + origin: known.origin, + destination: known.destination, + departureTime: nil + ) + try assertLoad(load, carrier: carrier, flightLabel: "\(carrier)\(known.flightNumber) \(known.origin)→\(known.destination)", file: file, line: line) + return + } + + guard let flight = pickedFlight, let hub = pickedHub else { + throw XCTSkip("Could not find a \(carrier) flight in the next 24h from any of: \(hubs.joined(separator: ", "))") + } + + NSLog("[\(carrier)Test] Using \(carrier)\(flight.flightNumber) \(flight.departure.airportIata)→\(flight.arrival.airportIata) departing \(flight.departure.dateTime) (hub queried: \(hub))") + + let load = await loadService.fetchLoad( + airlineCode: flight.carrierIata, + flightNumber: "\(flight.flightNumber)", + date: flight.departure.dateTime, + origin: flight.departure.airportIata, + destination: flight.arrival.airportIata, + departureTime: nil + ) + + let flightLabel = "\(carrier)\(flight.flightNumber) \(flight.departure.airportIata)→\(flight.arrival.airportIata) departing \(flight.departure.dateTime)" + try assertLoad(load, carrier: carrier, flightLabel: flightLabel, file: file, line: line) + } + + /// Shared assertion path for both the dynamic-discovery and + /// hardcoded-fallback test routes. + private func assertLoad( + _ load: FlightLoad?, + carrier: String, + flightLabel: String, + file: StaticString, + line: UInt + ) throws { + XCTAssertNotNil( + load, + "\(carrier) load fetcher returned nil for \(flightLabel). " + + "Check the [\(carrier)] console logs above for the underlying failure mode.", + file: file, + line: line + ) + + guard let load else { return } + + NSLog("[\(carrier)Test] ✅ cabins=\(load.cabins.count) standby=\(load.standbyList.count) upgrade=\(load.upgradeList.count) seatAvail=\(load.seatAvailability.count)") + + if Self.statusOnlyAirlines.contains(carrier) { + XCTAssertEqual(load.airlineCode, carrier) + return + } + + let hasAnyData = !load.cabins.isEmpty + || !load.standbyList.isEmpty + || !load.upgradeList.isEmpty + || !load.seatAvailability.isEmpty + + XCTAssertTrue( + hasAnyData, + "\(carrier) returned a FlightLoad but every collection is empty — " + + "the endpoint likely succeeded but with no data for this flight, " + + "or the response shape changed.", + file: file, + line: line + ) + } + + /// Fetch departures from `hub` and pick the first flight matching + /// `carrier` in the time window. On HTTP 429 (route-explorer rate + /// limit), parse `retryAfter` and retry once after that delay. + private func departuresWithRetry( + from hub: String, + after: Date, + before: Date, + carrier: String, + attemptsRemaining: Int = 2 + ) async -> RouteFlight? { + do { + let result = try await routeExplorer.searchDepartures( + from: hub, date: after, maxStops: 0, limit: 300 + ) + let allLegs = result.connections.flatMap { $0.flights } + let inWindow = allLegs.filter { $0.departure.dateTime > after && $0.departure.dateTime <= before } + let carrierMatches = inWindow.filter { $0.carrierIata == carrier } + NSLog("[\(carrier)Test] hub \(hub): legs=\(allLegs.count) inWindow=\(inWindow.count) \(carrier)Matches=\(carrierMatches.count)") + return carrierMatches.first + } catch let RouteExplorerClient.ClientError.requestFailed(status: 429, body: body) { + let retryAfter = parseRetryAfter(body: body) ?? 25 + NSLog("[\(carrier)Test] hub \(hub) rate-limited (429), sleeping \(retryAfter)s then retrying (attemptsRemaining=\(attemptsRemaining - 1))") + if attemptsRemaining <= 1 { return nil } + try? await Task.sleep(nanoseconds: UInt64(retryAfter) * 1_000_000_000) + return await departuresWithRetry(from: hub, after: after, before: before, carrier: carrier, attemptsRemaining: attemptsRemaining - 1) + } catch let RouteExplorerClient.ClientError.tokenFetchFailed(status: 429) { + NSLog("[\(carrier)Test] hub \(hub) token rate-limited (429), sleeping 25s then retrying (attemptsRemaining=\(attemptsRemaining - 1))") + if attemptsRemaining <= 1 { return nil } + try? await Task.sleep(nanoseconds: 25 * 1_000_000_000) + return await departuresWithRetry(from: hub, after: after, before: before, carrier: carrier, attemptsRemaining: attemptsRemaining - 1) + } catch { + NSLog("[\(carrier)Test] hub \(hub) lookup failed: \(error)") + return nil + } + } + + private func parseRetryAfter(body: String?) -> Int? { + guard let body, let data = body.data(using: .utf8) else { return nil } + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + return json["retryAfter"] as? Int + } + return nil + } +}