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