Files
Flights/FlightsTests/AirlineLoadIntegrationTests.swift
T
Trey T 398862e88b Add Aeromexico (AM) load integration
AM exposes a public Sabre GetPassengerListRQ proxy via AWS API Gateway —
no auth, no API key — used by the consumer app's flight-status widget.
The endpoint returns per-cabin authorized/available plus full standby +
upgrade passenger lists with isStaff flag, numeric priority, fare class,
position movement, and PII (matching what we get from AA but with
better cabin capacity data).

Implementation:
- AirlineLoadService.fetchAeromexicoLoad: parallel GETs against
  /rb/passengerliststandby and /rb/passengerlistupgrade, merging
  cabin info + per-list passengers into a single FlightLoad. Headers
  channel=web / flow=CHECKIN extracted from the AM APK Constant.smali.
  Cabin codes Y/C/P/F mapped to readable names (Economy / Clase Premier /
  Premier One / First).
- 4-digit zero-padding of the operating flight code (server validates
  ^[0-9]{4}$).
- "NONE LISTED" warning treated as nil (snapshot outside T-1d/T+2d
  window or no pax yet); explicit log so future failures are
  diagnosable.

Test infrastructure:
- Added test_AM_aeromexico using MEX/GDL/MTY/CUN hubs.
- Cascading fallback in runAirlineLoadTest: try the route-explorer
  discovered flight first; if it returns nil (typical for AM Connect
  regionals that aren't in Sabre), fall back to the known-daily flight
  (AM0058 MEX-MTY). Pattern useful for any future carrier whose
  regional ops don't show up in the load system.
- knownDailyFlights extended with AM0058 MEX-MTY.

Docs:
- AIRLINE_INTEGRATION_GUIDE: AM status row + full section 5b with
  endpoint params, response shape, snapshot window timing, failure
  modes, cabin code mapping, regional carrier caveat.

Test run 2026-05-26:
   AA, AM (cabins=1 upgrade=1), AS, B6, EK, KE, UA  ⏭️ XE
  7 passing, 1 skipped, 0 failures, 12s total.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 15:31:59 -05:00

264 lines
11 KiB
Swift

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<String> = ["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
}
}