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`. /// - 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 = ["B6", "EK"] /// Hardcoded daily flights used as fallbacks when route-explorer's /// `/departures` data doesn't include the carrier we're looking for /// (notably 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)] = [ "EK": ("201", "JFK", "DXB"), // Emirates JFK → Dubai, daily flagship "KE": ("82", "JFK", "ICN"), // Korean Air JFK → Incheon, daily "AM": ("58", "MEX", "MTY"), // Aeromexico MEX → Monterrey, multiple 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_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_AM_aeromexico() async throws { // Route-explorer doesn't include AM in /departures data, so this // always falls through to the known-daily fallback (AM0058 MEX-MTY). try await runAirlineLoadTest( carrier: "AM", hubs: ["MEX", "GDL", "MTY", "CUN"] ) } 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 } } // Try the discovered flight first when route-explorer found one. if let flight = pickedFlight, let hub = pickedHub { 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 ) if load != nil { let flightLabel = "\(carrier)\(flight.flightNumber) \(flight.departure.airportIata)→\(flight.arrival.airportIata)" try assertLoad(load, carrier: carrier, flightLabel: flightLabel, file: file, line: line) return } NSLog("[\(carrier)Test] Discovered flight returned nil; trying known-daily fallback if available") } // Fallback: known-good daily flight. Triggers when route-explorer // found nothing OR when the discovered flight returned nil (e.g. a // regional carrier op that isn't in the upstream load system). if let known = Self.knownDailyFlights[carrier] { NSLog("[\(carrier)Test] 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 } throw XCTSkip("Could not find a working \(carrier) flight in the next 24h from any of: \(hubs.joined(separator: ", "))") } /// 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 } }