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>
This commit is contained in:
Trey T
2026-05-26 15:31:59 -05:00
parent 4a939340a2
commit 398862e88b
3 changed files with 309 additions and 22 deletions
+66
View File
@@ -12,6 +12,7 @@ Drop-in reference for integrating flight load / seat availability data from 11 a
| 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) |
| AM | ✅ Working | Public AWS gateway Sabre proxy. Returns per-cabin `authorized`+`available` + full standby/upgrade passenger lists with `isStaff` flag and priority. Snapshot window: T-1d to T+2d. |
| ~~NK~~ | Removed | Spirit Airlines ceased operations (merged into Frontier). Removed from `AirlineLoadService` and tests. |
| XE | Manual only | WKWebView path; unit tests can't exercise it |
@@ -214,6 +215,71 @@ Spirit ceased operations and merged into Frontier. Removed from the codebase ent
---
## 5b. Aeromexico — WORKING (richer than AA in some ways)
**What you get:** per-cabin `authorized` (capacity) + `available` (open seats), full standby + upgrade passenger lists with `isStaff` flag, numeric priority, fare class, booking class, ascendsToClass, original/new position, check-in / board status, PII (firstName, lastName, reservationCode/PNR).
**Auth:** None. Public AWS API Gateway. Headers required: `channel: web`, `flow: CHECKIN`, `x-transaction-id: <uuid>`. Values extracted from `com.aeromexico.aeromexico.amwidgets.utils.Constant` in the APK.
**Flow:**
```
GET https://lw18yj0mhb.execute-api.us-east-1.amazonaws.com/rb/passengerliststandby
?departureAirport=<IATA>
&code=<4-digit, zero-padded>
&departureDate=<YYYY-MM-DD>
&operatingCarrier=AM
&operatingFlightCode=<4-digit, zero-padded>
GET https://lw18yj0mhb.execute-api.us-east-1.amazonaws.com/rb/passengerlistupgrade
?<same params>
```
`operatingFlightCode` is validated against `^[0-9]{4}$` — zero-pad short flight numbers.
**Response shape:**
```json
{
"itineraryInfo": {"airline":"AM","flight":"0058","origin":"MEX","destination":"MTY","aircraftType":"789"},
"cabinInfoList": [{"cabin":"Y","authorized":238,"available":0}],
"totalListed": 1,
"passengers": [{
"isStaff": true,
"rawPriorityCode": "SAE",
"priorityCode": {"id":"SAE","priority":21},
"status": "STB",
"bookingClass": "H",
"ascendsToClass": "Y",
"firstName": "RAMSITO",
"lastName": "UNO",
"reservationCode": "OBLWDT",
"passengerId": "0A6612610001",
"seat": null,
"originalPosition": 2,
"newPosition": 1,
"checkInStatus": false,
"boardStatus": false,
"boardingPassFlag": false
}]
}
```
**Snapshot window** (empirical, AM0058 MEX-MTY):
- T-3 days and earlier → `NONE LISTED` (data purged)
- **T-1 day → T+0** → snapshot live, `passengers[]` populates when listed
- T+1, T+2 → `NONE LISTED` (flight known but no snapshot)
- T+3 and beyond → `FLIGHT NOT INITIALIZED`
**Failure modes** to watch for in the response body:
- `NONE LISTED` → params valid, no passengers / no snapshot yet
- `FLIGHT NOT INITIALIZED - INVALID DATE OR CITY` → flight number doesn't match a real AM operation on that date+airport, OR snapshot window not open
- The `code` query param is ignored — only `operatingCarrier` + `operatingFlightCode` + `departureAirport` + `departureDate` are discriminating
**Cabin codes:** `Y` = Economy, `C` = Clase Premier (business), `P` = Premier One (long-haul biz/first), `F` = First. Mapped in `aeromexicoCabinName(code:)`.
**AM Connect / regional flights** (e.g. AM1460 MEX-QRO) often return `FLIGHT NOT INITIALIZED` — they're not in AM's Sabre system. The integration falls back to a known-daily mainline flight (AM0058 MEX-MTY) when route-explorer surfaces a regional that the load endpoint doesn't recognise.
---
## 6. JetBlue — PARTIAL (status yes, loads need PNR)
**What you get without PNR:** Flight status, full route database (12MB of origin/dest pairs, Mint/seasonal flags).
+208
View File
@@ -46,6 +46,7 @@ actor AirlineLoadService {
case "B6": return await fetchJetBlueStatus(flightNumber: flightNumber, date: date, origin: origin)
case "AS": return await fetchAlaskaLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination)
case "EK": return await fetchEmiratesStatus(flightNumber: flightNumber, date: date, origin: origin)
case "AM": return await fetchAeromexicoLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination)
case "XE": return await fetchJSXLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination, departureTime: departureTime)
default:
print("[LoadService] Unsupported airline: \(code)")
@@ -850,6 +851,213 @@ actor AirlineLoadService {
}
}
// MARK: - Aeromexico
/// Aeromexico exposes a Sabre `GetPassengerListRQ` proxy on a public AWS
/// API Gateway used by their consumer app's flight-status widget. The
/// endpoint requires no API key just a `channel: web` / `flow: CHECKIN`
/// header pair (constants extracted from the AM Android APK).
///
/// Response includes:
/// - `cabinInfoList[].authorized` (capacity) + `.available` (open seats)
/// - `passengers[]` with full PII, priority, `isStaff` flag, positions
///
/// Snapshot persists at least T-1d through T+2d; outside that the gateway
/// answers `FLIGHT NOT INITIALIZED` or `NONE LISTED`.
private func fetchAeromexicoLoad(
flightNumber: String,
date: Date,
origin: String,
destination: String
) async -> FlightLoad? {
let num = stripAirlinePrefix(flightNumber)
// Endpoint validates flight code against ^[0-9]{4}$.
let padded = String(format: "%04d", Int(num) ?? 0)
let dateStr = dayString(from: date, originIATA: origin)
async let standbyResp = fetchAeromexicoList(
endpoint: "passengerliststandby",
flightCode: padded,
origin: origin,
departureDate: dateStr
)
async let upgradeResp = fetchAeromexicoList(
endpoint: "passengerlistupgrade",
flightCode: padded,
origin: origin,
departureDate: dateStr
)
let sb = await standbyResp
let up = await upgradeResp
// If both calls failed entirely we have nothing.
guard sb != nil || up != nil else {
print("[AM] Both standby and upgrade calls returned nil")
return nil
}
// Cabin info: same per leg, either response carries it.
var cabins: [CabinLoad] = []
if let cabinList = sb?["cabinInfoList"] as? [[String: Any]] {
cabins = Self.parseAeromexicoCabins(cabinList)
} else if let cabinList = up?["cabinInfoList"] as? [[String: Any]] {
cabins = Self.parseAeromexicoCabins(cabinList)
}
let standbyList = Self.parseAeromexicoPassengers(
sb?["passengers"] as? [[String: Any]] ?? [],
listName: "Standby"
)
let upgradeList = Self.parseAeromexicoPassengers(
up?["passengers"] as? [[String: Any]] ?? [],
listName: "Upgrade"
)
let sbTotal = sb?["totalListed"] as? Int ?? 0
let upTotal = up?["totalListed"] as? Int ?? 0
print("[AM] parsed cabins=\(cabins.count) standby=\(standbyList.count)/\(sbTotal) upgrade=\(upgradeList.count)/\(upTotal)")
// Surface "no data yet" cleanly: if every response was NONE LISTED and
// we got nothing back, return nil so the detail view shows the
// "Load data not available" state rather than an empty card.
if cabins.isEmpty && standbyList.isEmpty && upgradeList.isEmpty {
print("[AM] No usable data in response (snapshot likely outside T-1d / T+2d window)")
return nil
}
return FlightLoad(
airlineCode: "AM",
flightNumber: "AM\(num)",
cabins: cabins,
standbyList: standbyList,
upgradeList: upgradeList,
seatAvailability: []
)
}
/// Single GET against the AM passenger-list gateway. Returns the parsed
/// JSON dict on success (with or without populated lists), or nil if the
/// request failed at the transport layer.
private func fetchAeromexicoList(
endpoint: String,
flightCode: String,
origin: String,
departureDate: String
) async -> [String: Any]? {
var components = URLComponents(string: "https://lw18yj0mhb.execute-api.us-east-1.amazonaws.com/rb/\(endpoint)")
components?.queryItems = [
URLQueryItem(name: "departureAirport", value: origin.uppercased()),
URLQueryItem(name: "code", value: flightCode),
URLQueryItem(name: "departureDate", value: departureDate),
URLQueryItem(name: "operatingCarrier", value: "AM"),
URLQueryItem(name: "operatingFlightCode", value: flightCode)
]
guard let url = components?.url else {
print("[AM] Invalid URL for \(endpoint)")
return nil
}
print("[AM] GET \(url.absoluteString)")
do {
var request = URLRequest(url: url)
request.setValue("web", forHTTPHeaderField: "channel")
request.setValue("CHECKIN", forHTTPHeaderField: "flow")
request.setValue(UUID().uuidString, forHTTPHeaderField: "x-transaction-id")
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await session.data(for: request)
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
print("[AM] \(endpoint) HTTP \(status), \(data.count) bytes")
guard status == 200 else {
if let body = String(data: data, encoding: .utf8) {
print("[AM] \(endpoint) non-200 body: \(body.prefix(300))")
}
return nil
}
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
print("[AM] \(endpoint) JSON parse failed")
return nil
}
// Log the warning surface so future failures are diagnosable.
if let warnings = json["warnings"] as? [[String: Any]], !warnings.isEmpty {
let msgs = warnings.compactMap { $0["errorMessage"] as? String }.joined(separator: " | ")
print("[AM] \(endpoint) warnings: \(msgs)")
}
return json
} catch {
print("[AM] \(endpoint) error: \(error)")
return nil
}
}
private static func parseAeromexicoCabins(_ raw: [[String: Any]]) -> [CabinLoad] {
raw.compactMap { entry in
let cabinCode = entry["cabin"] as? String ?? "?"
let authorized = entry["authorized"] as? Int ?? 0
let available = entry["available"] as? Int ?? 0
// Skip placeholder rows with no capacity at all.
guard authorized > 0 || available > 0 else { return nil }
let booked = max(0, authorized - available)
return CabinLoad(
name: aeromexicoCabinName(code: cabinCode),
capacity: authorized,
booked: booked,
revenueStandby: 0,
nonRevStandby: 0
)
}
}
/// Map AM's single-letter cabin codes to user-readable names. AM uses
/// `Y` for economy, `C` (Clase Premier) for business, and `P` for
/// Premier One (their first/long-haul biz). Anything unknown falls
/// through with the raw code.
private static func aeromexicoCabinName(code: String) -> String {
switch code.uppercased() {
case "Y": return "Economy"
case "C": return "Clase Premier"
case "P": return "Premier One"
case "F": return "First"
default: return code
}
}
private static func parseAeromexicoPassengers(
_ raw: [[String: Any]],
listName: String
) -> [StandbyPassenger] {
raw.enumerated().compactMap { (index, entry) in
let first = entry["firstName"] as? String ?? ""
let last = entry["lastName"] as? String ?? ""
// AM lists names in full, redact for display the way AA does
// (last name + first initial). If either piece is missing,
// fall back gracefully.
let display: String
if !last.isEmpty, !first.isEmpty {
display = "\(last), \(first.prefix(1))"
} else {
display = (last.isEmpty ? first : last)
}
let position = (entry["newPosition"] as? Int)
?? (entry["originalPosition"] as? Int)
?? (index + 1)
let cleared = (entry["boardingPassFlag"] as? Bool ?? false)
|| (entry["boardStatus"] as? Bool ?? false)
|| ((entry["seat"] as? String).map { !$0.isEmpty } ?? false)
return StandbyPassenger(
order: position,
displayName: display,
cleared: cleared,
seat: entry["seat"] as? String,
listName: listName
)
}
}
// MARK: - JSX (JetSuiteX)
private func fetchJSXLoad(
+35 -22
View File
@@ -48,6 +48,7 @@ final class AirlineLoadIntegrationTests: XCTestCase {
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
@@ -94,6 +95,15 @@ final class AirlineLoadIntegrationTests: XCTestCase {
)
}
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.
@@ -127,11 +137,30 @@ final class AirlineLoadIntegrationTests: XCTestCase {
}
}
// 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)")
// 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,
@@ -144,23 +173,7 @@ final class AirlineLoadIntegrationTests: XCTestCase {
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)
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