Files
Flights/FlightsTests/WeatherClientTests.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

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