Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions Flipcash/Core/Container.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}
36 changes: 21 additions & 15 deletions Flipcash/Core/Session/Session.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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()
Expand Down
14 changes: 12 additions & 2 deletions Flipcash/Core/Session/SessionAuthenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
21 changes: 18 additions & 3 deletions FlipcashCore/Sources/FlipcashCore/Models/Network.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
38 changes: 38 additions & 0 deletions FlipcashTests/ContainerTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
6 changes: 1 addition & 5 deletions FlipcashTests/SessionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down