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:
FuelBoard Contributor
2026-08-15 13:33:02 +01:00
parent c447372d40
commit 535a504160
8 changed files with 196 additions and 264 deletions
@@ -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")
}
}