From 2aa1eca91d0fc7e3df4f0f4aa6829dd36bbf5b9c Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Fri, 3 Jul 2026 15:15:27 -0400 Subject: [PATCH 1/3] fix: stop unit-test sessions firing doomed RPCs at production Container.mock wires real mainNet gRPC clients, so every test-built Session/SessionContainer fetched profile, flags, balances, and the conversation feed against production with unregistered owners. The server-backed bootstrap is now skipped under unit tests, mirroring the existing startStreaming() guard. --- Flipcash/Core/Session/Session.swift | 34 +++++++++++-------- .../Core/Session/SessionAuthenticator.swift | 7 +++- FlipcashTests/ContainerTests.swift | 23 +++++++++++++ 3 files changed, 49 insertions(+), 15 deletions(-) create mode 100644 FlipcashTests/ContainerTests.swift diff --git a/Flipcash/Core/Session/Session.swift b/Flipcash/Core/Session/Session.swift index 2fefac6f..b4027862 100644 --- a/Flipcash/Core/Session/Session.swift +++ b/Flipcash/Core/Session/Session.swift @@ -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..006320c1 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 diff --git a/FlipcashTests/ContainerTests.swift b/FlipcashTests/ContainerTests.swift new file mode 100644 index 00000000..aa9f6cca --- /dev/null +++ b/FlipcashTests/ContainerTests.swift @@ -0,0 +1,23 @@ +// +// ContainerTests.swift +// FlipcashTests +// +// Created by Claude on 2026-07-03. +// + +import Testing +@testable import Flipcash + +@MainActor +struct ContainerTests { + + /// Canary for every `Container.isRunningUnitTests` guard — `Session` and + /// `SessionContainer` skip their server-backed bootstrap under unit tests + /// so test-built sessions don't fire doomed RPCs at production. 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) + } +} From 468f2f385cae7acfe27d690f063049007f55b61e Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Sat, 4 Jul 2026 11:54:34 -0400 Subject: [PATCH 2/3] chore: drop attribution line from test file header --- FlipcashTests/ContainerTests.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/FlipcashTests/ContainerTests.swift b/FlipcashTests/ContainerTests.swift index aa9f6cca..58471431 100644 --- a/FlipcashTests/ContainerTests.swift +++ b/FlipcashTests/ContainerTests.swift @@ -2,8 +2,6 @@ // ContainerTests.swift // FlipcashTests // -// Created by Claude on 2026-07-03. -// import Testing @testable import Flipcash From 3906ff98964b975e28ea58f9f79431afe21591f4 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Sun, 5 Jul 2026 17:16:20 -0400 Subject: [PATCH 3/3] fix: route mock clients through an offline network so tests can't reach production Container.mock, Client.mock, and FlipClient.mock now build on a new Network.offline case that resolves every host to loopback. This is the structural backstop behind the per-site test guards: no matter which member of a test- or preview-built object graph fires a request, it can never reach the real backend. Also gates chatSpotlightIndexer.start() under unit tests (a CoreSpotlight XPC call the offline network can't cover) and relaxes poller to an optional since it stays nil under tests. --- Flipcash/Core/Container.swift | 12 ++++++---- Flipcash/Core/Session/Session.swift | 2 +- .../Core/Session/SessionAuthenticator.swift | 7 +++++- .../Clients/Flip API/FlipClient.swift | 2 +- .../Clients/Payments API/Client.swift | 2 +- .../Sources/FlipcashCore/Models/Network.swift | 21 ++++++++++++++--- FlipcashTests/ContainerTests.swift | 23 ++++++++++++++++--- FlipcashTests/SessionTests.swift | 6 +---- 8 files changed, 55 insertions(+), 20 deletions(-) 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 b4027862..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? diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index 006320c1..69c1abb1 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -555,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 index 58471431..8d38d913 100644 --- a/FlipcashTests/ContainerTests.swift +++ b/FlipcashTests/ContainerTests.swift @@ -4,16 +4,33 @@ // 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 fire doomed RPCs at production. If the - /// test host stops setting `XCTestConfigurationFilePath`, those guards - /// all silently disable; this fails loudly instead. + /// 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)