diff --git a/Flipcash/Core/Container.swift b/Flipcash/Core/Container.swift index 71db409e..b2beb6c1 100644 --- a/Flipcash/Core/Container.swift +++ b/Flipcash/Core/Container.swift @@ -27,13 +27,13 @@ class Container { // MARK: - Init - - init() { + init(network: Network = .mainNet) { Self.configureFirebase() - + // v2 transport construction is throwing, but it cannot fail for our fixed // DNS + TLS config (mirrors the v1 ClientConnection which never threw). - self.client = try! Client(network: .mainNet) - self.flipClient = try! FlipClient(network: .mainNet) + self.client = try! Client(network: network) + self.flipClient = try! FlipClient(network: network) self.accountManager = AccountManager() self.betaFlags = BetaFlags.shared self.preferences = Preferences() @@ -88,5 +88,7 @@ extension View { } extension Container { - static let mock = Container() + /// Offline so the object graphs tests and previews build from it can never + /// reach a real backend, no matter which member fires a request. + static let mock = Container(network: .offline) } diff --git a/Flipcash/Core/Session/Session.swift b/Flipcash/Core/Session/Session.swift index 2fefac6f..2681cc8d 100644 --- a/Flipcash/Core/Session/Session.swift +++ b/Flipcash/Core/Session/Session.swift @@ -165,7 +165,7 @@ class Session { @ObservationIgnored private let historyController: HistoryController @ObservationIgnored private let database: Database - @ObservationIgnored private var poller: Poller! + @ObservationIgnored private var poller: Poller? @ObservationIgnored private var scanOperation: ScanCashOperation? @ObservationIgnored private var sendOperation: SendCashOperation? @@ -204,27 +204,33 @@ class Session { ensureValidTokenSelection() - registerPoller() startStreaming() profile = try? database.getProfile() userFlags = try? database.getUserFlags() - // Independent so a profile failure doesn't starve the user-flags fetch. - // userFlags carries server-pinned withdrawal/launch fees; without it, - // those flows submit fee=0 and the server denies the intent. - Task { - do { try await updateProfile() } - catch { logger.error("Failed to fetch profile", metadata: ["error": "\(error)"]) } - } + // Server-backed bootstrap. Skipped under unit tests: test-built sessions + // carry unregistered owners, so every one of these RPCs is a doomed + // denial fired at the production backend. + if !Container.isRunningUnitTests { + registerPoller() - Task { - do { try await updateUserFlags() } - catch { logger.error("Failed to fetch user flags", metadata: ["error": "\(error)"]) } - } + // Independent so a profile failure doesn't starve the user-flags fetch. + // userFlags carries server-pinned withdrawal/launch fees; without it, + // those flows submit fee=0 and the server denies the intent. + Task { + do { try await updateProfile() } + catch { logger.error("Failed to fetch profile", metadata: ["error": "\(error)"]) } + } - Task { - await syncUserPreferences() + Task { + do { try await updateUserFlags() } + catch { logger.error("Failed to fetch user flags", metadata: ["error": "\(error)"]) } + } + + Task { + await syncUserPreferences() + } } observeBalanceCurrencyChanges() diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index af5db1b1..69c1abb1 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -537,7 +537,12 @@ final class SessionContainer { owner: session.ownerKeyPair, selfUserID: session.userID ) - conversationController.start() + // start() opens the per-user event stream and fetches the feed. Skipped + // under unit tests: test-built containers carry unregistered owners, so + // those calls are doomed denials fired at the production backend. + if !Container.isRunningUnitTests { + conversationController.start() + } self.conversationController = conversationController // Suppress foreground chat pushes for the conversation that's currently @@ -550,7 +555,12 @@ final class SessionContainer { controller: conversationController, contactSyncController: contactSyncController ) - chatSpotlightIndexer.start() + // Skipped under unit tests: even an empty feed sends a debounced + // indexSearchableItems XPC call to the simulator's live Spotlight index + // per test-built container. + if !Container.isRunningUnitTests { + chatSpotlightIndexer.start() + } self.chatSpotlightIndexer = chatSpotlightIndexer } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient.swift index 0f211424..303483ce 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient.swift @@ -98,5 +98,5 @@ public class FlipClient: ObservableObject { } extension FlipClient { - public static let mock = try! FlipClient(network: .mainNet) + public static let mock = try! FlipClient(network: .offline) } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Client.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Client.swift index 2f73965d..1baf2a4d 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Client.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Client.swift @@ -89,5 +89,5 @@ public class Client: ObservableObject { } extension Client { - public static let mock = try! Client(network: .mainNet) + public static let mock = try! Client(network: .offline) } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Network.swift b/FlipcashCore/Sources/FlipcashCore/Models/Network.swift index a3faa477..6a73e8ba 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Network.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Network.swift @@ -10,18 +10,33 @@ import Foundation public enum Network { case mainNet + /// Resolves every host to loopback so nothing built with this network can + /// reach a real backend. Used by test and preview mocks; transport + /// construction is unaffected (resolution is lazy), and RPCs fail fast + /// with a transient transport error, which `TransportClassifiableError` + /// classifies as `.suppressed`. + case offline } extension Network { var hostForCore: String { - return "fc-v2.api.flipcash-infra.net" + switch self { + case .mainNet: "fc-v2.api.flipcash-infra.net" + case .offline: "127.0.0.1" + } } var hostForPayments: String { - return "ocp-v2.api.flipcash-infra.net" + switch self { + case .mainNet: "ocp-v2.api.flipcash-infra.net" + case .offline: "127.0.0.1" + } } var port: Int { - return 443 + switch self { + case .mainNet: 443 + case .offline: 1 + } } } diff --git a/FlipcashTests/ContainerTests.swift b/FlipcashTests/ContainerTests.swift new file mode 100644 index 00000000..8d38d913 --- /dev/null +++ b/FlipcashTests/ContainerTests.swift @@ -0,0 +1,38 @@ +// +// ContainerTests.swift +// FlipcashTests +// + +import Testing +import FlipcashCore +@testable import Flipcash + +@MainActor +struct ContainerTests { + + /// The root guarantee that test- and preview-built object graphs can never + /// reach a real backend: every shared client mock resolves every host to + /// loopback. Covers all three offline entry points independently — + /// `Container.mock` (used by session/view-model tests) builds its own + /// clients, while `Client.mock` / `FlipClient.mock` are separate instances + /// used directly (e.g. the streamer stress tests). If any one is reverted + /// to `.mainNet`, this fails loudly instead of silently resuming production + /// traffic. + @Test("every shared client mock is offline") + func sharedClientMocks_areOffline() { + #expect(Container.mock.flipClient.network == .offline) + #expect(Container.mock.client.network == .offline) + #expect(FlipClient.mock.network == .offline) + #expect(Client.mock.network == .offline) + } + + /// Canary for every `Container.isRunningUnitTests` guard — `Session` and + /// `SessionContainer` skip their server-backed bootstrap under unit tests + /// so test-built sessions don't do doomed work. If the test host stops + /// setting `XCTestConfigurationFilePath`, those guards all silently + /// disable; this fails loudly instead. + @Test("unit test host is detected as a unit test run") + func isRunningUnitTests_underTestHost_isTrue() { + #expect(Container.isRunningUnitTests) + } +} diff --git a/FlipcashTests/SessionTests.swift b/FlipcashTests/SessionTests.swift index 91c09664..815e58f8 100644 --- a/FlipcashTests/SessionTests.swift +++ b/FlipcashTests/SessionTests.swift @@ -639,11 +639,7 @@ struct SessionOfflineCacheTests { ) } - // Tests stay synchronous: the restore runs in `init`, while `updateProfile()` - // runs in a main-actor Task that can't execute until the test yields. An - // `async` test would race the network fetch against the cache. - - @Test("A cached verified profile is restored before any network fetch") + @Test("A cached verified profile is restored from the database during init") func restoresProfileOnInit() throws { let database = Database.mock try database.insertProfile(.verifiedFixture)