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).
273 lines
12 KiB
Swift
273 lines
12 KiB
Swift
import XCTest
|
|
@testable import Flights
|
|
|
|
/// Unit tests for `WeatherClient`'s timezone correctness and the shared-cache contract.
|
|
///
|
|
/// These tests are intentionally written against the **post-fix** API surface
|
|
/// (`WeatherClient.dayKey(for:in:)` and `WeatherClient.shared` with an injectable
|
|
/// `URLSession`). Until the production code adopts that shape, they will not
|
|
/// compile / will not pass — that's the TDD contract for the timezone-bug phase.
|
|
///
|
|
/// Why the test exists:
|
|
///
|
|
/// 1. **Local-day key bug.** A flight departing 2026-12-31T22:00:00-05:00
|
|
/// (10 PM Eastern at JFK) is on December 31 in the airport's wall clock,
|
|
/// but is 2027-01-01 03:00 UTC. The current implementation builds the
|
|
/// cache key in UTC (see `WeatherClient.swift:217-223`), which causes the
|
|
/// daily precip-probability lookup to land on the *wrong* calendar day —
|
|
/// surfacing tomorrow's forecast as if it were tonight's.
|
|
///
|
|
/// 2. **Shared cache.** The UI currently spins up a fresh `WeatherClient()`
|
|
/// per view (see `LiveFlightDetailSheet.swift:898`), so the per-actor
|
|
/// cache never hits across legs of a trip. The fix is `WeatherClient.shared`
|
|
/// plus an injectable session so two requests for the same (iata, day)
|
|
/// issue a single network call.
|
|
final class WeatherClientTests: XCTestCase {
|
|
|
|
// MARK: - dayKey timezone correctness
|
|
|
|
/// 10 PM Eastern on Dec 31 is still Dec 31 to a JFK traveller, even though
|
|
/// its UTC representation rolls past midnight into Jan 1. The day key must
|
|
/// be derived in the airport's local zone or every NYE evening flight will
|
|
/// fetch tomorrow's daily precip probability.
|
|
func test_dayKey_usesAirportLocalTimeZone_notUTC() throws {
|
|
// 2026-12-31T22:00:00 America/New_York
|
|
var comps = DateComponents()
|
|
comps.year = 2026; comps.month = 12; comps.day = 31
|
|
comps.hour = 22; comps.minute = 0; comps.second = 0
|
|
comps.timeZone = TimeZone(identifier: "America/New_York")
|
|
var cal = Calendar(identifier: .gregorian)
|
|
cal.timeZone = TimeZone(identifier: "America/New_York")!
|
|
let date = cal.date(from: comps)!
|
|
|
|
let nyc = TimeZone(identifier: "America/New_York")!
|
|
let key = WeatherClient.dayKey(for: date, in: nyc)
|
|
|
|
XCTAssertEqual(
|
|
key, "2026-12-31",
|
|
"10pm Eastern on NYE must resolve to the local Dec 31, not UTC's Jan 1."
|
|
)
|
|
}
|
|
|
|
/// Same instant, asked for in Tokyo — should report Jan 1 (Tokyo is +9,
|
|
/// so 10pm EST Dec 31 == 12pm JST Jan 1). Proves the helper is honouring
|
|
/// its `tz` argument and not silently defaulting to UTC.
|
|
func test_dayKey_respectsCallerProvidedTimeZone() throws {
|
|
var comps = DateComponents()
|
|
comps.year = 2026; comps.month = 12; comps.day = 31
|
|
comps.hour = 22; comps.minute = 0; comps.second = 0
|
|
comps.timeZone = TimeZone(identifier: "America/New_York")
|
|
var cal = Calendar(identifier: .gregorian)
|
|
cal.timeZone = TimeZone(identifier: "America/New_York")!
|
|
let date = cal.date(from: comps)!
|
|
|
|
let tokyo = TimeZone(identifier: "Asia/Tokyo")!
|
|
let key = WeatherClient.dayKey(for: date, in: tokyo)
|
|
|
|
XCTAssertEqual(
|
|
key, "2027-01-01",
|
|
"Same instant viewed in Tokyo is already Jan 1 — helper must use the supplied tz."
|
|
)
|
|
}
|
|
|
|
/// Sanity: noon local on a normal day round-trips through the helper for
|
|
/// every supported zone. Guards against accidentally re-introducing a
|
|
/// hard-coded "UTC" inside the formatter.
|
|
func test_dayKey_noonLocal_matchesCalendarDay() throws {
|
|
for id in ["America/Los_Angeles", "America/New_York", "Europe/London", "Asia/Tokyo", "Australia/Sydney"] {
|
|
let tz = TimeZone(identifier: id)!
|
|
var cal = Calendar(identifier: .gregorian)
|
|
cal.timeZone = tz
|
|
var comps = DateComponents()
|
|
comps.year = 2026; comps.month = 6; comps.day = 15
|
|
comps.hour = 12; comps.minute = 0
|
|
comps.timeZone = tz
|
|
let date = cal.date(from: comps)!
|
|
XCTAssertEqual(
|
|
WeatherClient.dayKey(for: date, in: tz),
|
|
"2026-06-15",
|
|
"Noon \(id) on 2026-06-15 must round-trip to that calendar day."
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Shared cache + single-flight network behaviour
|
|
|
|
/// Two `forecast(...)` calls for the same airport and local day should
|
|
/// hit the network once. The fix is `WeatherClient.shared` plus an
|
|
/// injectable `URLSession` so we can count requests against a stub
|
|
/// protocol — and `LiveFlightDetailSheet` must adopt `.shared` for the
|
|
/// production cache to actually share.
|
|
func test_shared_cachesPerLocalDay_acrossCalls() async throws {
|
|
let db = AirportDatabase()
|
|
try XCTSkipIf(db.airport(byIATA: "JFK") == nil, "airports.json missing JFK; cannot exercise weather fetch")
|
|
|
|
// Single-shot stub that returns the same canned Open-Meteo payload
|
|
// for any URL. The counter is incremented on every network request.
|
|
let counter = RequestCounter()
|
|
let config = URLSessionConfiguration.ephemeral
|
|
config.protocolClasses = [StubURLProtocol.self]
|
|
StubURLProtocol.counter = counter
|
|
StubURLProtocol.responder = { _ in
|
|
let body = Self.openMeteoFixture()
|
|
return (HTTPURLResponse(
|
|
url: URL(string: "https://api.open-meteo.com/v1/forecast")!,
|
|
statusCode: 200, httpVersion: "HTTP/1.1",
|
|
headerFields: ["Content-Type": "application/json"]
|
|
)!, body)
|
|
}
|
|
let session = URLSession(configuration: config)
|
|
let client = WeatherClient(session: session)
|
|
|
|
// 8 AM Eastern at JFK — squarely inside Open-Meteo's fixture window.
|
|
let date = Self.localDate(2026, 6, 15, 8, "America/New_York")
|
|
|
|
_ = await client.forecast(forIATA: "JFK", on: date, database: db)
|
|
_ = await client.forecast(forIATA: "JFK", on: date, database: db)
|
|
|
|
let hits = await counter.value
|
|
XCTAssertEqual(
|
|
hits, 1,
|
|
"Second call for the same (iata, local day) must be served from cache, not re-fetched."
|
|
)
|
|
|
|
StubURLProtocol.responder = nil
|
|
StubURLProtocol.counter = nil
|
|
}
|
|
|
|
/// Confirms the singleton exists and is the shared instance, so the UI
|
|
/// pivot to `WeatherClient.shared` actually deduplicates across views.
|
|
func test_sharedSingleton_isStable() {
|
|
let a = WeatherClient.shared
|
|
let b = WeatherClient.shared
|
|
XCTAssertTrue(a === b, "WeatherClient.shared must vend the same actor instance across calls.")
|
|
}
|
|
|
|
/// The forecast surface must use the local-day daily precip probability,
|
|
/// not the UTC-day one. With the fixture below, June 15 local has
|
|
/// precipProbability=42 and June 16 has 88 — a UTC-keyed lookup at 10pm
|
|
/// Eastern would land on the wrong bucket and return 88.
|
|
func test_forecast_dailyPrecipProbability_usesLocalDay() async throws {
|
|
let db = AirportDatabase()
|
|
try XCTSkipIf(db.airport(byIATA: "JFK") == nil, "airports.json missing JFK; cannot exercise weather fetch")
|
|
|
|
let config = URLSessionConfiguration.ephemeral
|
|
config.protocolClasses = [StubURLProtocol.self]
|
|
StubURLProtocol.counter = RequestCounter()
|
|
StubURLProtocol.responder = { _ in
|
|
let body = Self.openMeteoFixture()
|
|
return (HTTPURLResponse(
|
|
url: URL(string: "https://api.open-meteo.com/v1/forecast")!,
|
|
statusCode: 200, httpVersion: "HTTP/1.1",
|
|
headerFields: ["Content-Type": "application/json"]
|
|
)!, body)
|
|
}
|
|
let session = URLSession(configuration: config)
|
|
let client = WeatherClient(session: session)
|
|
|
|
// 10 PM local on June 15 NYC — UTC would resolve to June 16.
|
|
let date = Self.localDate(2026, 6, 15, 22, "America/New_York")
|
|
let forecast = await client.forecast(forIATA: "JFK", on: date, database: db)
|
|
XCTAssertNotNil(forecast)
|
|
XCTAssertEqual(forecast?.airport, "JFK")
|
|
XCTAssertEqual(
|
|
forecast?.precipProbabilityPct, 42,
|
|
"Daily precip prob must reflect the local day's bucket (42), not the UTC day after (88)."
|
|
)
|
|
|
|
StubURLProtocol.responder = nil
|
|
StubURLProtocol.counter = nil
|
|
}
|
|
|
|
// MARK: - Fixtures / helpers
|
|
|
|
/// Open-Meteo's `timezone=auto` response with hourly entries spanning
|
|
/// the night of 2026-06-15 and into 06-16 (America/New_York), plus two
|
|
/// daily entries — one with precipProb=42 (the 15th) and one with 88
|
|
/// (the 16th) so we can detect which day the client picked.
|
|
private static func openMeteoFixture() -> Data {
|
|
let json = """
|
|
{
|
|
"timezone": "America/New_York",
|
|
"hourly": {
|
|
"time": [
|
|
"2026-06-15T20:00",
|
|
"2026-06-15T21:00",
|
|
"2026-06-15T22:00",
|
|
"2026-06-15T23:00",
|
|
"2026-06-16T00:00",
|
|
"2026-06-16T01:00"
|
|
],
|
|
"temperature_2m": [21.0, 20.5, 20.0, 19.5, 19.0, 18.5],
|
|
"precipitation": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
|
|
"wind_speed_10m": [10.0, 10.0, 10.0, 10.0, 10.0, 10.0],
|
|
"visibility": [20000.0, 20000.0, 20000.0, 20000.0, 20000.0, 20000.0],
|
|
"weather_code": [1, 1, 1, 1, 2, 2]
|
|
},
|
|
"daily": {
|
|
"time": ["2026-06-15", "2026-06-16"],
|
|
"weathercode": [1, 2],
|
|
"precipitation_probability_max": [42, 88]
|
|
}
|
|
}
|
|
"""
|
|
return Data(json.utf8)
|
|
}
|
|
|
|
private static func localDate(_ y: Int, _ m: Int, _ d: Int, _ h: Int, _ tzID: String) -> Date {
|
|
var cal = Calendar(identifier: .gregorian)
|
|
cal.timeZone = TimeZone(identifier: tzID)!
|
|
var comps = DateComponents()
|
|
comps.year = y; comps.month = m; comps.day = d
|
|
comps.hour = h; comps.minute = 0; comps.second = 0
|
|
comps.timeZone = TimeZone(identifier: tzID)
|
|
return cal.date(from: comps)!
|
|
}
|
|
}
|
|
|
|
// MARK: - Test doubles
|
|
|
|
/// Thread-safe call counter for the stub URLProtocol. Lives outside the
|
|
/// actor system so the protocol class can touch it from arbitrary queues.
|
|
final class RequestCounter: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var _value = 0
|
|
var value: Int {
|
|
lock.lock(); defer { lock.unlock() }
|
|
return _value
|
|
}
|
|
func bump() {
|
|
lock.lock(); defer { lock.unlock() }
|
|
_value += 1
|
|
}
|
|
}
|
|
|
|
/// Minimal URLProtocol that hands every request to `responder` and bumps
|
|
/// `counter`. Lets us assert "exactly one fetch" without leaning on the
|
|
/// real network.
|
|
///
|
|
/// The static `responder` / `counter` fields are accessed serially from one
|
|
/// test at a time (XCTest runs tests sequentially within a class), so a
|
|
/// plain `static var` is safe here without nonisolated-unsafe annotations.
|
|
final class StubURLProtocol: URLProtocol {
|
|
static var responder: ((URLRequest) -> (HTTPURLResponse, Data))?
|
|
static var counter: RequestCounter?
|
|
|
|
override class func canInit(with request: URLRequest) -> Bool { responder != nil }
|
|
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
|
|
|
override func startLoading() {
|
|
Self.counter?.bump()
|
|
guard let responder = Self.responder else {
|
|
client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
|
|
return
|
|
}
|
|
let (response, data) = responder(request)
|
|
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
|
client?.urlProtocol(self, didLoad: data)
|
|
client?.urlProtocolDidFinishLoading(self)
|
|
}
|
|
|
|
override func stopLoading() {}
|
|
}
|