Add FlightsTests target + fix AA load fetcher (Android UA version bump)
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) <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -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/
|
||||
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 = "<group>"; };
|
||||
0CD303E3EDCC4BF2BCF31722 /* AirportSearchField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirportSearchField.swift; sourceTree = "<group>"; };
|
||||
@@ -87,6 +98,8 @@
|
||||
RE6600006666000066660002 /* ConnectionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionRow.swift; sourceTree = "<group>"; };
|
||||
RE7700007777000077770002 /* ConnectionLoadDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionLoadDetailView.swift; sourceTree = "<group>"; };
|
||||
RE8800008888000088880002 /* SearchRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRoute.swift; sourceTree = "<group>"; };
|
||||
T1000000000000000000001B /* AirlineLoadIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirlineLoadIntegrationTests.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>";
|
||||
};
|
||||
T1000000000000000000005A /* FlightsTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
T1000000000000000000001B /* AirlineLoadIntegrationTests.swift */,
|
||||
);
|
||||
path = FlightsTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
6E94DB5F9EB345948E2D5E2A /* ViewModels */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -187,6 +216,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1D5A2C06B99046F3934D2E59 /* Flights */,
|
||||
T1000000000000000000005A /* FlightsTests */,
|
||||
517CC07B82D949359C6CD4F5 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@@ -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 */;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1540"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "E373C48C497D48D388BF7657"
|
||||
BuildableName = "Flights.app"
|
||||
BlueprintName = "Flights"
|
||||
ReferencedContainer = "container:Flights.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "NO"
|
||||
buildForProfiling = "NO"
|
||||
buildForArchiving = "NO"
|
||||
buildForAnalyzing = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "T1000000000000000000006A"
|
||||
BuildableName = "FlightsTests.xctest"
|
||||
BlueprintName = "FlightsTests"
|
||||
ReferencedContainer = "container:Flights.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "T1000000000000000000006A"
|
||||
BuildableName = "FlightsTests.xctest"
|
||||
BlueprintName = "FlightsTests"
|
||||
ReferencedContainer = "container:Flights.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "E373C48C497D48D388BF7657"
|
||||
BuildableName = "Flights.app"
|
||||
BlueprintName = "Flights"
|
||||
ReferencedContainer = "container:Flights.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "E373C48C497D48D388BF7657"
|
||||
BuildableName = "Flights.app"
|
||||
BlueprintName = "Flights"
|
||||
ReferencedContainer = "container:Flights.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> = ["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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user