Consumer chain: GitHub-only — relay removed from app+widget, Local Network permission gone
The LAN relay is unreachable from any phone outside the developer's LAN, so for App Store users a relay fallback was dead weight AND harmful: attempting the private IP fires the iOS Local Network prompt on a stranger's phone and adds a timeout before the bundled dump. GitHub raw is reachable anywhere the relay would be, and more reliably. - LiveChainProvider: GitHub mirror first EVERYWHERE (focused alert path no longer relay-first); LAN relay only joins behind the hidden dev flag fuelboard.relayFallback (Settings → Debug, keychain-first, off by default) - MirrorFuelProvider: focused() projection replaces the relay's server-side radius endpoint (fuel filter + within-radius + nearest-first + limit 500) — the alert path re-checks radius/fuel itself, so behaviour is identical - Widget: fetchFocused/fetchFuelOnly now GitHub-first via the chain (day-cache shared through the app group); relay + widget-diag GET gated on the same dev flag — off-LAN widgets no longer attempt local network at all - FuelBeacon: fires only when the dev flag is on — consumers make zero local-network attempts; /stats app-hit spike = GitHub down + dev flag on - Onboarding: Local Network machinery removed entirely (no probe, no prompt, no 12 s stall) — data page is now informational: prices download from the internet, no permissions needed. Replay on cellular can no longer hang. - Info.plist: NSLocalNetworkUsageDescription copy → optional dev-only wording - 95 tests (6 new: relay-skip-when-disabled x2, focused prefer-mirror, mirror focused projection x3, beacon dev-flag gate)
This commit is contained in:
@@ -1,12 +1,16 @@
|
||||
import XCTest
|
||||
@testable import FuelBoardShared
|
||||
|
||||
/// P0 live chain: GitHub mirror → LAN relay → bundled dump.
|
||||
/// 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() {
|
||||
@@ -33,9 +37,9 @@ final class LiveChainTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
private func fixtureStation(_ id: String = "s1") -> FuelStation {
|
||||
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: 53.7, lng: -1.8,
|
||||
postcode: "SW1A 1AA", lat: lat, lng: lng,
|
||||
prices: [.e10: 137.9, .e5: 144.9, .diesel: 144.9], priceUpdated: nil)
|
||||
}
|
||||
|
||||
@@ -54,7 +58,21 @@ final class LiveChainTests: XCTestCase {
|
||||
XCTAssertEqual(LiveChainProvider.lastSource, "github")
|
||||
}
|
||||
|
||||
func testFullPathFallsBackToRelay() async throws {
|
||||
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)
|
||||
@@ -62,35 +80,77 @@ final class LiveChainTests: XCTestCase {
|
||||
let stations = try await chain.fetchStations(near: nil, lng: nil, fuel: .e10, radiusKM: nil)
|
||||
|
||||
XCTAssertEqual(stations.first?.id, "relay")
|
||||
XCTAssertEqual(relay.callCount, 1, "relay must serve when the mirror is down")
|
||||
XCTAssertEqual(relay.callCount, 1, "dev flag on → relay fallback serves")
|
||||
XCTAssertEqual(LiveChainProvider.lastSource, "relay")
|
||||
}
|
||||
|
||||
// MARK: Focused path (alert checks): relay first — never pull the full
|
||||
// ~2.8 MB dump over mobile data for a background alert when the relay is up.
|
||||
// MARK: Focused path (alert checks + widget): GitHub first too
|
||||
|
||||
func testFocusedPathPrefersRelay() async throws {
|
||||
func testFocusedPathPrefersMirror() async throws {
|
||||
let mirror = StubProvider(result: [fixtureStation("mirror")])
|
||||
let relay = StubProvider(result: [fixtureStation("relay")])
|
||||
let chain = LiveChainProvider(mirror: mirror, relay: relay)
|
||||
|
||||
_ = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5)
|
||||
let stations = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5)
|
||||
|
||||
XCTAssertEqual(relay.callCount, 1, "focused alert fetch must prefer the light relay call")
|
||||
XCTAssertEqual(mirror.callCount, 0, "mirror full dump must NOT be pulled when the relay is up")
|
||||
XCTAssertEqual(LiveChainProvider.lastSource, "relay")
|
||||
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 testFocusedPathFallsBackToMirror() async throws {
|
||||
let mirror = StubProvider(result: [fixtureStation("mirror")])
|
||||
let relay = StubProvider(error: FuelProviderError.relayUnavailable)
|
||||
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, "mirror")
|
||||
XCTAssertEqual(mirror.callCount, 1)
|
||||
XCTAssertEqual(LiveChainProvider.lastSource, "github")
|
||||
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
|
||||
@@ -103,7 +163,7 @@ final class LiveChainTests: XCTestCase {
|
||||
XCTAssertFalse(MirrorFuelProvider.canReuseCache(cachedDay: "2026-08-15", latestDay: nil))
|
||||
}
|
||||
|
||||
// MARK: Beacon URL
|
||||
// MARK: Beacon
|
||||
|
||||
func testBeaconURLCarriesAppAttribution() {
|
||||
guard let url = FuelBeacon.beaconURL(source: "github", n: 8022) else {
|
||||
@@ -115,4 +175,12 @@ final class LiveChainTests: XCTestCase {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user