92a69cf16c
Sun Country runs Navitaire (same PSS as JSX) but exposes their public availability search endpoint that returns BETTER load data than AA: per-flight `capacity` AND `sold` (booked passenger count), so we can compute exact load factor. Implementation: - AirlineLoadService.fetchSunCountryLoad: POSTs to syprod-api.suncountry.com/api/nsk/v4/availability/search/simple. Parses results→trips→journeysAvailableByMarket, matches by flight number, pulls capacity + sold + equipmentType from legInfo. - Returns a single Economy CabinLoad with capacity/booked = sold. No standby program — SY is single-cabin Y. - Auth: Azure APIM subscription key + a long-lived dotREZ JWT (both static, captured from suncountry.com network traffic, neither is a user session token). - Anti-bot: Imperva WAF in front of syprod-api.suncountry.com is gated on User-Agent + Referer + Origin headers. applySunCountryBrowserHeaders mirrors the pattern we use for UA / AA. NO WebView needed. - Explicit ⚠️ log when 403 Incapsula response detected, pointing at the header helper. Test infrastructure: - knownDailyFlights now carries a dayOffset (today vs tomorrow) per carrier — different upstreams have different snapshot windows: AM is T-1d..T+0 (today); SY's Navitaire only returns future flights (tomorrow); others default to tomorrow as a safer choice. - Added test_SY_sunCountry with hubs MSP/LAS/MCO/DEN. Fallback is SY104 LAS-MSP tomorrow. Docs: - AIRLINE_INTEGRATION_GUIDE: SY status row + full section 5c covering endpoint, auth, headers, response shape, failure modes, and how to re-capture tokens when they rotate. Reverse-engineering notes: - SY app is Flutter (Dart AOT) — bridge smali is minimal. Strings extracted from libapp.so revealed isNonRevTrip/isStandby/ inventoryControl keywords + the syprod-api hostname. - Token endpoint is PUT (not POST). Returns {"data":null} — token is the existing Authorization JWT, not a session refresh. - Confirmed working from plain curl with browser headers (no Imperva TLS-fingerprint gate beyond UA/Referer/Origin). Test run 2026-05-26 (xcodebuild test): ✅ AA, AM, AS, B6, EK, KE, SY (capacity=186 sold=184 load=99%), UA ⏭️ XE 8 passing, 1 skipped, 0 failures, 11s total. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
283 lines
12 KiB
Swift
283 lines
12 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.
|
|
///
|
|
/// `dayOffset` controls which day's flight to probe:
|
|
/// - `0` (today) for carriers whose snapshot window is T-1d to T+0 (AM)
|
|
/// - `1` (tomorrow) for carriers whose API only returns future flights
|
|
/// (SY's Navitaire availability/search drops already-departed legs)
|
|
private static let knownDailyFlights: [String: (flightNumber: String, origin: String, destination: String, dayOffset: Int)] = [
|
|
"EK": ("201", "JFK", "DXB", 1), // Emirates JFK → Dubai, daily flagship
|
|
"KE": ("82", "JFK", "ICN", 1), // Korean Air JFK → Incheon, daily
|
|
"AM": ("58", "MEX", "MTY", 0), // Aeromexico — snapshot only T-1d/T+0
|
|
"SY": ("104", "LAS", "MSP", 1), // Sun Country — Navitaire shows future only
|
|
]
|
|
|
|
// 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_SY_sunCountry() async throws {
|
|
// Sun Country runs on Navitaire; the load endpoint takes flight
|
|
// number + route + date. Route-explorer doesn't cover SY well, so
|
|
// most runs hit the known-daily fallback (SY104 LAS-MSP).
|
|
try await runAirlineLoadTest(
|
|
carrier: "SY",
|
|
hubs: ["MSP", "LAS", "MCO", "DEN"]
|
|
)
|
|
}
|
|
|
|
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).
|
|
// dayOffset in the table controls today-vs-tomorrow based on each
|
|
// carrier's snapshot window quirks.
|
|
if let known = Self.knownDailyFlights[carrier] {
|
|
let probeDate = Date().addingTimeInterval(TimeInterval(known.dayOffset * 86400))
|
|
NSLog("[\(carrier)Test] Using known daily \(carrier)\(known.flightNumber) \(known.origin)→\(known.destination) +\(known.dayOffset)d")
|
|
let load = await loadService.fetchLoad(
|
|
airlineCode: carrier,
|
|
flightNumber: known.flightNumber,
|
|
date: probeDate,
|
|
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
|
|
}
|
|
}
|