Search: FlightAware backbone, blob catalog, diagnostic infra
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).
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
import XCTest
|
||||
import SwiftData
|
||||
@testable import Flights
|
||||
|
||||
/// Unit tests for `StandbyStatsService`.
|
||||
///
|
||||
/// All tests use an in-memory `ModelContainer` so they don't touch the
|
||||
/// real SwiftData store or CloudKit. We seed `LoggedFlight` rows with
|
||||
/// varied standby outcomes / carriers / routes / dates, then exercise
|
||||
/// the public surface (`personalRate`, `recentOutcomes`) and assert on
|
||||
/// the aggregate result.
|
||||
@MainActor
|
||||
final class StandbyStatsServiceTests: XCTestCase {
|
||||
|
||||
private var container: ModelContainer!
|
||||
private var context: ModelContext!
|
||||
private var service: StandbyStatsService!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
try super.setUpWithError()
|
||||
let schema = Schema([LoggedFlight.self])
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||
container = try ModelContainer(for: schema, configurations: config)
|
||||
context = ModelContext(container)
|
||||
service = StandbyStatsService()
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
service = nil
|
||||
context = nil
|
||||
container = nil
|
||||
try super.tearDownWithError()
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Reference epoch we offset from so date ordering is deterministic
|
||||
/// regardless of wall-clock time when the test runs.
|
||||
private static let epoch = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
|
||||
private func date(_ dayOffset: Int) -> Date {
|
||||
Self.epoch.addingTimeInterval(TimeInterval(dayOffset) * 86_400)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func insert(
|
||||
outcome: String?,
|
||||
carrierIATA: String? = "WN",
|
||||
carrierICAO: String? = "SWA",
|
||||
origin: String = "DAL",
|
||||
dest: String = "HOU",
|
||||
flightDate: Date? = nil
|
||||
) -> LoggedFlight {
|
||||
let flight = LoggedFlight(
|
||||
flightDate: flightDate ?? date(0),
|
||||
carrierICAO: carrierICAO,
|
||||
carrierIATA: carrierIATA,
|
||||
departureIATA: origin,
|
||||
arrivalIATA: dest
|
||||
)
|
||||
flight.standbyOutcome = outcome
|
||||
context.insert(flight)
|
||||
return flight
|
||||
}
|
||||
|
||||
// MARK: - personalRate
|
||||
|
||||
/// Empty store should return the documented sentinel.
|
||||
func test_personalRate_emptyContext_returnsEmpty() {
|
||||
let rate = service.personalRate(carrier: nil, origin: nil, dest: nil, context: context)
|
||||
|
||||
XCTAssertEqual(rate.attempts, 0)
|
||||
XCTAssertEqual(rate.made, 0)
|
||||
XCTAssertEqual(rate.bumped, 0)
|
||||
XCTAssertEqual(rate.confirmed, 0)
|
||||
XCTAssertEqual(rate.rate, 0)
|
||||
}
|
||||
|
||||
/// 5 confirmed + 3 standby-made + 2 standby-bumped — sanity check
|
||||
/// the aggregate maths. attempts = made + bumped = 5; rate = 3/5.
|
||||
func test_personalRate_mixedOutcomes_returnsExpectedCounts() {
|
||||
for _ in 0..<5 { insert(outcome: "confirmed") }
|
||||
for _ in 0..<3 { insert(outcome: "standby-made") }
|
||||
for _ in 0..<2 { insert(outcome: "standby-bumped") }
|
||||
|
||||
let rate = service.personalRate(carrier: nil, origin: nil, dest: nil, context: context)
|
||||
|
||||
XCTAssertEqual(rate.attempts, 5, "attempts = standby-made + standby-bumped")
|
||||
XCTAssertEqual(rate.made, 3)
|
||||
XCTAssertEqual(rate.bumped, 2)
|
||||
XCTAssertEqual(rate.confirmed, 5)
|
||||
XCTAssertEqual(rate.rate, 0.6, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
/// Carrier filter must restrict to flights whose IATA *or* ICAO matches
|
||||
/// (the service deliberately checks both — caller doesn't know which
|
||||
/// code was stored).
|
||||
func test_personalRate_carrierFilter_onlyCountsMatchingCarrier() {
|
||||
// WN: 2 made, 1 bumped → 3 attempts, rate = 2/3
|
||||
insert(outcome: "standby-made", carrierIATA: "WN", carrierICAO: "SWA")
|
||||
insert(outcome: "standby-made", carrierIATA: "WN", carrierICAO: "SWA")
|
||||
insert(outcome: "standby-bumped", carrierIATA: "WN", carrierICAO: "SWA")
|
||||
// AA noise that must be excluded by the filter.
|
||||
insert(outcome: "standby-made", carrierIATA: "AA", carrierICAO: "AAL")
|
||||
insert(outcome: "standby-bumped", carrierIATA: "AA", carrierICAO: "AAL")
|
||||
insert(outcome: "confirmed", carrierIATA: "AA", carrierICAO: "AAL")
|
||||
|
||||
let rate = service.personalRate(carrier: "WN", origin: nil, dest: nil, context: context)
|
||||
|
||||
XCTAssertEqual(rate.attempts, 3)
|
||||
XCTAssertEqual(rate.made, 2)
|
||||
XCTAssertEqual(rate.bumped, 1)
|
||||
XCTAssertEqual(rate.confirmed, 0)
|
||||
XCTAssertEqual(rate.rate, 2.0 / 3.0, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
/// Origin filter only counts flights departing the requested airport.
|
||||
func test_personalRate_originFilter_onlyCountsMatchingDeparture() {
|
||||
insert(outcome: "standby-made", origin: "DAL", dest: "HOU")
|
||||
insert(outcome: "standby-bumped", origin: "DAL", dest: "LAS")
|
||||
insert(outcome: "confirmed", origin: "DAL", dest: "MDW")
|
||||
// Other-origin noise — must be excluded.
|
||||
insert(outcome: "standby-made", origin: "HOU", dest: "DAL")
|
||||
insert(outcome: "confirmed", origin: "AUS", dest: "DAL")
|
||||
|
||||
let rate = service.personalRate(carrier: nil, origin: "DAL", dest: nil, context: context)
|
||||
|
||||
XCTAssertEqual(rate.attempts, 2)
|
||||
XCTAssertEqual(rate.made, 1)
|
||||
XCTAssertEqual(rate.bumped, 1)
|
||||
XCTAssertEqual(rate.confirmed, 1)
|
||||
XCTAssertEqual(rate.rate, 0.5, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
/// Carrier + origin + dest filters combine with AND semantics. Only
|
||||
/// flights matching every condition should be counted.
|
||||
func test_personalRate_combinedFilters_useAndSemantics() {
|
||||
// Target combo: WN, DAL → HOU. 2 made, 1 bumped → rate 2/3.
|
||||
insert(outcome: "standby-made", carrierIATA: "WN", origin: "DAL", dest: "HOU")
|
||||
insert(outcome: "standby-made", carrierIATA: "WN", origin: "DAL", dest: "HOU")
|
||||
insert(outcome: "standby-bumped", carrierIATA: "WN", origin: "DAL", dest: "HOU")
|
||||
|
||||
// Same carrier + origin, wrong dest.
|
||||
insert(outcome: "standby-made", carrierIATA: "WN", origin: "DAL", dest: "LAS")
|
||||
// Same carrier + dest, wrong origin.
|
||||
insert(outcome: "standby-made", carrierIATA: "WN", origin: "AUS", dest: "HOU")
|
||||
// Same route, wrong carrier.
|
||||
insert(outcome: "standby-made", carrierIATA: "AA", carrierICAO: "AAL", origin: "DAL", dest: "HOU")
|
||||
|
||||
let rate = service.personalRate(carrier: "WN", origin: "DAL", dest: "HOU", context: context)
|
||||
|
||||
XCTAssertEqual(rate.attempts, 3)
|
||||
XCTAssertEqual(rate.made, 2)
|
||||
XCTAssertEqual(rate.bumped, 1)
|
||||
XCTAssertEqual(rate.confirmed, 0)
|
||||
XCTAssertEqual(rate.rate, 2.0 / 3.0, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
// MARK: - recentOutcomes
|
||||
|
||||
/// recentOutcomes returns flights sorted by flightDate desc and
|
||||
/// honours the fetch limit. Flights without an outcome are excluded.
|
||||
func test_recentOutcomes_returnsMostRecentNByDateDescending() {
|
||||
// Insert 7 flights with outcomes across day offsets 0..6.
|
||||
// Day 6 is newest. Insert out of order to prove sort is by
|
||||
// flightDate (not insertion order).
|
||||
let outcomes = ["confirmed", "standby-made", "standby-bumped",
|
||||
"confirmed", "standby-made", "standby-bumped", "confirmed"]
|
||||
let insertionOrder = [3, 0, 6, 2, 5, 1, 4]
|
||||
for day in insertionOrder {
|
||||
insert(outcome: outcomes[day], flightDate: date(day))
|
||||
}
|
||||
// Plus a flight with no outcome — must NOT appear.
|
||||
insert(outcome: nil, flightDate: date(99))
|
||||
|
||||
let recent = service.recentOutcomes(limit: 5, context: context)
|
||||
|
||||
XCTAssertEqual(recent.count, 5)
|
||||
let returnedDays = recent.map { $0.flightDate.timeIntervalSince(Self.epoch) / 86_400 }
|
||||
.map { Int($0.rounded()) }
|
||||
XCTAssertEqual(returnedDays, [6, 5, 4, 3, 2],
|
||||
"Should be the 5 most recent by flightDate desc")
|
||||
XCTAssertTrue(recent.allSatisfy { $0.standbyOutcome != nil })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user