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).
323 lines
11 KiB
Swift
323 lines
11 KiB
Swift
import XCTest
|
|
@testable import Flights
|
|
|
|
// MARK: - Test Doubles
|
|
//
|
|
// Phase 3 wired the production `FlightScheduleProvider` protocol in
|
|
// `Services/SisterFlightService.swift`, so we just consume it here rather
|
|
// than re-declaring it.
|
|
|
|
/// Hard-coded schedule provider. Tests configure airport autocomplete
|
|
/// results and a list of schedules to return; the mock plays them back.
|
|
actor MockScheduleProvider: FlightScheduleProvider {
|
|
private let airportLookups: [String: [Airport]]
|
|
private let schedulesToReturn: [FlightSchedule]
|
|
private let shouldThrowOnSchedules: Bool
|
|
|
|
init(airportLookups: [String: [Airport]] = [:],
|
|
schedulesToReturn: [FlightSchedule] = [],
|
|
shouldThrowOnSchedules: Bool = false) {
|
|
self.airportLookups = airportLookups
|
|
self.schedulesToReturn = schedulesToReturn
|
|
self.shouldThrowOnSchedules = shouldThrowOnSchedules
|
|
}
|
|
|
|
func searchAirports(term: String) async throws -> [Airport] {
|
|
return airportLookups[term.uppercased()] ?? []
|
|
}
|
|
|
|
func allSchedules(
|
|
dep: String,
|
|
des: String,
|
|
onProgress: @Sendable @escaping (Int, Int) -> Void
|
|
) async throws -> [FlightSchedule] {
|
|
if shouldThrowOnSchedules {
|
|
throw NSError(domain: "MockScheduleProvider", code: -1, userInfo: nil)
|
|
}
|
|
return schedulesToReturn
|
|
}
|
|
}
|
|
|
|
final class SisterFlightServiceTests: XCTestCase {
|
|
|
|
// A fixed test date so day-of-week assertions are deterministic.
|
|
// 2026-06-03 is a Wednesday → Calendar weekday = 4.
|
|
private lazy var targetDate: Date = {
|
|
var components = DateComponents()
|
|
components.year = 2026
|
|
components.month = 6
|
|
components.day = 3
|
|
components.hour = 12
|
|
components.minute = 0
|
|
components.timeZone = TimeZone(identifier: "UTC")
|
|
return Calendar(identifier: .gregorian).date(from: components)!
|
|
}()
|
|
|
|
private let origin = "JFK"
|
|
private let dest = "LAX"
|
|
|
|
// MARK: - Test 1: empty schedules
|
|
|
|
func test_emptySchedules_returnsEmptyArray() async {
|
|
let provider = MockScheduleProvider(
|
|
airportLookups: [
|
|
"JFK": [airport(id: "jfk-id", iata: "JFK")],
|
|
"LAX": [airport(id: "lax-id", iata: "LAX")]
|
|
],
|
|
schedulesToReturn: []
|
|
)
|
|
let service = SisterFlightService(flightService: provider)
|
|
|
|
let results = await service.sisterFlights(
|
|
origin: origin,
|
|
dest: dest,
|
|
date: targetDate,
|
|
currentFlight: nil
|
|
)
|
|
|
|
XCTAssertTrue(results.isEmpty, "Empty upstream schedule → empty sister-flight list.")
|
|
}
|
|
|
|
// MARK: - Test 2: schedules that don't operate on target date are filtered
|
|
|
|
func test_schedulesNotOperatingOnTargetDate_areFiltered() async {
|
|
let weekday = Calendar.current.component(.weekday, from: targetDate)
|
|
let otherWeekdays = Set([1, 2, 3, 4, 5, 6, 7]).subtracting([weekday])
|
|
|
|
// Two schedules: one runs on the target weekday, one doesn't.
|
|
let operating = schedule(
|
|
airlineIATA: "DL",
|
|
flightNumberRaw: "DL 100",
|
|
departureTime: "09:00",
|
|
arrivalTime: "12:00",
|
|
daysOfWeek: [weekday]
|
|
)
|
|
let nonOperating = schedule(
|
|
airlineIATA: "AA",
|
|
flightNumberRaw: "AA 200",
|
|
departureTime: "10:00",
|
|
arrivalTime: "13:00",
|
|
daysOfWeek: otherWeekdays
|
|
)
|
|
|
|
let provider = MockScheduleProvider(
|
|
airportLookups: [
|
|
"JFK": [airport(id: "jfk-id", iata: "JFK")],
|
|
"LAX": [airport(id: "lax-id", iata: "LAX")]
|
|
],
|
|
schedulesToReturn: [operating, nonOperating]
|
|
)
|
|
let service = SisterFlightService(flightService: provider)
|
|
|
|
let results = await service.sisterFlights(
|
|
origin: origin,
|
|
dest: dest,
|
|
date: targetDate,
|
|
currentFlight: nil
|
|
)
|
|
|
|
XCTAssertEqual(results.count, 1, "Only the schedule operating on the target weekday should survive.")
|
|
XCTAssertEqual(results.first?.carrier, "DL")
|
|
XCTAssertEqual(results.first?.flightNumber, 100)
|
|
}
|
|
|
|
// MARK: - Test 3: currentFlight match marks one entry isYourFlight
|
|
|
|
func test_currentFlightMatch_marksIsYourFlight() async {
|
|
let weekday = Calendar.current.component(.weekday, from: targetDate)
|
|
|
|
let mine = schedule(
|
|
airlineIATA: "UA",
|
|
flightNumberRaw: "UA 555",
|
|
departureTime: "08:00",
|
|
arrivalTime: "11:00",
|
|
daysOfWeek: [weekday]
|
|
)
|
|
let other = schedule(
|
|
airlineIATA: "UA",
|
|
flightNumberRaw: "UA 777",
|
|
departureTime: "14:00",
|
|
arrivalTime: "17:00",
|
|
daysOfWeek: [weekday]
|
|
)
|
|
|
|
let provider = MockScheduleProvider(
|
|
airportLookups: [
|
|
"JFK": [airport(id: "jfk-id", iata: "JFK")],
|
|
"LAX": [airport(id: "lax-id", iata: "LAX")]
|
|
],
|
|
schedulesToReturn: [mine, other]
|
|
)
|
|
let service = SisterFlightService(flightService: provider)
|
|
|
|
let results = await service.sisterFlights(
|
|
origin: origin,
|
|
dest: dest,
|
|
date: targetDate,
|
|
currentFlight: (carrier: "UA", number: 555)
|
|
)
|
|
|
|
XCTAssertEqual(results.count, 2)
|
|
let mineResult = results.first { $0.flightNumber == 555 }
|
|
let otherResult = results.first { $0.flightNumber == 777 }
|
|
XCTAssertNotNil(mineResult, "User's flight should be present in results.")
|
|
XCTAssertNotNil(otherResult, "Other sister flight should be present.")
|
|
XCTAssertTrue(mineResult?.isYourFlight == true, "Matching carrier+number → isYourFlight true.")
|
|
XCTAssertTrue(otherResult?.isYourFlight == false, "Non-matching flight should not be flagged.")
|
|
}
|
|
|
|
// MARK: - Test 4: sort by predictedLoad ascending (nil last), then by scheduledDeparture
|
|
|
|
func test_resultsSortedByLoadAscending_nilLast_thenByDeparture() async {
|
|
let weekday = Calendar.current.component(.weekday, from: targetDate)
|
|
|
|
// Four schedules with distinct departure times so we can identify them.
|
|
// Loads injected via predictor below: DL=0.50, AA=0.10, UA=0.10, B6=nil.
|
|
// Expected order:
|
|
// AA (0.10, 09:00) — earliest tie-broken
|
|
// UA (0.10, 11:00)
|
|
// DL (0.50, 08:00)
|
|
// B6 (nil, 10:00)
|
|
let dl = schedule(
|
|
airlineIATA: "DL",
|
|
flightNumberRaw: "DL 100",
|
|
departureTime: "08:00",
|
|
arrivalTime: "11:00",
|
|
daysOfWeek: [weekday]
|
|
)
|
|
let aa = schedule(
|
|
airlineIATA: "AA",
|
|
flightNumberRaw: "AA 200",
|
|
departureTime: "09:00",
|
|
arrivalTime: "12:00",
|
|
daysOfWeek: [weekday]
|
|
)
|
|
let b6 = schedule(
|
|
airlineIATA: "B6",
|
|
flightNumberRaw: "B6 300",
|
|
departureTime: "10:00",
|
|
arrivalTime: "13:00",
|
|
daysOfWeek: [weekday]
|
|
)
|
|
let ua = schedule(
|
|
airlineIATA: "UA",
|
|
flightNumberRaw: "UA 400",
|
|
departureTime: "11:00",
|
|
arrivalTime: "14:00",
|
|
daysOfWeek: [weekday]
|
|
)
|
|
|
|
let loadTable: [String: Double] = [
|
|
"DL-100": 0.50,
|
|
"AA-200": 0.10,
|
|
"UA-400": 0.10
|
|
// B6-300 omitted → nil load
|
|
]
|
|
let predictor: @Sendable (String, Int, Date) async -> Double? = { carrier, number, _ in
|
|
return loadTable["\(carrier)-\(number)"]
|
|
}
|
|
|
|
let provider = MockScheduleProvider(
|
|
airportLookups: [
|
|
"JFK": [airport(id: "jfk-id", iata: "JFK")],
|
|
"LAX": [airport(id: "lax-id", iata: "LAX")]
|
|
],
|
|
schedulesToReturn: [dl, aa, b6, ua]
|
|
)
|
|
let service = SisterFlightService(flightService: provider, loadPredictor: predictor)
|
|
|
|
let results = await service.sisterFlights(
|
|
origin: origin,
|
|
dest: dest,
|
|
date: targetDate,
|
|
currentFlight: nil
|
|
)
|
|
|
|
XCTAssertEqual(results.count, 4)
|
|
XCTAssertEqual(results[0].carrier, "AA",
|
|
"Lowest load with earliest departure first.")
|
|
XCTAssertEqual(results[1].carrier, "UA",
|
|
"Same load as AA but departs later — second.")
|
|
XCTAssertEqual(results[2].carrier, "DL",
|
|
"Higher load than AA/UA — third.")
|
|
XCTAssertEqual(results[3].carrier, "B6",
|
|
"Nil load is sorted last regardless of time.")
|
|
}
|
|
|
|
// MARK: - Test 5: predictedLoad nil when loadPredictor is nil
|
|
|
|
func test_loadPredictorNil_predictedLoadAlwaysNil() async {
|
|
let weekday = Calendar.current.component(.weekday, from: targetDate)
|
|
|
|
let s = schedule(
|
|
airlineIATA: "DL",
|
|
flightNumberRaw: "DL 100",
|
|
departureTime: "08:00",
|
|
arrivalTime: "11:00",
|
|
daysOfWeek: [weekday]
|
|
)
|
|
|
|
let provider = MockScheduleProvider(
|
|
airportLookups: [
|
|
"JFK": [airport(id: "jfk-id", iata: "JFK")],
|
|
"LAX": [airport(id: "lax-id", iata: "LAX")]
|
|
],
|
|
schedulesToReturn: [s]
|
|
)
|
|
let service = SisterFlightService(flightService: provider, loadPredictor: nil)
|
|
|
|
let results = await service.sisterFlights(
|
|
origin: origin,
|
|
dest: dest,
|
|
date: targetDate,
|
|
currentFlight: nil
|
|
)
|
|
|
|
XCTAssertEqual(results.count, 1)
|
|
XCTAssertNil(results.first?.predictedLoad,
|
|
"No predictor wired → predictedLoad must be nil.")
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func airport(id: String, iata: String) -> Airport {
|
|
Airport(id: id, iata: iata, name: "\(iata) Airport")
|
|
}
|
|
|
|
/// Build a FlightSchedule with a synthetic Airline. Date range is
|
|
/// wide enough (2020 → 2030) that any reasonable target date falls
|
|
/// inside it; the only real filter is the daysOfWeek set.
|
|
private func schedule(
|
|
airlineIATA: String,
|
|
flightNumberRaw: String,
|
|
departureTime: String,
|
|
arrivalTime: String,
|
|
daysOfWeek: Set<Int>
|
|
) -> FlightSchedule {
|
|
let airline = Airline(
|
|
id: "airline-\(airlineIATA)",
|
|
name: airlineIATA,
|
|
iata: airlineIATA,
|
|
logoFilename: "\(airlineIATA).png"
|
|
)
|
|
|
|
var utc = Calendar(identifier: .gregorian)
|
|
utc.timeZone = TimeZone(identifier: "UTC")!
|
|
let from = utc.date(from: DateComponents(year: 2020, month: 1, day: 1))!
|
|
let to = utc.date(from: DateComponents(year: 2030, month: 12, day: 31))!
|
|
|
|
return FlightSchedule(
|
|
airline: airline,
|
|
flightNumber: flightNumberRaw,
|
|
aircraft: "738",
|
|
aircraftId: "",
|
|
departureTime: departureTime,
|
|
arrivalTime: arrivalTime,
|
|
dateFrom: from,
|
|
dateTo: to,
|
|
daysOfWeek: daysOfWeek,
|
|
cabinClasses: .economy
|
|
)
|
|
}
|
|
}
|