ba0688a412
route-explorer's /api/token sits behind invisible Cloudflare Turnstile
that requires Apple's Private Access Token attestation. Third-party
iOS apps don't qualify for PAT issuance, and Linux Docker containers
can't pass it either (cross-OS fingerprint, even with patchright /
Camoufox). Migrates direct-flight search to FlightAware; multi-stop
and where-can-I-go remain via embedded SFSafariViewController.
- FlightAwareScheduleClient — scrapes route.rvt + trackpoll JSON for
real schedules without auth. T+0..2 day window. Tests against
captured HTML fixtures.
- BlobRouteClient — pulls the public Vercel blob route catalog
route-explorer's frontend reads (no auth, no Turnstile).
- DiagnosticLogger + LoggingURLSessionDelegate + DiagnosticsView —
device-shareable forensic trace. Boot header captures device, OS,
locale, UA; share-sheet export of session logs.
- TurnstileDebugView — live WKWebView gate inspector. Used to prove
the PAT-entitlement gap on a real device.
- RouteExplorerBrowserView — SFSafariViewController wrapper. Real
Safari clears Turnstile naturally; the in-app browser opens at
pre-filled search URLs. Surfaced from Search ("Open in
route-explorer") and Settings → Tools.
- RouteExplorerTokenStore + RouteExplorerSetupView — bookmarklet
capture flow (token round-tripped via flights://routeexplorer-token
URL scheme). Kept dormant for future use.
backend/ — Docker proxy attempts (Playwright, patchright, Camoufox).
All fail on Linux because Cloudflare auto-denies before the Turnstile
widget renders. Documented; kept as scaffolding for a future paid-
solver integration.
scripts/probe_flightaware.py — reference algorithm for the FA path.
scripts/probe_nodriver.py — local-Mac sanity check confirming the
gate clears with real macOS Chrome (proves the blocker is
fingerprint-level, not network-level).
143 lines
6.2 KiB
Swift
143 lines
6.2 KiB
Swift
import XCTest
|
|
@testable import Flights
|
|
|
|
/// Tests for ``FlightAwareScheduleClient``. The two pure-parser entry
|
|
/// points (``parseIdents`` and ``extractTrackpollBlob``) are exercised
|
|
/// directly against fixture HTML captured from a live request — this
|
|
/// catches FlightAware schema drift the moment it happens (route.rvt or
|
|
/// trackpoll layout changes) instead of finding out via empty search
|
|
/// results in production.
|
|
///
|
|
/// Fixtures live next to this file under `Fixtures/`. They're real
|
|
/// HTML pages saved verbatim from FlightAware, not synthetic markup,
|
|
/// so the tests assert against the actual shapes the parser sees.
|
|
final class FlightAwareScheduleClientTests: XCTestCase {
|
|
|
|
// MARK: - Fixture loading
|
|
|
|
/// Reads a file from the `Fixtures/` directory sibling to this test
|
|
/// source file. Avoids needing the test target's pbxproj to declare
|
|
/// a Resources phase — `#filePath` resolves to the real source path
|
|
/// at test-run time.
|
|
private func loadFixture(_ name: String, file: StaticString = #filePath) throws -> String {
|
|
let here = URL(fileURLWithPath: String(describing: file))
|
|
let url = here.deletingLastPathComponent()
|
|
.appendingPathComponent("Fixtures")
|
|
.appendingPathComponent(name)
|
|
return try String(contentsOf: url, encoding: .utf8)
|
|
}
|
|
|
|
// MARK: - parseIdents
|
|
|
|
func test_parseIdents_extractsFlightIdent_fromRouteAnalysisPage() throws {
|
|
let html = try loadFixture("DFW_EHAM_route.html")
|
|
let idents = FlightAwareScheduleClient.parseIdents(routeHTML: html)
|
|
XCTAssertFalse(idents.isEmpty,
|
|
"Should find at least one operating ident on DFW->AMS route page.")
|
|
XCTAssertTrue(idents.contains("AAL220"),
|
|
"AAL220 (AA daily 777-200 DFW->AMS) must surface; got \(idents)")
|
|
}
|
|
|
|
func test_parseIdents_dedupesRepeatedIdents() throws {
|
|
let html = try loadFixture("DFW_EHAM_route.html")
|
|
let idents = FlightAwareScheduleClient.parseIdents(routeHTML: html)
|
|
XCTAssertEqual(idents.count, Set(idents).count,
|
|
"Returned idents should be deduped; got \(idents)")
|
|
}
|
|
|
|
func test_parseIdents_returnsEmpty_whenNoRoutesPresent() {
|
|
let empty = """
|
|
<html><body><table>
|
|
<tr><th>Filed Time</th><th>Ident</th></tr>
|
|
<tr><td>No data</td></tr>
|
|
</table></body></html>
|
|
"""
|
|
XCTAssertEqual(
|
|
FlightAwareScheduleClient.parseIdents(routeHTML: empty),
|
|
[],
|
|
"Page with no flight rows should produce an empty list, not crash."
|
|
)
|
|
}
|
|
|
|
// MARK: - extractTrackpollBlob
|
|
|
|
func test_extractTrackpollBlob_returnsParseableJSON() throws {
|
|
let html = try loadFixture("AAL220_trackpoll.html")
|
|
guard let blob = FlightAwareScheduleClient.extractTrackpollBlob(from: html) else {
|
|
XCTFail("Should extract trackpollBootstrap from AAL220 page")
|
|
return
|
|
}
|
|
XCTAssertTrue(blob.hasPrefix("{") && blob.hasSuffix("}"),
|
|
"Extracted blob should be a JSON object literal")
|
|
// Round-trip through JSONDecoder to confirm shape.
|
|
XCTAssertNoThrow(
|
|
try JSONDecoder().decode(TrackpollBootstrap.self, from: Data(blob.utf8)),
|
|
"Extracted JSON should decode against TrackpollBootstrap schema"
|
|
)
|
|
}
|
|
|
|
func test_extractTrackpollBlob_returnsNil_whenMarkerMissing() {
|
|
let html = "<html><body>no script here</body></html>"
|
|
XCTAssertNil(FlightAwareScheduleClient.extractTrackpollBlob(from: html))
|
|
}
|
|
|
|
func test_extractTrackpollBlob_isStringContentAware() {
|
|
// A closing brace inside a string literal must NOT terminate the scan.
|
|
let html = #"""
|
|
<script>var trackpollBootstrap = {"a":"} not the end","b":1};</script>
|
|
"""#
|
|
let blob = FlightAwareScheduleClient.extractTrackpollBlob(from: html)
|
|
XCTAssertEqual(
|
|
blob,
|
|
#"{"a":"} not the end","b":1}"#,
|
|
"Braces inside JSON strings must not break the brace-balance scan."
|
|
)
|
|
}
|
|
|
|
// MARK: - ident decomposition
|
|
|
|
func test_identCarrierICAO_stripsTrailingDigits() {
|
|
XCTAssertEqual(FlightAwareScheduleClient.identCarrierICAO("AAL220"), "AAL")
|
|
XCTAssertEqual(FlightAwareScheduleClient.identCarrierICAO("BAW296"), "BAW")
|
|
XCTAssertEqual(FlightAwareScheduleClient.identCarrierICAO("SWA1"), "SWA")
|
|
}
|
|
|
|
func test_identFlightNumber_extractsTrailingDigits() {
|
|
XCTAssertEqual(FlightAwareScheduleClient.identFlightNumber("AAL220"), 220)
|
|
XCTAssertEqual(FlightAwareScheduleClient.identFlightNumber("BAW296"), 296)
|
|
XCTAssertEqual(FlightAwareScheduleClient.identFlightNumber("SWA1"), 1)
|
|
}
|
|
|
|
func test_airlineIATA_mapsKnownAndReturnsNilForUnknown() {
|
|
XCTAssertEqual(FlightAwareScheduleClient.airlineIATA(forICAO: "AAL"), "AA")
|
|
XCTAssertEqual(FlightAwareScheduleClient.airlineIATA(forICAO: "KLM"), "KL")
|
|
XCTAssertEqual(FlightAwareScheduleClient.airlineIATA(forICAO: "BAW"), "BA")
|
|
XCTAssertNil(FlightAwareScheduleClient.airlineIATA(forICAO: "ZZZ"),
|
|
"Unknown ICAO should return nil so caller can fall back to the raw prefix.")
|
|
}
|
|
|
|
// MARK: - End-to-end against fixture
|
|
|
|
func test_endToEnd_AAL220_trackpoll_decodesToScheduledLeg() throws {
|
|
let html = try loadFixture("AAL220_trackpoll.html")
|
|
guard let blob = FlightAwareScheduleClient.extractTrackpollBlob(from: html) else {
|
|
XCTFail("missing trackpoll blob")
|
|
return
|
|
}
|
|
let decoded = try JSONDecoder().decode(TrackpollBootstrap.self, from: Data(blob.utf8))
|
|
|
|
// The fixture was captured on 2026-06-05; it should contain a DFW->AMS
|
|
// leg with a B772 aircraft. We don't assert exact timestamps because
|
|
// future updates to the fixture (re-capture) will rotate the dates.
|
|
let dfwAmsLegs = decoded.flights.values
|
|
.flatMap { $0.activityLog.flights }
|
|
.filter { $0.origin.iata == "DFW" && $0.destination.iata == "AMS" }
|
|
XCTAssertFalse(dfwAmsLegs.isEmpty,
|
|
"AAL220 fixture should contain at least one DFW->AMS leg")
|
|
XCTAssertTrue(
|
|
dfwAmsLegs.contains { $0.aircraftType == "B772" },
|
|
"AAL220 DFW->AMS legs should be operated by B772 per the captured fixture"
|
|
)
|
|
}
|
|
}
|