diff --git a/Bitkit/Services/CurrencyService.swift b/Bitkit/Services/CurrencyService.swift index 0abbb6ae6..d269614a2 100644 --- a/Bitkit/Services/CurrencyService.swift +++ b/Bitkit/Services/CurrencyService.swift @@ -7,7 +7,10 @@ class CurrencyService { private let cache = UserDefaults.standard private let cacheKey = "cached_fx_rates" - private init() {} + /// Internal rather than private so tests can substitute a service that does not reach the + /// network. `CurrencyViewModel.refresh()` writes the rate cache and mirrors the display + /// currency into the app group on success, from a task that can outlive a test. + init() {} func fetchLatestRates() async throws -> [FxRate] { var lastError: Error? diff --git a/BitkitTests/ActivityListTest.swift b/BitkitTests/ActivityListTest.swift index e551e4e44..b3004a3e3 100644 --- a/BitkitTests/ActivityListTest.swift +++ b/BitkitTests/ActivityListTest.swift @@ -3,11 +3,17 @@ import BitkitCore import XCTest final class ActivityTests: XCTestCase { - let testDbPath = NSTemporaryDirectory() + /// Unique per run: `NSTemporaryDirectory()` is shared with every other suite that calls + /// `initDb`, and `init_db` creates blocktank.db alongside activity.db, which none of them + /// cleaned up. + let testDbPath = FileManager.default.temporaryDirectory + .appendingPathComponent("ActivityTests-\(UUID().uuidString)", isDirectory: true).path let service = CoreService.shared.activity override func setUp() async throws { try await super.setUp() + await drainCoreServiceQueue() + try FileManager.default.createDirectory(atPath: testDbPath, withIntermediateDirectories: true) // Initialize the database before each test _ = try initDb(basePath: testDbPath) try await Task.sleep(nanoseconds: 1_000_000_000) @@ -16,13 +22,8 @@ final class ActivityTests: XCTestCase { override func tearDown() async throws { try await super.tearDown() - // Clean up the test database directory - let fileManager = FileManager.default - let dbPath = (testDbPath as NSString).appendingPathComponent("activity.db") - - if fileManager.fileExists(atPath: dbPath) { - try fileManager.removeItem(atPath: dbPath) - } + await repointCoreToAppStorage() + try? FileManager.default.removeItem(atPath: testDbPath) } func testInsertAndRetrieveLightningActivity() async throws { diff --git a/BitkitTests/AddressTypeIntegrationTests.swift b/BitkitTests/AddressTypeIntegrationTests.swift index db230b79d..bd4665792 100644 --- a/BitkitTests/AddressTypeIntegrationTests.swift +++ b/BitkitTests/AddressTypeIntegrationTests.swift @@ -9,6 +9,24 @@ final class AddressTypeIntegrationTests: XCTestCase { override func setUp() async throws { try await super.setUp() + // `resetToDefaults()` writes ~30 real keys. The keychain wipe and LDK storage are namespaced + // under test; the app's preferences are not, so snapshot the domain and restore it afterwards. + snapshotAppDefaultsDomain() + // A running node persists address-search indexes as transactions arrive. Teardown blocks run + // last-in, first-out, so stopping it here lands before the restore above; `tearDown()` runs + // after both and would stop it too late. + addTeardownBlock { [settings] in + let lightning = await MainActor.run { settings.lightningService } + let isRunning = await MainActor.run { lightning.status?.isRunning == true } + if isRunning { + try? await lightning.stop() + } + } + // Reset here rather than in tearDown. The domain restore only fixes disk, and + // `SettingsViewModel.shared`'s `@AppStorage` does not observe it — so without this the cached + // `selectedAddressType` carries between tests and `setMonitoring` returns early at its + // "same as selected" guard, before the balance check the test means to exercise. + await MainActor.run { settings.resetToDefaults() } Logger.test("Starting address type integration test setup", context: "AddressTypeIntegrationTests") try Keychain.wipeEntireKeychain() } @@ -22,7 +40,6 @@ final class AddressTypeIntegrationTests: XCTestCase { try? await lightning.stop() } try? await lightning.wipeStorage(walletIndex: walletIndex) - await MainActor.run { settings.resetToDefaults() } try await super.tearDown() } diff --git a/BitkitTests/AddressTypeSettingsTests.swift b/BitkitTests/AddressTypeSettingsTests.swift index 7b1c7cdb4..8f277d276 100644 --- a/BitkitTests/AddressTypeSettingsTests.swift +++ b/BitkitTests/AddressTypeSettingsTests.swift @@ -11,14 +11,12 @@ final class AddressTypeSettingsTests: XCTestCase { override func setUp() { super.setUp() + // `resetToDefaults()` writes ~30 real keys, including pinEnabled, useBiometrics and + // requirePinForPayments; the tests then write address-type keys directly. + snapshotAppDefaultsDomain() settings.resetToDefaults() } - override func tearDown() { - settings.resetToDefaults() - super.tearDown() - } - // MARK: - SettingsBackupConfig (address type keys) func testSettingsBackupConfigContainsAddressTypeKeys() { diff --git a/BitkitTests/AppStateIsolation.swift b/BitkitTests/AppStateIsolation.swift index 754d8a4a3..592cd2959 100644 --- a/BitkitTests/AppStateIsolation.swift +++ b/BitkitTests/AppStateIsolation.swift @@ -1,3 +1,5 @@ +@testable import Bitkit +import BitkitCore import Foundation import XCTest @@ -13,6 +15,10 @@ import XCTest /// 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. +/// +/// All three register their work with `addTeardownBlock`, which XCTest runs **before** `tearDown()`. +/// So a `tearDown` that also clears the same key wins, and silently defeats the restore — if a suite +/// has one, delete it and let the restore be the cleanup. 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 { @@ -23,9 +29,27 @@ extension XCTestCase { return defaults } + /// Restores the app's entire persistent domain when the test ends. For suites that call + /// `SettingsViewModel.resetToDefaults()`, which writes ~30 real keys in one go — including + /// `pinEnabled`, `useBiometrics` and `requirePinForPayments` — or that otherwise touch more keys + /// than are worth enumerating. Restoring the whole domain also removes keys the test added. + func snapshotAppDefaultsDomain(file: StaticString = #filePath, line: UInt = #line) { + guard let domain = Bundle.main.bundleIdentifier else { + XCTFail("No bundle identifier to snapshot", file: file, line: line) + return + } + let defaults = UserDefaults.standard + let snapshot = defaults.persistentDomain(forName: domain) ?? [:] + addTeardownBlock { defaults.setPersistentDomain(snapshot, forName: domain) } + } + /// 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...) { + snapshotAppDefaults(keys) + } + + func snapshotAppDefaults(_ keys: [String]) { let defaults = UserDefaults.standard let snapshot = keys.map { (key: $0, value: defaults.object(forKey: $0)) } addTeardownBlock { @@ -39,6 +63,51 @@ extension XCTestCase { } } + /// Restores `keys` in the shared `group.bitkit` suite when the test ends. Constructing a + /// `CurrencyViewModel` syncs the display currency into that suite from its initializer, so any + /// suite that builds one writes state the widget extension reads. + func snapshotAppGroupDefaults(_ keys: String...) { + guard let defaults = UserDefaults(suiteName: "group.bitkit") else { return } + 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) + } + } + } + } + + /// Waits for work already queued on the core service queue to finish. + /// + /// `CoreService.init` calls `initDb` against the app's real storage twice — once synchronously and + /// once queued — and `initDb` is last-one-wins. Touching `CoreService.shared` and then calling + /// `initDb` against a temp directory is therefore not enough on its own: the queued call can land + /// afterwards and point the globals back at the app's database. The queue is serial, so enqueueing + /// a no-op and awaiting it drains whatever was queued ahead of it. + func drainCoreServiceQueue() async { + // Both copies: `ServiceQueue` is compiled into the test target as well as the app, so each has + // its own `coreQueue`. `CoreService.shared` reached through `Bitkit.` queues onto the app + // module's, which the test target's drain would not wait on. + _ = try? await ServiceQueue.background(.core) { true } + _ = try? await Bitkit.ServiceQueue.background(.core) { true } + } + + /// Points bitkit-core's global connections back at the app's own storage, for a suite that moved + /// them to a temp directory. Call it before unlinking that directory: the connections stay open on + /// the old path, and a later write through core then fails with `attempt to write a readonly + /// database`. The app's storage is namespaced under test, so this is a safe target. + /// + /// `init_db` also rebuilds the Blocktank client with bitkit-core's default URL, which is mainnet + /// (`api1.blocktank.to`). Without restoring `Env.blocktankClientServer`, every later suite's + /// regtest faucet call 404s. + func repointCoreToAppStorage() async { + _ = try? initDb(basePath: Env.bitkitCoreStorage(walletIndex: 0).path) + try? await updateBlocktankUrl(newUrl: Env.blocktankClientServer) + } + /// Skips the test unless `BITKIT_DESTRUCTIVE_TESTS=1` is set. For the handful of suites that /// deliberately operate on real, un-namespaceable state — the React-Native migration source under /// `~/Documents`, for instance — and so can only run on a simulator that may be erased afterwards. @@ -79,3 +148,17 @@ extension XCTestCase { } } } + +/// A `CurrencyService` that never reaches the network. +/// +/// `CurrencyViewModel` starts polling from its initializer, and `refresh()` writes `cached_fx_rates` +/// and mirrors the display currency into the shared app group — but only on success. Failing the +/// fetch keeps both writes from ever happening, which a snapshot cannot do on its own: the refresh +/// is unstructured and can complete after the restore has already run. +final class OfflineCurrencyService: CurrencyService { + struct Offline: Error {} + + override func fetchLatestRates() async throws -> [FxRate] { + throw Offline() + } +} diff --git a/BitkitTests/BlocktankRefundAddressLiveIntegrationTests.swift b/BitkitTests/BlocktankRefundAddressLiveIntegrationTests.swift index ea246d49e..0deb4d7fa 100644 --- a/BitkitTests/BlocktankRefundAddressLiveIntegrationTests.swift +++ b/BitkitTests/BlocktankRefundAddressLiveIntegrationTests.swift @@ -11,6 +11,18 @@ final class BlocktankRefundAddressLiveIntegrationTests: XCTestCase { override func setUp() async throws { try await super.setUp() + // `resetToDefaults()` in both hooks writes ~30 real keys, and `clear()` drops the user's own + // refund address. The keychain wipe and LDK storage are namespaced under test; these are not. + snapshotAppDefaultsDomain() + // A running node persists address-search indexes as transactions arrive. Teardown blocks run + // last-in, first-out, so stopping it here lands before the restore above; `tearDown()` runs + // after both and would stop it too late. + addTeardownBlock { [lightning] in + let isRunning = await MainActor.run { lightning.status?.isRunning == true } + if isRunning { + try? await lightning.stop() + } + } try Bitkit.Keychain.wipeEntireKeychain() Bitkit.SettingsViewModel.shared.resetToDefaults() Bitkit.BlocktankRefundAddressStore().clear() @@ -23,7 +35,6 @@ final class BlocktankRefundAddressLiveIntegrationTests: XCTestCase { } try? await lightning.wipeStorage(walletIndex: walletIndex) try Bitkit.Keychain.wipeEntireKeychain() - Bitkit.SettingsViewModel.shared.resetToDefaults() try await super.tearDown() } diff --git a/BitkitTests/BlocktankRefundAddressProviderTests.swift b/BitkitTests/BlocktankRefundAddressProviderTests.swift index 9b85995f3..945332c8e 100644 --- a/BitkitTests/BlocktankRefundAddressProviderTests.swift +++ b/BitkitTests/BlocktankRefundAddressProviderTests.swift @@ -38,14 +38,12 @@ final class BlocktankRefundAddressProviderTests: XCTestCase { override func setUp() { super.setUp() + // `clear()` here and in tearDown, `restoreAppCacheData`, `resetToDefaults()` and the corrupt + // cache fixture all write UserDefaults.standard — the host app's own preferences. + snapshotAppDefaultsDomain() BlocktankRefundAddressStore().clear() } - override func tearDown() { - BlocktankRefundAddressStore().clear() - super.tearDown() - } - private func makeProvider( state: State, lookup: ((UInt32) async throws -> BlocktankRefundAddress)? = nil, diff --git a/BitkitTests/BlocktankTests.swift b/BitkitTests/BlocktankTests.swift index 29a37df5d..ddc71866e 100644 --- a/BitkitTests/BlocktankTests.swift +++ b/BitkitTests/BlocktankTests.swift @@ -3,11 +3,17 @@ import BitkitCore import XCTest final class BlocktankTests: XCTestCase { - let testDbPath = NSTemporaryDirectory() + /// Unique per run: `NSTemporaryDirectory()` is shared with every other suite that calls + /// `initDb`, and `init_db` creates blocktank.db alongside activity.db, which none of them + /// cleaned up. + let testDbPath = FileManager.default.temporaryDirectory + .appendingPathComponent("BlocktankTests-\(UUID().uuidString)", isDirectory: true).path let service = CoreService.shared.blocktank override func setUp() async throws { try await super.setUp() + await drainCoreServiceQueue() + try FileManager.default.createDirectory(atPath: testDbPath, withIntermediateDirectories: true) // Initialize the database before each test _ = try initDb(basePath: testDbPath) try await updateBlocktankUrl(newUrl: Env.blocktankClientServer) @@ -15,6 +21,8 @@ final class BlocktankTests: XCTestCase { override func tearDown() async throws { try await super.tearDown() + await repointCoreToAppStorage() + try? FileManager.default.removeItem(atPath: testDbPath) } func testGetInfo() async throws { diff --git a/BitkitTests/ChannelPurchaseFlow.swift b/BitkitTests/ChannelPurchaseFlow.swift index d100e7663..75eb18bbc 100644 --- a/BitkitTests/ChannelPurchaseFlow.swift +++ b/BitkitTests/ChannelPurchaseFlow.swift @@ -3,13 +3,22 @@ import BitkitCore import XCTest final class PaymentFlowTests: XCTestCase { - let testDbPath = NSTemporaryDirectory() let walletIndex = 0 let blocktank = CoreService.shared.blocktank let lightning = LightningService.shared override func setUp() async throws { try await super.setUp() + // `StartupHandler.createNewWallet` resets `selectedAddressType` and `addressTypesToMonitor` for a + // new wallet, and the running node persists address-search indexes. Teardown blocks run + // last-in, first-out, so the node stop registered below runs before this restore. + snapshotAppDefaultsDomain() + addTeardownBlock { [lightning] in + let isRunning = await MainActor.run { lightning.status?.isRunning == true } + if isRunning { + try? await lightning.stop() + } + } Logger.test("Starting payment flow test setup", context: "PaymentFlowTests") // Wipe the keychain before starting tests diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index 27cc195d1..09ae6b7f1 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -6,14 +6,11 @@ import XCTest final class ContactsManagerTests: XCTestCase { override func setUp() { super.setUp() + // tearDown used to delete this outright, so a user who had enabled Paykit UI lost the setting. + snapshotAppDefaults(PaykitFeatureFlags.uiEnabledKey) UserDefaults.standard.set(false, forKey: PaykitFeatureFlags.uiEnabledKey) } - override func tearDown() { - UserDefaults.standard.removeObject(forKey: PaykitFeatureFlags.uiEnabledKey) - super.tearDown() - } - func testPubkyPublicKeyFormatNormalizesPrefixedAndUnprefixedKeys() { let rawKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" let prefixedKey = "pubky\(rawKey)" diff --git a/BitkitTests/NewsWidgetTitleTests.swift b/BitkitTests/NewsWidgetTitleTests.swift index 0f9a6bfbf..afa2a9f37 100644 --- a/BitkitTests/NewsWidgetTitleTests.swift +++ b/BitkitTests/NewsWidgetTitleTests.swift @@ -10,12 +10,22 @@ import XCTest final class NewsWidgetTitleTests: XCTestCase { override func setUp() { super.setUp() + // `savedWidgets` is the user's home-screen layout; these tests delete it and persist + // a synthetic set over the top. + snapshotAppDefaults("savedWidgets") + // Saving a widget also mirrors its options into the shared group.bitkit suite, which the + // home-screen widget extension reads. + snapshotAppGroupDefaults( + "home_screen_news_widget_options_v1", + "home_screen_price_widget_options_v1", + "home_screen_blocks_widget_options_v1", + "home_screen_weather_widget_options_v1" + ) UserDefaults.standard.removeObject(forKey: "savedWidgets") NewsViewModel.shared.widgetData = nil } override func tearDown() { - UserDefaults.standard.removeObject(forKey: "savedWidgets") NewsViewModel.shared.widgetData = nil super.tearDown() } diff --git a/BitkitTests/NumberPadTests.swift b/BitkitTests/NumberPadTests.swift index e3fedcc20..a7187f0a4 100644 --- a/BitkitTests/NumberPadTests.swift +++ b/BitkitTests/NumberPadTests.swift @@ -3,6 +3,22 @@ import XCTest @MainActor final class NumberPadTests: XCTestCase { + override func setUp() { + super.setUp() + // Building a `CurrencyViewModel` syncs the display currency into the shared group.bitkit + // suite from its initializer, which the widget extension reads. Setting `selectedCurrency` + // also re-formats the cached weather widget fee into that currency and rewrites it there. + snapshotAppGroupDefaults( + "home_screen_display_currency_code_v1", + "home_screen_display_currency_symbol_v1", + "weather_widget_latest_v1", + "weather_widget_latest_timestamp_v1" + ) + // `mockCurrency` sets selectedCurrency and displayUnit, both of which write through to the + // app's own preferences — a developer on EUR/classic otherwise ends a run on USD/modern. + snapshotAppDefaults("primaryDisplay", "cached_fx_rates", "selectedCurrency", "bitcoinDisplayUnit") + } + func testFiatDecimalInput() { let viewModel = AmountInputViewModel() let currency = mockCurrency(primaryDisplay: .fiat) @@ -520,7 +536,7 @@ final class NumberPadTests: XCTestCase { // MARK: - Helper Methods private func mockCurrency(primaryDisplay: PrimaryDisplay, displayUnit: BitcoinDisplayUnit = .modern) -> CurrencyViewModel { - let currency = CurrencyViewModel() + let currency = CurrencyViewModel(currencyService: OfflineCurrencyService()) currency.primaryDisplay = primaryDisplay currency.selectedCurrency = "USD" currency.displayUnit = displayUnit diff --git a/BitkitTests/PaymentNavigationHelperTests.swift b/BitkitTests/PaymentNavigationHelperTests.swift index b304b7f05..0a2ac2110 100644 --- a/BitkitTests/PaymentNavigationHelperTests.swift +++ b/BitkitTests/PaymentNavigationHelperTests.swift @@ -6,24 +6,28 @@ import XCTest @MainActor final class PaymentNavigationHelperTests: XCTestCase { private let settings = SettingsViewModel.shared - private var originalEnableQuickpay = false - private var originalQuickpayAmount: Double = 0 - private var originalQuickpayDailyLimitMultiplier: Double = 0 - private var originalPinEnabled = false - private var originalRequirePinForPayments = false - private var originalCachedRates: Data? private var spendDefaults: UserDefaults! private var spendSuiteName: String! private var spendStore: QuickPaySpendStore! override func setUp() { super.setUp() - originalEnableQuickpay = settings.enableQuickpay - originalQuickpayAmount = settings.quickpayAmount - originalQuickpayDailyLimitMultiplier = settings.quickpayDailyLimitMultiplier - originalPinEnabled = settings.pinEnabled - originalRequirePinForPayments = settings.requirePinForPayments - originalCachedRates = UserDefaults.standard.data(forKey: "cached_fx_rates") + // Building a `CurrencyViewModel` syncs the display currency into the shared group.bitkit + // suite from its initializer, which the widget extension reads. + snapshotAppGroupDefaults("home_screen_display_currency_code_v1", "home_screen_display_currency_symbol_v1") + // Snapshot from disk, not from `SettingsViewModel.shared`. Its `@AppStorage` properties do not + // observe `setPersistentDomain`, so after an earlier suite's `resetToDefaults()` the singleton + // still reports the defaults — capturing those as "originals" and writing them back in + // tearDown overwrote the user's values that the domain restore had just put back. + snapshotAppDefaults( + "primaryDisplay", + "cached_fx_rates", + "enableQuickpay", + "quickpayAmount", + "quickpayDailyLimitMultiplier", + "pinEnabled", + "requirePinForPayments" + ) spendSuiteName = "PaymentNavigationHelperTests.\(UUID().uuidString)" spendDefaults = UserDefaults(suiteName: spendSuiteName) @@ -40,18 +44,8 @@ final class PaymentNavigationHelperTests: XCTestCase { } override func tearDown() { - settings.enableQuickpay = originalEnableQuickpay - settings.quickpayAmount = originalQuickpayAmount - settings.quickpayDailyLimitMultiplier = originalQuickpayDailyLimitMultiplier - settings.pinEnabled = originalPinEnabled - settings.requirePinForPayments = originalRequirePinForPayments - - if let originalCachedRates { - UserDefaults.standard.set(originalCachedRates, forKey: "cached_fx_rates") - } else { - UserDefaults.standard.removeObject(forKey: "cached_fx_rates") - } - + // No settings restored here: teardown blocks run before this, so writing the singleton's + // values back would land on top of the snapshot's restore. spendDefaults.removePersistentDomain(forName: spendSuiteName) spendDefaults = nil spendStore = nil @@ -70,7 +64,7 @@ final class PaymentNavigationHelperTests: XCTestCase { } func testSkipsQuickpayWhenDailySpendCapIsExceeded() throws { - let rates = QuickPaySpendRates.live(CurrencyViewModel()) + let rates = QuickPaySpendRates.live(CurrencyViewModel(currencyService: OfflineCurrencyService())) for i in 0 ..< 5 { XCTAssertNotNil( try spendStore.reserveBound( @@ -87,7 +81,7 @@ final class PaymentNavigationHelperTests: XCTestCase { } func testAllowsQuickpayWhenSpendPlusAmountEqualsDailyCap() throws { - let rates = QuickPaySpendRates.live(CurrencyViewModel()) + let rates = QuickPaySpendRates.live(CurrencyViewModel(currencyService: OfflineCurrencyService())) for i in 0 ..< 4 { XCTAssertNotNil(try spendStore.reserveBound(paymentHash: "under\(i)", amountSats: 5000, thresholdUsd: 5, multiplier: 5, rates: rates)) } @@ -105,7 +99,7 @@ final class PaymentNavigationHelperTests: XCTestCase { amountSats: 1000, thresholdUsd: 5, multiplier: 5, - rates: QuickPaySpendRates.live(CurrencyViewModel()) + rates: QuickPaySpendRates.live(CurrencyViewModel(currencyService: OfflineCurrencyService())) ) ) let coordinator = QuickPayPaymentCoordinator(store: spendStore, sendBolt11: { _ in hash }, listRows: { [] }) @@ -115,7 +109,7 @@ final class PaymentNavigationHelperTests: XCTestCase { func testUsesQuickpayWhenHashIsOpenEvenIfDailyCapIsExceeded() throws { settings.quickpayDailyLimitMultiplier = 1 - let rates = QuickPaySpendRates.live(CurrencyViewModel()) + let rates = QuickPaySpendRates.live(CurrencyViewModel(currencyService: OfflineCurrencyService())) let hash = "aabbccdd" XCTAssertNotNil(try spendStore.reserveBound(paymentHash: hash, amountSats: 1000, thresholdUsd: 5, multiplier: 1, rates: rates)) XCTAssertNotNil(try spendStore.reserveBound(paymentHash: "cap0", amountSats: 4000, thresholdUsd: 5, multiplier: 1, rates: rates)) @@ -132,7 +126,7 @@ final class PaymentNavigationHelperTests: XCTestCase { amountSats: 1000, thresholdUsd: 5, multiplier: 5, - rates: QuickPaySpendRates.live(CurrencyViewModel()) + rates: QuickPaySpendRates.live(CurrencyViewModel(currencyService: OfflineCurrencyService())) ) ) let coordinator = QuickPayPaymentCoordinator(store: spendStore, sendBolt11: { _ in hash }, listRows: { [] }) @@ -141,7 +135,7 @@ final class PaymentNavigationHelperTests: XCTestCase { XCTAssertEqual( PaymentNavigationHelper.contactPaymentRoute( app: app, - currency: CurrencyViewModel(), + currency: CurrencyViewModel(currencyService: OfflineCurrencyService()), settings: settings, spendStore: spendStore, coordinator: coordinator @@ -209,7 +203,7 @@ final class PaymentNavigationHelperTests: XCTestCase { private func sendRoute(for app: AppViewModel, coordinator: QuickPayPaymentCoordinator? = nil) -> SendRoute? { PaymentNavigationHelper.appropriateSendRoute( app: app, - currency: CurrencyViewModel(), + currency: CurrencyViewModel(currencyService: OfflineCurrencyService()), settings: settings, spendStore: spendStore, coordinator: coordinator @@ -219,7 +213,7 @@ final class PaymentNavigationHelperTests: XCTestCase { private func contactPaymentRoute(for app: AppViewModel) -> SendRoute? { PaymentNavigationHelper.contactPaymentRoute( app: app, - currency: CurrencyViewModel(), + currency: CurrencyViewModel(currencyService: OfflineCurrencyService()), settings: settings, spendStore: spendStore ) diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index dcb92cf69..618b4b290 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -3,6 +3,15 @@ import Paykit import XCTest final class PrivatePaykitServiceTests: XCTestCase { + override func setUp() { + super.setUp() + // Constructing `PrivatePaykitService` and mutating it writes the real cache state, injecting + // fake contacts and invoices into the user's own and flagging the wallet backup dirty. It also + // reaches `PrivatePaykitAddressReservationStore`, which persists its own ledger and removes the + // receive address outright — more keys than are worth enumerating, so snapshot the domain. + snapshotAppDefaultsDomain() + } + func testSupportedReceiverPathsPreserveSupportedOrderWhenMergingDiscoveredServerPath() async { let service = PrivatePaykitService() diff --git a/BitkitTests/PubkyImageCacheTests.swift b/BitkitTests/PubkyImageCacheTests.swift index d8a61a5ab..2ec3fa4fb 100644 --- a/BitkitTests/PubkyImageCacheTests.swift +++ b/BitkitTests/PubkyImageCacheTests.swift @@ -5,14 +5,18 @@ import XCTest final class PubkyImageCacheTests: XCTestCase { func testClearRemovesCachedImageFromMemoryAndDisk() async throws { - let cache = PubkyImageCache.shared + // `.shared` writes to the real ~/Library/Caches/pubky-images, so clearing it here threw away + // the user's downloaded avatars. Every other test in this file already injects a directory. + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let cache = PubkyImageCache(diskDirectory: directory) let uri = "pubky://test-user/pub/bitkit.to/blobs/avatar.jpg" let image = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1)).image { context in context.cgContext.setFillColor(UIColor.red.cgColor) context.cgContext.fill(CGRect(x: 0, y: 0, width: 1, height: 1)) } let imageData = try XCTUnwrap(image.pngData()) - let diskPath = pubkyImageDiskPath(for: uri) + let diskPath = pubkyImageDiskPath(for: uri, directory: directory) await cache.clear() cache.store(image, data: imageData, for: uri) diff --git a/BitkitTests/PublicPaykitServiceTests.swift b/BitkitTests/PublicPaykitServiceTests.swift index 8e664dec2..b8f8d8fcb 100644 --- a/BitkitTests/PublicPaykitServiceTests.swift +++ b/BitkitTests/PublicPaykitServiceTests.swift @@ -6,14 +6,12 @@ import XCTest final class PublicPaykitServiceTests: XCTestCase { override func setUp() { super.setUp() + // `clearPaykitDefaults()` removes seven live keys, including the bolt11 the app published to + // the user's homeserver along with its payment hash and expiry. + snapshotAppDefaultsDomain() clearPaykitDefaults() } - override func tearDown() { - clearPaykitDefaults() - super.tearDown() - } - func testParseEndpointReadsSpecPayloadObject() { let endpoint = PublicPaykitService.parseEndpoint( methodId: "btc-lightning-bolt11", diff --git a/BitkitTests/QuickPayPaymentCoordinatorTests.swift b/BitkitTests/QuickPayPaymentCoordinatorTests.swift index d7ce35e65..3d08772f2 100644 --- a/BitkitTests/QuickPayPaymentCoordinatorTests.swift +++ b/BitkitTests/QuickPayPaymentCoordinatorTests.swift @@ -6,10 +6,6 @@ import XCTest @MainActor final class QuickPayPaymentCoordinatorTests: XCTestCase { private let settings = SettingsViewModel.shared - private var originalEnableQuickpay = false - private var originalQuickpayAmount: Double = 0 - private var originalQuickpayDailyLimitMultiplier: Double = 0 - private var originalCachedRates: Data? private var defaults: UserDefaults! private var suiteName: String! private var store: QuickPaySpendStore! @@ -20,10 +16,20 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { override func setUp() { super.setUp() - originalEnableQuickpay = settings.enableQuickpay - originalQuickpayAmount = settings.quickpayAmount - originalQuickpayDailyLimitMultiplier = settings.quickpayDailyLimitMultiplier - originalCachedRates = UserDefaults.standard.data(forKey: "cached_fx_rates") + // Building a `CurrencyViewModel` syncs the display currency into the shared group.bitkit + // suite from its initializer, which the widget extension reads. + snapshotAppGroupDefaults("home_screen_display_currency_code_v1", "home_screen_display_currency_symbol_v1") + // Snapshot from disk rather than from `SettingsViewModel.shared`: its `@AppStorage` properties + // do not observe `setPersistentDomain`, so after an earlier suite's `resetToDefaults()` the + // singleton still reports the defaults, and writing those back in tearDown landed on top of + // the snapshot's restore. + snapshotAppDefaults( + "primaryDisplay", + "cached_fx_rates", + "enableQuickpay", + "quickpayAmount", + "quickpayDailyLimitMultiplier" + ) suiteName = "QuickPayPaymentCoordinatorTests.\(UUID().uuidString)" defaults = UserDefaults(suiteName: suiteName) store = QuickPaySpendStore(defaults: defaults, dayKey: { "2026-08-15" }) @@ -38,14 +44,7 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { } override func tearDown() { - settings.enableQuickpay = originalEnableQuickpay - settings.quickpayAmount = originalQuickpayAmount - settings.quickpayDailyLimitMultiplier = originalQuickpayDailyLimitMultiplier - if let originalCachedRates { - UserDefaults.standard.set(originalCachedRates, forKey: "cached_fx_rates") - } else { - UserDefaults.standard.removeObject(forKey: "cached_fx_rates") - } + // No settings restored here: teardown blocks run first, so this would overwrite the restore. defaults.removePersistentDomain(forName: suiteName) defaults = nil store = nil @@ -476,7 +475,7 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { app: appWithInvoice, wallet: WalletViewModel(), settings: settings, - currency: CurrencyViewModel(), + currency: CurrencyViewModel(currencyService: OfflineCurrencyService()), presentation: presentation(onRoute: { _ in }, onConfirm: {}) ) await fulfillment(of: [sendStarted], timeout: 2) @@ -487,7 +486,7 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { app: appWithInvoice, wallet: WalletViewModel(), settings: settings, - currency: CurrencyViewModel(), + currency: CurrencyViewModel(currencyService: OfflineCurrencyService()), presentation: presentation( onRoute: { _ in if liveSendShouldNotEmit { @@ -527,7 +526,7 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { app: appWithInvoice, wallet: WalletViewModel(), settings: settings, - currency: CurrencyViewModel(), + currency: CurrencyViewModel(currencyService: OfflineCurrencyService()), presentation: presentation(onRoute: { _ in }, onConfirm: {}) ) await fulfillment(of: [sendStarted], timeout: 2) @@ -535,7 +534,7 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { app: appWithInvoice, wallet: WalletViewModel(), settings: settings, - currency: CurrencyViewModel(), + currency: CurrencyViewModel(currencyService: OfflineCurrencyService()), presentation: presentation(onRoute: { _ in XCTFail("Re-entry should not emit") }, onConfirm: {}) ) try await Task.sleep(nanoseconds: 150_000_000) @@ -618,7 +617,7 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { app: appWithInvoice, wallet: WalletViewModel(), settings: settings, - currency: CurrencyViewModel(), + currency: CurrencyViewModel(currencyService: OfflineCurrencyService()), presentation: presentation(onRoute: { _ in }, onConfirm: {}) ) await fulfillment(of: [sendStarted], timeout: 2) @@ -712,7 +711,7 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { app: appWithInvoice, wallet: WalletViewModel(), settings: settings, - currency: CurrencyViewModel(), + currency: CurrencyViewModel(currencyService: OfflineCurrencyService()), presentation: presentation( onRoute: { _ in firstSettled.fulfill() }, onConfirm: { didConfirm = true } @@ -724,7 +723,7 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { app: appWithInvoice, wallet: WalletViewModel(), settings: settings, - currency: CurrencyViewModel(), + currency: CurrencyViewModel(currencyService: OfflineCurrencyService()), presentation: presentation( onRoute: { _ in XCTFail("Second pay should not emit a route") }, onConfirm: { didConfirm = true } @@ -755,7 +754,7 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { app: appWithInvoice, wallet: wallet ?? WalletViewModel(), settings: settings, - currency: CurrencyViewModel(), + currency: CurrencyViewModel(currencyService: OfflineCurrencyService()), presentation: presentation( onRoute: { route = $0 diff --git a/BitkitTests/RNMigrationAddressTypeTests.swift b/BitkitTests/RNMigrationAddressTypeTests.swift index e8fc26939..daf307e19 100644 --- a/BitkitTests/RNMigrationAddressTypeTests.swift +++ b/BitkitTests/RNMigrationAddressTypeTests.swift @@ -6,10 +6,11 @@ import XCTest final class RNMigrationAddressTypeTests: XCTestCase { private let migrations = MigrationsService.shared - override func tearDown() { - UserDefaults.standard.removeObject(forKey: "selectedAddressType") - UserDefaults.standard.removeObject(forKey: "addressTypesToMonitor") - super.tearDown() + override func setUp() { + super.setUp() + // `applyRNAddressTypeSettings` writes these for real; tearDown used to delete them outright + // rather than put back whatever the user had. + snapshotAppDefaults("selectedAddressType", "addressTypesToMonitor") } // MARK: - Helper Methods diff --git a/BitkitTests/SamRockSetupRequestTests.swift b/BitkitTests/SamRockSetupRequestTests.swift index 7a8f767b5..9e7343f87 100644 --- a/BitkitTests/SamRockSetupRequestTests.swift +++ b/BitkitTests/SamRockSetupRequestTests.swift @@ -286,6 +286,7 @@ private extension SamRockSetupRequestTests { } func prepareWalletKeychain() throws { + snapshotAppDefaults("selectedAddressType") try? Keychain.delete(key: .bip39Mnemonic(index: Self.testWalletIndex)) try? Keychain.delete(key: .bip39Passphrase(index: Self.testWalletIndex)) try Keychain.saveString(key: .bip39Mnemonic(index: Self.testWalletIndex), str: Self.testMnemonic) diff --git a/BitkitTests/TransferServiceActivityTests.swift b/BitkitTests/TransferServiceActivityTests.swift index f44fcc108..a0c810782 100644 --- a/BitkitTests/TransferServiceActivityTests.swift +++ b/BitkitTests/TransferServiceActivityTests.swift @@ -11,7 +11,11 @@ import XCTest /// App types are `Bitkit.`-qualified because some services are also compiled into the test target, /// so unqualified names would resolve to the duplicate and mismatch `Bitkit.TransferService`. final class TransferServiceActivityTests: XCTestCase { - private let testDbPath = NSTemporaryDirectory() + /// Unique per run: `NSTemporaryDirectory()` is shared with every other suite that calls + /// `initDb`, and `init_db` creates blocktank.db alongside activity.db, which none of them + /// cleaned up. + private let testDbPath = FileManager.default.temporaryDirectory + .appendingPathComponent("TransferServiceActivityTests-\(UUID().uuidString)", isDirectory: true).path private let activity = Bitkit.CoreService.shared.activity private var transferDefaults: UserDefaults! @@ -19,16 +23,16 @@ final class TransferServiceActivityTests: XCTestCase { try await super.setUp() transferDefaults = try makeIsolatedDefaults() guardAppDefaults("transfers") + await drainCoreServiceQueue() + try FileManager.default.createDirectory(atPath: testDbPath, withIntermediateDirectories: true) _ = try initDb(basePath: testDbPath) try await Task.sleep(nanoseconds: 1_000_000_000) } override func tearDown() async throws { try await super.tearDown() - let dbPath = (testDbPath as NSString).appendingPathComponent("activity.db") - if FileManager.default.fileExists(atPath: dbPath) { - try FileManager.default.removeItem(atPath: dbPath) - } + await repointCoreToAppStorage() + try? FileManager.default.removeItem(atPath: testDbPath) } private func makeService() -> Bitkit.TransferService { diff --git a/BitkitTests/UtxoSelectionTests.swift b/BitkitTests/UtxoSelectionTests.swift index d228de813..76128e20e 100644 --- a/BitkitTests/UtxoSelectionTests.swift +++ b/BitkitTests/UtxoSelectionTests.swift @@ -4,13 +4,22 @@ import LDKNode import XCTest final class UtxoSelectionTests: XCTestCase { - let testDbPath = NSTemporaryDirectory() let walletIndex = 0 let blocktank = CoreService.shared.blocktank let lightning = LightningService.shared override func setUp() async throws { try await super.setUp() + // `StartupHandler.createNewWallet` resets `selectedAddressType` and `addressTypesToMonitor` for a + // new wallet, and the running node persists address-search indexes. Teardown blocks run + // last-in, first-out, so the node stop registered below runs before this restore. + snapshotAppDefaultsDomain() + addTeardownBlock { [lightning] in + let isRunning = await MainActor.run { lightning.status?.isRunning == true } + if isRunning { + try? await lightning.stop() + } + } Logger.test("Starting UTXO selection test setup", context: "UtxoSelectionTests") // Wipe the keychain before starting tests diff --git a/BitkitTests/WidgetsViewModelReorderTests.swift b/BitkitTests/WidgetsViewModelReorderTests.swift index 50557ce26..ee4e1ecc8 100644 --- a/BitkitTests/WidgetsViewModelReorderTests.swift +++ b/BitkitTests/WidgetsViewModelReorderTests.swift @@ -7,14 +7,20 @@ import XCTest final class WidgetsViewModelReorderTests: XCTestCase { override func setUp() { super.setUp() + // `savedWidgets` is the user's home-screen layout; these tests delete it and persist + // a synthetic set over the top. + snapshotAppDefaults("savedWidgets") + // Saving a widget also mirrors its options into the shared group.bitkit suite, which the + // home-screen widget extension reads. + snapshotAppGroupDefaults( + "home_screen_news_widget_options_v1", + "home_screen_price_widget_options_v1", + "home_screen_blocks_widget_options_v1", + "home_screen_weather_widget_options_v1" + ) UserDefaults.standard.removeObject(forKey: "savedWidgets") } - override func tearDown() { - UserDefaults.standard.removeObject(forKey: "savedWidgets") - super.tearDown() - } - /// Builds a deterministic three-widget set regardless of the default install set. private func makeViewModel(order: [WidgetType] = [.price, .blocks, .news]) -> WidgetsViewModel { let widgets = WidgetsViewModel() diff --git a/BitkitTests/WidgetsViewModelTests.swift b/BitkitTests/WidgetsViewModelTests.swift index acf5fd235..c27bc2c41 100644 --- a/BitkitTests/WidgetsViewModelTests.swift +++ b/BitkitTests/WidgetsViewModelTests.swift @@ -5,14 +5,20 @@ import XCTest final class WidgetsViewModelTests: XCTestCase { override func setUp() { super.setUp() + // `savedWidgets` is the user's home-screen layout; these tests delete it and persist + // a synthetic set over the top. + snapshotAppDefaults("savedWidgets") + // Saving a widget also mirrors its options into the shared group.bitkit suite, which the + // home-screen widget extension reads. + snapshotAppGroupDefaults( + "home_screen_news_widget_options_v1", + "home_screen_price_widget_options_v1", + "home_screen_blocks_widget_options_v1", + "home_screen_weather_widget_options_v1" + ) UserDefaults.standard.removeObject(forKey: "savedWidgets") } - override func tearDown() { - UserDefaults.standard.removeObject(forKey: "savedWidgets") - super.tearDown() - } - func testSavingWidgetAfterEditingUnsavedOptionsDoesNotDuplicateAfterReload() { let widgets = WidgetsViewModel() widgets.deleteWidget(.suggestions)