Files
Flights/FlightsTests/SisterFlightServiceTests.swift
T
Trey T ba0688a412 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).
2026-06-06 01:09:59 -05:00

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
)
}
}