import XCTest @testable import FuelBoardShared /// P0 live chain: GitHub mirror FIRST everywhere → [dev-only LAN relay] → /// (app: bundled dump in ContentView's catch, widget: cache → placeholder). /// The relay is NOT part of the consumer chain — it only joins behind the /// hidden dev flag `fuelboard.relayFallback`. final class LiveChainTests: XCTestCase { override func setUp() { super.setUp() FuelBeacon.isEnabled = false // chain tests must not fire real pings FuelStore.saveRelayFallbackEnabled(false) // dev flag off by default } override func tearDown() { FuelBeacon.isEnabled = true super.tearDown() } // MARK: Fixtures private final class StubProvider: FuelPriceProviding { var result: [FuelStation] var error: Error? private(set) var callCount = 0 init(result: [FuelStation]? = nil, error: Error? = nil) { self.result = result ?? [] self.error = error } func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] { callCount += 1 if let error { throw error } return result } } private func fixtureStation(_ id: String = "s1", lat: Double = 53.7, lng: Double = -1.8) -> FuelStation { FuelStation(id: id, name: "Station \(id)", brand: "Test", address: "1 High St", postcode: "SW1A 1AA", lat: lat, lng: lng, prices: [.e10: 137.9, .e5: 144.9, .diesel: 144.9], priceUpdated: nil) } // MARK: Full path (app refresh): GitHub first func testFullPathPrefersMirror() async throws { let mirror = StubProvider(result: [fixtureStation()]) let relay = StubProvider(error: FuelProviderError.relayUnavailable) let chain = LiveChainProvider(mirror: mirror, relay: relay) let stations = try await chain.fetchStations(near: nil, lng: nil, fuel: .e10, radiusKM: nil) XCTAssertEqual(stations.count, 1) XCTAssertEqual(mirror.callCount, 1, "full path must try the GitHub mirror first") XCTAssertEqual(relay.callCount, 0, "relay must not run when the mirror serves") XCTAssertEqual(LiveChainProvider.lastSource, "github") } func testFullPathRelaySkippedWhenDisabled() async { let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable) let relay = StubProvider(result: [fixtureStation("relay")]) let chain = LiveChainProvider(mirror: mirror, relay: relay) do { _ = try await chain.fetchStations(near: nil, lng: nil, fuel: .e10, radiusKM: nil) XCTFail("chain must throw when the mirror fails and the relay flag is off") } catch { XCTAssertEqual(relay.callCount, 0, "consumers must NEVER attempt the relay") } } func testFullPathRelayFallbackWhenDevFlagOn() async throws { FuelStore.saveRelayFallbackEnabled(true) let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable) let relay = StubProvider(result: [fixtureStation("relay")]) let chain = LiveChainProvider(mirror: mirror, relay: relay) let stations = try await chain.fetchStations(near: nil, lng: nil, fuel: .e10, radiusKM: nil) XCTAssertEqual(stations.first?.id, "relay") XCTAssertEqual(relay.callCount, 1, "dev flag on → relay fallback serves") XCTAssertEqual(LiveChainProvider.lastSource, "relay") } // MARK: Focused path (alert checks + widget): GitHub first too func testFocusedPathPrefersMirror() async throws { let mirror = StubProvider(result: [fixtureStation("mirror")]) let relay = StubProvider(result: [fixtureStation("relay")]) let chain = LiveChainProvider(mirror: mirror, relay: relay) let stations = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5) XCTAssertEqual(stations.first?.id, "mirror", "focused path must prefer the mirror — the relay is unreachable off-LAN") XCTAssertEqual(relay.callCount, 0) XCTAssertEqual(LiveChainProvider.lastSource, "github") } func testFocusedPathRelaySkippedWhenDisabled() async { let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable) let relay = StubProvider(result: [fixtureStation("relay")]) let chain = LiveChainProvider(mirror: mirror, relay: relay) do { _ = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5) XCTFail("focused path must throw when the mirror fails and the relay flag is off") } catch { XCTAssertEqual(relay.callCount, 0, "consumers must NEVER attempt the relay") } } func testFocusedPathRelayFallbackWhenDevFlagOn() async throws { FuelStore.saveRelayFallbackEnabled(true) let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable) let relay = StubProvider(result: [fixtureStation("relay")]) let chain = LiveChainProvider(mirror: mirror, relay: relay) let stations = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5) XCTAssertEqual(stations.first?.id, "relay") XCTAssertEqual(relay.callCount, 1) } // MARK: Mirror focused projection (replaces the relay's server-side radius) func testMirrorFocusedFiltersByFuelRadiusDistanceAndLimit() { let here = fixtureStation("here", lat: 53.7, lng: -1.8) // e10+others, at origin let far = fixtureStation("far", lat: 54.0, lng: -1.8) // ~33 km away, e10 let noFuel = FuelStation(id: "noFuel", name: "Diesel Only", brand: "Test", address: "2 High St", postcode: "SW1A 1AA", lat: 53.7, lng: -1.8, prices: [.diesel: 144.9], priceUpdated: nil) let d1 = fixtureStation("d1", lat: 53.701, lng: -1.8) let d2 = fixtureStation("d2", lat: 53.702, lng: -1.8) let out = MirrorFuelProvider.focused([far, noFuel, d2, d1, here], near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 10) XCTAssertEqual(out.map(\.id), ["here", "d1", "d2"], "nearest-first, fuel-only, within radius") XCTAssertFalse(out.contains { $0.id == "far" }, "outside radius must drop") XCTAssertFalse(out.contains { $0.id == "noFuel" }, "missing fuel must drop") } func testMirrorFocusedPassesThroughWithoutRadius() { let stations = [fixtureStation("a"), fixtureStation("b")] XCTAssertEqual(MirrorFuelProvider.focused(stations, near: nil, lng: nil, fuel: .e10, radiusKM: nil).count, 2) } func testMirrorFocusedLimit() { let stations = (0..<600).map { fixtureStation("s\($0)", lat: 53.7 + Double($0) * 0.0001, lng: -1.8) } let out = MirrorFuelProvider.focused(stations, near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 100) XCTAssertEqual(out.count, 500, "focused projection must stay bounded") } // MARK: Mirror day-cache decision func testDumpCacheReuseDecision() { XCTAssertTrue(MirrorFuelProvider.canReuseCache(cachedDay: "2026-08-15", latestDay: "2026-08-15"), "same day → reuse the cached dump, no ~2.8 MB re-download") XCTAssertFalse(MirrorFuelProvider.canReuseCache(cachedDay: "2026-08-14", latestDay: "2026-08-15")) XCTAssertFalse(MirrorFuelProvider.canReuseCache(cachedDay: nil, latestDay: "2026-08-15")) XCTAssertFalse(MirrorFuelProvider.canReuseCache(cachedDay: "2026-08-15", latestDay: nil)) } // MARK: Beacon func testBeaconURLCarriesAppAttribution() { guard let url = FuelBeacon.beaconURL(source: "github", n: 8022) else { return XCTFail("beacon URL must build") } XCTAssertTrue(url.absoluteString.contains("api/v1/widget-diag")) let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] XCTAssertTrue(items.contains(URLQueryItem(name: "intent", value: "app-live"))) XCTAssertTrue(items.contains(URLQueryItem(name: "source", value: "github"))) XCTAssertTrue(items.contains(URLQueryItem(name: "n", value: "8022"))) } func testBeaconGatedByDevFlag() { FuelBeacon.isEnabled = true FuelStore.saveRelayFallbackEnabled(false) XCTAssertFalse(FuelBeacon.shouldFire, "consumers: beacon must never fire (no local-network attempt)") FuelStore.saveRelayFallbackEnabled(true) XCTAssertTrue(FuelBeacon.shouldFire, "dev flag on: beacon fires for attribution") } }