Skip to content
Merged
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
8 changes: 2 additions & 6 deletions Bitkit/Services/TransferStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,8 @@ class TransferStorage {
transfersChangedSubject.eraseToAnyPublisher()
}

private init(suiteName: String? = nil) {
if let suiteName {
defaults = UserDefaults(suiteName: suiteName) ?? .standard
} else {
defaults = .standard
}
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}

/// Insert a new transfer
Expand Down
17 changes: 13 additions & 4 deletions Bitkit/ViewModels/TransferViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -213,14 +213,18 @@ class TransferViewModel: ObservableObject {
}
}

/// Convenience initializer for testing and previews
/// Convenience initializer for testing and previews. Leave `transferDefaults` nil to persist through
/// `TransferStorage.shared`, whose change notifications drive backups; tests pass an isolated suite
/// so mock transfers never reach the app's own store.
convenience init(
coreService: CoreService = .shared,
lightningService: LightningService = .shared,
currencyService: CurrencyService = .shared,
sheetViewModel: SheetViewModel = SheetViewModel()
sheetViewModel: SheetViewModel = SheetViewModel(),
transferDefaults: UserDefaults? = nil
) {
let transferService = TransferService(
storage: transferDefaults.map { TransferStorage(defaults: $0) } ?? .shared,
lightningService: lightningService,
blocktankService: coreService.blocktank
)
Expand All @@ -234,7 +238,10 @@ class TransferViewModel: ObservableObject {
}

/// Convenience initializer for hardware-wallet transfer tests. Builds the `TransferService`
/// inside the app module so callers don't construct cross-module service types.
/// inside the app module so callers don't construct cross-module service types — `transferDefaults`
/// is a `UserDefaults` for the same reason, since `TransferStorage` is compiled into both modules.
/// Leave it nil to persist through `TransferStorage.shared`; tests pass an isolated suite so mock
/// transfers never reach the app's own store.
convenience init(
hwFunding: HwTransferFunding?,
hwConnecting: HwTransferConnecting?,
Expand All @@ -243,9 +250,11 @@ class TransferViewModel: ObservableObject {
hwTimeouts: (reconnect: Double, compose: Double, sign: Double, broadcast: Double) = (reconnect: 30, compose: 45, sign: 120, broadcast: 120),
coreService: CoreService = .shared,
lightningService: LightningService = .shared,
sheetViewModel: SheetViewModel = SheetViewModel()
sheetViewModel: SheetViewModel = SheetViewModel(),
transferDefaults: UserDefaults? = nil
) {
let transferService = TransferService(
storage: transferDefaults.map { TransferStorage(defaults: $0) } ?? .shared,
lightningService: lightningService,
blocktankService: coreService.blocktank
)
Expand Down
64 changes: 64 additions & 0 deletions BitkitTests/AppStateIsolation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import Foundation
import XCTest

/// Helpers that keep a test suite off the host app's own state.
///
/// `BitkitTests` is hosted in the Bitkit app (`TEST_HOST` in the project settings), so
/// `UserDefaults.standard` *is* the app's preferences and anything a test writes there lands in the
/// developer's wallet. Issue #733 is what that looks like in practice: mock transfer records left
/// behind by a test run pinned a permanent "TRANSFER IN PROGRESS" banner on the real wallet.
///
/// Reach for these in order of preference:
/// 1. `makeIsolatedDefaults()` when the code under test accepts injected defaults — nothing touches
/// the app's domain at all.
/// 2. `snapshotAppDefaults(_:)` when it does not, so the keys are put back afterwards.
/// 3. `guardAppDefaults(_:)` on suites that should write nothing, to keep it that way.
extension XCTestCase {
/// A `UserDefaults` suite unique to this test, emptied before it runs and removed afterwards.
func makeIsolatedDefaults(_ label: String = #function, file: StaticString = #filePath, line: UInt = #line) throws -> UserDefaults {
let suiteName = "\(type(of: self)).\(label).\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName), "Could not open suite \(suiteName)", file: file, line: line)
defaults.removePersistentDomain(forName: suiteName)
addTeardownBlock { defaults.removePersistentDomain(forName: suiteName) }
return defaults
}

/// Restores `keys` in `UserDefaults.standard` when the test ends, removing any that are absent
/// now. Use when the code under test has no seam for injected defaults.
func snapshotAppDefaults(_ keys: String...) {
let defaults = UserDefaults.standard
let snapshot = keys.map { (key: $0, value: defaults.object(forKey: $0)) }
addTeardownBlock {
for entry in snapshot {
if let value = entry.value {
defaults.set(value, forKey: entry.key)
} else {
defaults.removeObject(forKey: entry.key)
}
}
}
}

/// Fails the test if it leaves any of `keys` in `UserDefaults.standard` changed. The regression
/// guard for #733: a suite that should be writing to an isolated suite goes red here instead of
/// silently corrupting the wallet on the simulator.
func guardAppDefaults(_ keys: String..., file: StaticString = #filePath, line: UInt = #line) {
let defaults = UserDefaults.standard
let before = keys.map { (key: $0, value: defaults.object(forKey: $0) as? NSObject) }
addTeardownBlock {
for entry in before where defaults.object(forKey: entry.key) as? NSObject != entry.value {
// Deliberately not interpolating the values: these keys hold large encoded blobs, and
// dumping both of them buries the one line that says what to do about it.
XCTFail(
"""
'\(entry.key)' in UserDefaults.standard was modified by this test, which writes to the host app's \
own preferences. Inject an isolated suite with makeIsolatedDefaults(), or snapshot the key with \
snapshotAppDefaults(_:) if the code under test has no seam for it.
""",
file: file,
line: line
)
}
}
}
}
9 changes: 8 additions & 1 deletion BitkitTests/TransferServiceActivityTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ import XCTest
final class TransferServiceActivityTests: XCTestCase {
private let testDbPath = NSTemporaryDirectory()
private let activity = Bitkit.CoreService.shared.activity
private var transferDefaults: UserDefaults!

override func setUp() async throws {
try await super.setUp()
transferDefaults = try makeIsolatedDefaults()
guardAppDefaults("transfers")
_ = try initDb(basePath: testDbPath)
try await Task.sleep(nanoseconds: 1_000_000_000)
}
Expand All @@ -29,7 +32,11 @@ final class TransferServiceActivityTests: XCTestCase {
}

private func makeService() -> Bitkit.TransferService {
Bitkit.TransferService(lightningService: .shared, blocktankService: Bitkit.CoreService.shared.blocktank)
Bitkit.TransferService(
storage: Bitkit.TransferStorage(defaults: transferDefaults),
lightningService: .shared,
blocktankService: Bitkit.CoreService.shared.blocktank
)
}

func testPendingToSpendingActivityDoesNotStoreShortChannelId() async throws {
Expand Down
19 changes: 16 additions & 3 deletions BitkitTests/TransferViewModelHwTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ import XCTest
/// and guards against re-entry. The device orchestration itself is covered by `HwFundingSignerTests`.
@MainActor
final class TransferViewModelHwTests: XCTestCase {
/// A successful mock broadcast reaches `fundPaidOrder`, which persists a transfer record. Without
/// an isolated suite that record lands in the app's own preferences and never settles, because
/// the mock order id is not a real Blocktank order (#733).
private var transferDefaults: UserDefaults!

override func setUpWithError() throws {
try super.setUpWithError()
transferDefaults = try makeIsolatedDefaults()
Comment thread
jvsena42 marked this conversation as resolved.
guardAppDefaults("transfers")
}

private func makeViewModel(
funding: MockHwFunding,
connecting: MockHwConnecting,
Expand All @@ -17,7 +28,8 @@ final class TransferViewModelHwTests: XCTestCase {
hwFunding: funding,
hwConnecting: connecting,
hwFeeRateProvider: { feeRate },
hwTimeouts: timeouts
hwTimeouts: timeouts,
transferDefaults: transferDefaults
)
}

Expand Down Expand Up @@ -223,7 +235,7 @@ final class TransferViewModelHwTests: XCTestCase {
}

func testConfirmWithoutHwCapabilitiesSurfacesGenericError() {
let vm = TransferViewModel() // no signer injected
let vm = TransferViewModel(transferDefaults: transferDefaults) // no signer injected
vm.onTransferToSpendingHwConfirm(order: .mock(), walletId: "trezor:wallet")
if case .generic = vm.hwTransferError {} else {
XCTFail("expected .generic error")
Expand Down Expand Up @@ -255,7 +267,8 @@ final class TransferViewModelHwTests: XCTestCase {
hwFunding: funding,
hwConnecting: MockHwConnecting(),
hwFeeRateProvider: { 2 },
hwAddressProvider: { "bcrt1qtest" }
hwAddressProvider: { "bcrt1qtest" },
transferDefaults: transferDefaults
)

let budget = await vm.hwFundingBudget(walletId: "trezor:wallet")
Expand Down
Loading
Loading