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).
135 lines
5.0 KiB
Swift
135 lines
5.0 KiB
Swift
import XCTest
|
|
import SwiftData
|
|
@testable import Flights
|
|
|
|
/// Unit tests for `AirframeHistoryStore`.
|
|
///
|
|
/// We exercise the store against an in-memory `ModelContainer` seeded
|
|
/// with `LoggedFlight` rows that vary by tail number, route, and date.
|
|
/// All assertions reference the documented `AirframeStats` contract.
|
|
@MainActor
|
|
final class AirframeHistoryStoreTests: XCTestCase {
|
|
|
|
private var container: ModelContainer!
|
|
private var context: ModelContext!
|
|
private var store: AirframeHistoryStore!
|
|
|
|
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)
|
|
store = AirframeHistoryStore()
|
|
}
|
|
|
|
override func tearDownWithError() throws {
|
|
store = nil
|
|
context = nil
|
|
container = nil
|
|
try super.tearDownWithError()
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
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(
|
|
registration: String?,
|
|
origin: String,
|
|
dest: String,
|
|
flightDate: Date
|
|
) -> LoggedFlight {
|
|
let flight = LoggedFlight(
|
|
flightDate: flightDate,
|
|
departureIATA: origin,
|
|
arrivalIATA: dest,
|
|
registration: registration
|
|
)
|
|
context.insert(flight)
|
|
return flight
|
|
}
|
|
|
|
// MARK: - Tests
|
|
|
|
/// Empty store → empty stats sentinel.
|
|
func test_stats_emptyContext_returnsEmpty() {
|
|
let stats = store.stats(forTail: "N281WN", context: context)
|
|
|
|
XCTAssertEqual(stats.totalFlights, 0)
|
|
XCTAssertTrue(stats.routes.isEmpty)
|
|
XCTAssertNil(stats.firstSeen)
|
|
XCTAssertNil(stats.lastSeen)
|
|
XCTAssertNil(stats.mostCommonRoute)
|
|
}
|
|
|
|
/// 3 flights on the same tail across 2 distinct routes — verify the
|
|
/// aggregate counts and the "DAL→HOU (2 of 3)" most-common-route
|
|
/// formatting.
|
|
func test_stats_threeFlightsTwoRoutes_aggregatesCorrectly() {
|
|
insert(registration: "N281WN", origin: "DAL", dest: "HOU", flightDate: date(0))
|
|
insert(registration: "N281WN", origin: "DAL", dest: "HOU", flightDate: date(5))
|
|
insert(registration: "N281WN", origin: "DAL", dest: "LAS", flightDate: date(10))
|
|
// Other-tail noise — must not be counted.
|
|
insert(registration: "N999AA", origin: "DAL", dest: "HOU", flightDate: date(2))
|
|
|
|
let stats = store.stats(forTail: "N281WN", context: context)
|
|
|
|
XCTAssertEqual(stats.totalFlights, 3)
|
|
XCTAssertEqual(Set(stats.routes), Set(["DAL→HOU", "DAL→LAS"]))
|
|
XCTAssertEqual(stats.routes.count, 2)
|
|
XCTAssertEqual(stats.firstSeen, date(0))
|
|
XCTAssertEqual(stats.lastSeen, date(10))
|
|
XCTAssertEqual(stats.mostCommonRoute, "DAL→HOU (2 of 3)")
|
|
}
|
|
|
|
/// Lookup tail must be normalized to uppercase — passing "n281wn"
|
|
/// matches a stored "N281WN".
|
|
func test_stats_lookupIsCaseInsensitive() {
|
|
insert(registration: "N281WN", origin: "DAL", dest: "HOU", flightDate: date(0))
|
|
|
|
let stats = store.stats(forTail: "n281wn", context: context)
|
|
|
|
XCTAssertEqual(stats.totalFlights, 1)
|
|
XCTAssertEqual(stats.mostCommonRoute, "DAL→HOU (1 of 1)")
|
|
}
|
|
|
|
/// The store should still report stats for a single-flight tail. The
|
|
/// History UI hides the section in that case, but the underlying
|
|
/// store contract returns the real count.
|
|
func test_stats_singleFlight_returnsTotalOne() {
|
|
insert(registration: "N281WN", origin: "DAL", dest: "HOU", flightDate: date(0))
|
|
|
|
let stats = store.stats(forTail: "N281WN", context: context)
|
|
|
|
XCTAssertEqual(stats.totalFlights, 1)
|
|
XCTAssertEqual(stats.routes, ["DAL→HOU"])
|
|
XCTAssertEqual(stats.firstSeen, date(0))
|
|
XCTAssertEqual(stats.lastSeen, date(0))
|
|
XCTAssertEqual(stats.mostCommonRoute, "DAL→HOU (1 of 1)")
|
|
}
|
|
|
|
/// Mixed-case stored registration: a record persisted with lowercase
|
|
/// "n281wn" must still be discoverable when callers ask for
|
|
/// "N281WN". Today the fast-path #Predicate misses (it compares
|
|
/// exact bytes against the uppercased query) and the fallback
|
|
/// table-scan recovers it. After Phase 3 fixes registration
|
|
/// normalisation at write-time (or switches to a case-insensitive
|
|
/// predicate), the fast path will hit — but this test should still
|
|
/// pass either way.
|
|
func test_stats_lowercaseStoredRegistration_isFoundViaFallback() {
|
|
insert(registration: "n281wn", origin: "DAL", dest: "HOU", flightDate: date(0))
|
|
|
|
let stats = store.stats(forTail: "N281WN", context: context)
|
|
|
|
XCTAssertEqual(stats.totalFlights, 1)
|
|
XCTAssertEqual(stats.routes, ["DAL→HOU"])
|
|
XCTAssertEqual(stats.mostCommonRoute, "DAL→HOU (1 of 1)")
|
|
}
|
|
}
|