From 07314ecc078c6c242ce43fa9cc7b51e036b59f9d Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 10:43:39 -0300 Subject: [PATCH 01/21] test: add a whole-domain defaults snapshot helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SettingsViewModel.resetToDefaults()` writes ~30 real keys in one call, so the suites that call it need more than a key list — and enumerating one is a maintenance trap as settings are added. Snapshot and restore the whole persistent domain instead, which also removes keys a test added. Documents the ordering that makes this work: XCTest runs `addTeardownBlock` blocks BEFORE `tearDown()`, so a `tearDown` clearing the same key silently defeats the restore. Found the hard way — the suites stayed green while the isolation did nothing, and only a diff of the persistent domain showed it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T --- BitkitTests/AppStateIsolation.swift | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/BitkitTests/AppStateIsolation.swift b/BitkitTests/AppStateIsolation.swift index 754d8a4a3..217ce3fb0 100644 --- a/BitkitTests/AppStateIsolation.swift +++ b/BitkitTests/AppStateIsolation.swift @@ -13,6 +13,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 +27,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 { From 364de41c2d5762b19ffb690ed9c57a31a4e7c26b Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 10:43:39 -0300 Subject: [PATCH 02/21] test: stop settings and Paykit suites clobbering app preferences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Between them these suites reset ~30 real settings keys — including pinEnabled, useBiometrics and requirePinForPayments — drop the user's Blocktank refund address, delete the bolt11 the app published to their homeserver along with its payment hash and expiry, and write fake contacts into the real private-Paykit cache. Snapshot first and let the restore be the cleanup. Where a tearDown cleared the same keys it is removed: teardown blocks run before tearDown(), so it would undo the restore — which is exactly what was still destroying the refund address after the first pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T --- BitkitTests/AddressTypeSettingsTests.swift | 8 +++----- BitkitTests/BlocktankRefundAddressProviderTests.swift | 8 +++----- BitkitTests/ContactsManagerTests.swift | 7 ++----- BitkitTests/PrivatePaykitServiceTests.swift | 8 ++++++++ BitkitTests/PublicPaykitServiceTests.swift | 8 +++----- 5 files changed, 19 insertions(+), 20 deletions(-) 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/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/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/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index dcb92cf69..c0cddf51c 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -3,6 +3,14 @@ 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. Several + // tests already guard this per-test; this covers the ones that don't. + snapshotAppDefaults(PrivatePaykitService.cacheStateKey) + } + func testSupportedReceiverPathsPreserveSupportedOrderWhenMergingDiscoveredServerPath() async { let service = PrivatePaykitService() 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", From 4d8ca19775016ca2145a439128fcb82899f32ae8 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 10:43:39 -0300 Subject: [PATCH 03/21] test: stop widget suites deleting the user's widget layout All three deleted `savedWidgets` in setUp and again in tearDown, then persisted a synthetic set over the top. That key is the home-screen layout. Snapshot it instead, and drop the tearDown deletes so the restore stands. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T --- BitkitTests/NewsWidgetTitleTests.swift | 4 +++- BitkitTests/WidgetsViewModelReorderTests.swift | 8 +++----- BitkitTests/WidgetsViewModelTests.swift | 8 +++----- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/BitkitTests/NewsWidgetTitleTests.swift b/BitkitTests/NewsWidgetTitleTests.swift index 0f9a6bfbf..85819798b 100644 --- a/BitkitTests/NewsWidgetTitleTests.swift +++ b/BitkitTests/NewsWidgetTitleTests.swift @@ -10,12 +10,14 @@ 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") 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/WidgetsViewModelReorderTests.swift b/BitkitTests/WidgetsViewModelReorderTests.swift index 50557ce26..d65153977 100644 --- a/BitkitTests/WidgetsViewModelReorderTests.swift +++ b/BitkitTests/WidgetsViewModelReorderTests.swift @@ -7,14 +7,12 @@ 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") 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..c9a6b7f1f 100644 --- a/BitkitTests/WidgetsViewModelTests.swift +++ b/BitkitTests/WidgetsViewModelTests.swift @@ -5,14 +5,12 @@ 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") UserDefaults.standard.removeObject(forKey: "savedWidgets") } - override func tearDown() { - UserDefaults.standard.removeObject(forKey: "savedWidgets") - super.tearDown() - } - func testSavingWidgetAfterEditingUnsavedOptionsDoesNotDuplicateAfterReload() { let widgets = WidgetsViewModel() widgets.deleteWidget(.suggestions) From 7bfe614dcdd0f427e643804326c08e7db125742c Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 10:43:39 -0300 Subject: [PATCH 04/21] test: stop address-type and integration suites resetting live settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RNMigrationAddressTypeTests deleted selectedAddressType and addressTypesToMonitor in a tearDown with no matching setUp; SamRockSetupRequestTests dropped selectedAddressType with no restore at all; both live integration suites called resetToDefaults() in setUp and tearDown. Their keychain wipes and LDK storage are already namespaced under test — the app's preferences were the part still going through to the real wallet. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T --- BitkitTests/AddressTypeIntegrationTests.swift | 4 +++- .../BlocktankRefundAddressLiveIntegrationTests.swift | 4 +++- BitkitTests/RNMigrationAddressTypeTests.swift | 9 +++++---- BitkitTests/SamRockSetupRequestTests.swift | 1 + 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/BitkitTests/AddressTypeIntegrationTests.swift b/BitkitTests/AddressTypeIntegrationTests.swift index db230b79d..ae8b3c0ee 100644 --- a/BitkitTests/AddressTypeIntegrationTests.swift +++ b/BitkitTests/AddressTypeIntegrationTests.swift @@ -9,6 +9,9 @@ final class AddressTypeIntegrationTests: XCTestCase { override func setUp() async throws { try await super.setUp() + // tearDown calls `resetToDefaults()`, which writes ~30 real keys. The keychain wipe and LDK + // storage are namespaced under test; the app's preferences are not. + snapshotAppDefaultsDomain() Logger.test("Starting address type integration test setup", context: "AddressTypeIntegrationTests") try Keychain.wipeEntireKeychain() } @@ -22,7 +25,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/BlocktankRefundAddressLiveIntegrationTests.swift b/BitkitTests/BlocktankRefundAddressLiveIntegrationTests.swift index ea246d49e..ad2845260 100644 --- a/BitkitTests/BlocktankRefundAddressLiveIntegrationTests.swift +++ b/BitkitTests/BlocktankRefundAddressLiveIntegrationTests.swift @@ -11,6 +11,9 @@ 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() try Bitkit.Keychain.wipeEntireKeychain() Bitkit.SettingsViewModel.shared.resetToDefaults() Bitkit.BlocktankRefundAddressStore().clear() @@ -23,7 +26,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/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) From 5c03a999edb7b29cf6d4a4ba2652f17642012262 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 10:48:59 -0300 Subject: [PATCH 05/21] test: isolate the image cache test from the real avatar cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `testClearRemovesCachedImageFromMemoryAndDisk` used `PubkyImageCache.shared` and called `clear()` twice, wiping the real ~/Library/Caches/pubky-images and forcing every avatar to re-download. `PubkyImageCache` already takes `diskDirectory:`, and the other four tests in the file already inject one — this was the odd one out. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T --- BitkitTests/PubkyImageCacheTests.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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) From b45c229944397f69d883acbf301e54fcf069f8cb Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 10:48:59 -0300 Subject: [PATCH 06/21] test: give the bitkit-core DB suites their own temp directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three called `initDb` against a bare `NSTemporaryDirectory()`, shared with each other, and cleaned up only activity.db — leaving the blocktank.db that `init_db` creates alongside it. BlocktankTests cleaned up nothing at all. Use a per-run UUID directory and remove the whole thing in tearDown. Also drops the `testDbPath` in ChannelPurchaseFlow and UtxoSelectionTests, which never call `initDb` and so never used it. Does not address the underlying race: `CoreService.init` fires `initDb` once synchronously and again asynchronously on ServiceQueue, and the call is last-one-wins, so the async one can still land after a suite's. Closing that needs a way to drain the queue that does not exist yet. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T --- BitkitTests/ActivityListTest.swift | 16 ++++++++-------- BitkitTests/BlocktankTests.swift | 8 +++++++- BitkitTests/ChannelPurchaseFlow.swift | 1 - BitkitTests/TransferServiceActivityTests.swift | 13 ++++++++----- BitkitTests/UtxoSelectionTests.swift | 1 - 5 files changed, 23 insertions(+), 16 deletions(-) diff --git a/BitkitTests/ActivityListTest.swift b/BitkitTests/ActivityListTest.swift index e551e4e44..a4f9cb668 100644 --- a/BitkitTests/ActivityListTest.swift +++ b/BitkitTests/ActivityListTest.swift @@ -3,11 +3,16 @@ 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() + 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 +21,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) - } + // Remove the whole directory: init_db also creates blocktank.db next to activity.db + try? FileManager.default.removeItem(atPath: testDbPath) } func testInsertAndRetrieveLightningActivity() async throws { diff --git a/BitkitTests/BlocktankTests.swift b/BitkitTests/BlocktankTests.swift index 29a37df5d..45925a3e1 100644 --- a/BitkitTests/BlocktankTests.swift +++ b/BitkitTests/BlocktankTests.swift @@ -3,11 +3,16 @@ 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() + 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 +20,7 @@ final class BlocktankTests: XCTestCase { override func tearDown() async throws { try await super.tearDown() + try? FileManager.default.removeItem(atPath: testDbPath) } func testGetInfo() async throws { diff --git a/BitkitTests/ChannelPurchaseFlow.swift b/BitkitTests/ChannelPurchaseFlow.swift index d100e7663..5cd3ed70f 100644 --- a/BitkitTests/ChannelPurchaseFlow.swift +++ b/BitkitTests/ChannelPurchaseFlow.swift @@ -3,7 +3,6 @@ import BitkitCore import XCTest final class PaymentFlowTests: XCTestCase { - let testDbPath = NSTemporaryDirectory() let walletIndex = 0 let blocktank = CoreService.shared.blocktank let lightning = LightningService.shared diff --git a/BitkitTests/TransferServiceActivityTests.swift b/BitkitTests/TransferServiceActivityTests.swift index f44fcc108..be3cf8786 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,15 @@ final class TransferServiceActivityTests: XCTestCase { try await super.setUp() transferDefaults = try makeIsolatedDefaults() guardAppDefaults("transfers") + 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) - } + // Remove the whole directory: init_db also creates blocktank.db next to activity.db + try? FileManager.default.removeItem(atPath: testDbPath) } private func makeService() -> Bitkit.TransferService { diff --git a/BitkitTests/UtxoSelectionTests.swift b/BitkitTests/UtxoSelectionTests.swift index d228de813..f4a9bc90e 100644 --- a/BitkitTests/UtxoSelectionTests.swift +++ b/BitkitTests/UtxoSelectionTests.swift @@ -4,7 +4,6 @@ import LDKNode import XCTest final class UtxoSelectionTests: XCTestCase { - let testDbPath = NSTemporaryDirectory() let walletIndex = 0 let blocktank = CoreService.shared.blocktank let lightning = LightningService.shared From f4a9697f747892eac2770ff066cfed884d592e39 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 10:48:59 -0300 Subject: [PATCH 07/21] test: stop currency view model suites leaking display state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constructing a `CurrencyViewModel` syncs the display currency into the shared group.bitkit suite from its initializer, which the widget extension reads, and these suites toggle `primaryDisplay` in the app's own domain. Injecting a stub `CurrencyService` would not have covered it: the app-group write happens in `init` regardless of which service is passed. Snapshot both instead. The live rate fetch and its repeating Timer are untouched. `CurrencyService` writes `cached_fx_rates` from a detached poll that can land after teardown, so no snapshot can win that race — it needs a way to opt out of polling. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T --- BitkitTests/AppStateIsolation.swift | 17 +++++++++++++++++ BitkitTests/NumberPadTests.swift | 8 ++++++++ BitkitTests/PaymentNavigationHelperTests.swift | 4 ++++ .../QuickPayPaymentCoordinatorTests.swift | 4 ++++ 4 files changed, 33 insertions(+) diff --git a/BitkitTests/AppStateIsolation.swift b/BitkitTests/AppStateIsolation.swift index 217ce3fb0..2df2e4f40 100644 --- a/BitkitTests/AppStateIsolation.swift +++ b/BitkitTests/AppStateIsolation.swift @@ -61,6 +61,23 @@ 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) + } + } + } + } + /// 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. diff --git a/BitkitTests/NumberPadTests.swift b/BitkitTests/NumberPadTests.swift index e3fedcc20..eb6d630aa 100644 --- a/BitkitTests/NumberPadTests.swift +++ b/BitkitTests/NumberPadTests.swift @@ -3,6 +3,14 @@ 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. + snapshotAppGroupDefaults("home_screen_display_currency_code_v1", "home_screen_display_currency_symbol_v1") + snapshotAppDefaults("primaryDisplay", "cached_fx_rates") + } + func testFiatDecimalInput() { let viewModel = AmountInputViewModel() let currency = mockCurrency(primaryDisplay: .fiat) diff --git a/BitkitTests/PaymentNavigationHelperTests.swift b/BitkitTests/PaymentNavigationHelperTests.swift index b304b7f05..0d77356c6 100644 --- a/BitkitTests/PaymentNavigationHelperTests.swift +++ b/BitkitTests/PaymentNavigationHelperTests.swift @@ -18,6 +18,10 @@ final class PaymentNavigationHelperTests: 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. + snapshotAppGroupDefaults("home_screen_display_currency_code_v1", "home_screen_display_currency_symbol_v1") + snapshotAppDefaults("primaryDisplay") originalEnableQuickpay = settings.enableQuickpay originalQuickpayAmount = settings.quickpayAmount originalQuickpayDailyLimitMultiplier = settings.quickpayDailyLimitMultiplier diff --git a/BitkitTests/QuickPayPaymentCoordinatorTests.swift b/BitkitTests/QuickPayPaymentCoordinatorTests.swift index d7ce35e65..1e2e16018 100644 --- a/BitkitTests/QuickPayPaymentCoordinatorTests.swift +++ b/BitkitTests/QuickPayPaymentCoordinatorTests.swift @@ -20,6 +20,10 @@ final class QuickPayPaymentCoordinatorTests: 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. + snapshotAppGroupDefaults("home_screen_display_currency_code_v1", "home_screen_display_currency_symbol_v1") + snapshotAppDefaults("primaryDisplay") originalEnableQuickpay = settings.enableQuickpay originalQuickpayAmount = settings.quickpayAmount originalQuickpayDailyLimitMultiplier = settings.quickpayDailyLimitMultiplier From c65dab8a217cd2e5be9d0abc41c5bc39eefa2dd0 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 11:08:42 -0300 Subject: [PATCH 08/21] test: snapshot the app-group widget options the widget suites mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saving a widget mirrors its options into the shared group.bitkit suite, which the home-screen widget extension reads, so the three suites that build a `WidgetsViewModel` were overwriting the user's real widget configuration there — separately from `savedWidgets` in the app's own domain. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T --- BitkitTests/NewsWidgetTitleTests.swift | 8 ++++++++ BitkitTests/WidgetsViewModelReorderTests.swift | 8 ++++++++ BitkitTests/WidgetsViewModelTests.swift | 8 ++++++++ 3 files changed, 24 insertions(+) diff --git a/BitkitTests/NewsWidgetTitleTests.swift b/BitkitTests/NewsWidgetTitleTests.swift index 85819798b..afa2a9f37 100644 --- a/BitkitTests/NewsWidgetTitleTests.swift +++ b/BitkitTests/NewsWidgetTitleTests.swift @@ -13,6 +13,14 @@ final class NewsWidgetTitleTests: XCTestCase { // `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 } diff --git a/BitkitTests/WidgetsViewModelReorderTests.swift b/BitkitTests/WidgetsViewModelReorderTests.swift index d65153977..ee4e1ecc8 100644 --- a/BitkitTests/WidgetsViewModelReorderTests.swift +++ b/BitkitTests/WidgetsViewModelReorderTests.swift @@ -10,6 +10,14 @@ final class WidgetsViewModelReorderTests: XCTestCase { // `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") } diff --git a/BitkitTests/WidgetsViewModelTests.swift b/BitkitTests/WidgetsViewModelTests.swift index c9a6b7f1f..c27bc2c41 100644 --- a/BitkitTests/WidgetsViewModelTests.swift +++ b/BitkitTests/WidgetsViewModelTests.swift @@ -8,6 +8,14 @@ final class WidgetsViewModelTests: XCTestCase { // `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") } From 3ace7276abbc24a2b0e31ed0ef45ea772cf7f444 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 11:36:22 -0300 Subject: [PATCH 09/21] fix: let tests substitute the currency service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The initialiser was private, so the shared instance was the only one that could exist and the view model's `currencyService` parameter could not actually be used from a test — the same trap the transfer storage had. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T --- Bitkit/Services/CurrencyService.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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? From eb0c6e30d425bec3b270780269b81f9b6f4bae00 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 11:36:22 -0300 Subject: [PATCH 10/21] test: add queue-drain and offline-currency helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both close gaps raised in review on #758. The core service queue drain: `CoreService.init` calls `initDb` against the app's real storage twice, once synchronously and once queued, and the call is last-one-wins. Touching the shared instance then calling `initDb` against a temp directory is not enough on its own, because the queued call can land afterwards and point the globals back. The queue is serial and `ServiceQueue` already has an awaitable overload, so enqueueing a no-op and awaiting it drains what was queued ahead — no production change needed. An earlier commit claimed this needed a drain API that did not exist; it does exist. The offline currency service: `CurrencyViewModel.refresh()` writes the rate cache and mirrors the display currency into the shared app group, but only on success, from an unstructured task that can finish after a teardown block has restored both. A snapshot cannot win that race. Failing the fetch means neither write ever happens. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T --- BitkitTests/AppStateIsolation.swift | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/BitkitTests/AppStateIsolation.swift b/BitkitTests/AppStateIsolation.swift index 2df2e4f40..2bfb41203 100644 --- a/BitkitTests/AppStateIsolation.swift +++ b/BitkitTests/AppStateIsolation.swift @@ -1,3 +1,4 @@ +@testable import Bitkit import Foundation import XCTest @@ -78,6 +79,17 @@ extension XCTestCase { } } + /// 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 { + _ = try? await ServiceQueue.background(.core) { true } + } + /// 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. @@ -118,3 +130,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() + } +} From fc7be78bcd07e41cc5f4c4005e2469b9c58ecb86 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 11:36:22 -0300 Subject: [PATCH 11/21] fix: drain the core queue before pointing it at a temp database Without this the per-run temp directory was decoration: the queued init against the app's real storage could still land last, so activity operations ran against the host app's database. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T --- BitkitTests/ActivityListTest.swift | 1 + BitkitTests/BlocktankTests.swift | 1 + BitkitTests/TransferServiceActivityTests.swift | 1 + 3 files changed, 3 insertions(+) diff --git a/BitkitTests/ActivityListTest.swift b/BitkitTests/ActivityListTest.swift index a4f9cb668..ef2443d98 100644 --- a/BitkitTests/ActivityListTest.swift +++ b/BitkitTests/ActivityListTest.swift @@ -12,6 +12,7 @@ final class ActivityTests: XCTestCase { 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) diff --git a/BitkitTests/BlocktankTests.swift b/BitkitTests/BlocktankTests.swift index 45925a3e1..0c070e56b 100644 --- a/BitkitTests/BlocktankTests.swift +++ b/BitkitTests/BlocktankTests.swift @@ -12,6 +12,7 @@ final class BlocktankTests: XCTestCase { 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) diff --git a/BitkitTests/TransferServiceActivityTests.swift b/BitkitTests/TransferServiceActivityTests.swift index be3cf8786..cfd9a2e79 100644 --- a/BitkitTests/TransferServiceActivityTests.swift +++ b/BitkitTests/TransferServiceActivityTests.swift @@ -23,6 +23,7 @@ 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) From 855fb5b5d3491237b3e305225169ccad3cddca04 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 11:36:22 -0300 Subject: [PATCH 12/21] fix: stop currency polling writing after the restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These suites build a currency view model, which starts a live rate fetch from its initializer. The snapshots added earlier cover the synchronous write in `init`, but not the one in `refresh()`, which can complete after teardown has already put the values back. An earlier commit said injection could not fix this because the app-group write happens in `init` regardless of the service passed. That was true but beside the point: the `init` write is synchronous and already covered — it is the refresh that escapes, and it only writes on success. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T --- BitkitTests/NumberPadTests.swift | 2 +- BitkitTests/PaymentNavigationHelperTests.swift | 16 ++++++++-------- .../QuickPayPaymentCoordinatorTests.swift | 16 ++++++++-------- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/BitkitTests/NumberPadTests.swift b/BitkitTests/NumberPadTests.swift index eb6d630aa..d9334c55c 100644 --- a/BitkitTests/NumberPadTests.swift +++ b/BitkitTests/NumberPadTests.swift @@ -528,7 +528,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 0d77356c6..10eb69378 100644 --- a/BitkitTests/PaymentNavigationHelperTests.swift +++ b/BitkitTests/PaymentNavigationHelperTests.swift @@ -74,7 +74,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( @@ -91,7 +91,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)) } @@ -109,7 +109,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: { [] }) @@ -119,7 +119,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)) @@ -136,7 +136,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: { [] }) @@ -145,7 +145,7 @@ final class PaymentNavigationHelperTests: XCTestCase { XCTAssertEqual( PaymentNavigationHelper.contactPaymentRoute( app: app, - currency: CurrencyViewModel(), + currency: CurrencyViewModel(currencyService: OfflineCurrencyService()), settings: settings, spendStore: spendStore, coordinator: coordinator @@ -213,7 +213,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 @@ -223,7 +223,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/QuickPayPaymentCoordinatorTests.swift b/BitkitTests/QuickPayPaymentCoordinatorTests.swift index 1e2e16018..ad6e8466a 100644 --- a/BitkitTests/QuickPayPaymentCoordinatorTests.swift +++ b/BitkitTests/QuickPayPaymentCoordinatorTests.swift @@ -480,7 +480,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) @@ -491,7 +491,7 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { app: appWithInvoice, wallet: WalletViewModel(), settings: settings, - currency: CurrencyViewModel(), + currency: CurrencyViewModel(currencyService: OfflineCurrencyService()), presentation: presentation( onRoute: { _ in if liveSendShouldNotEmit { @@ -531,7 +531,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) @@ -539,7 +539,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) @@ -622,7 +622,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) @@ -716,7 +716,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 } @@ -728,7 +728,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 } @@ -759,7 +759,7 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { app: appWithInvoice, wallet: wallet ?? WalletViewModel(), settings: settings, - currency: CurrencyViewModel(), + currency: CurrencyViewModel(currencyService: OfflineCurrencyService()), presentation: presentation( onRoute: { route = $0 From fdb9f3eabbca7f89f7946cc4b1604aae1f5e0534 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 11:36:22 -0300 Subject: [PATCH 13/21] fix: stop paykit tests deleting the receive address The suite reaches `PrivatePaykitAddressReservationStore`, which persists its own ledger and removes `onchainAddress` outright. Snapshotting the one cache key I had enumerated by hand missed both, so a full run deleted the user's receive address. Found while verifying the review fixes, once the app was stopped during measurement and stopped writing keys of its own. Every hand-enumerated key list in this branch has missed something; the whole-domain snapshot is the safer default. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T --- BitkitTests/PrivatePaykitServiceTests.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index c0cddf51c..618b4b290 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -6,9 +6,10 @@ 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. Several - // tests already guard this per-test; this covers the ones that don't. - snapshotAppDefaults(PrivatePaykitService.cacheStateKey) + // 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 { From 18ef6fc16fafd80375e28db1099f9cd7f3ac9706 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 13:26:34 -0300 Subject: [PATCH 14/21] fix: stop quickpay suites restoring stale singleton values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both read their "originals" from `SettingsViewModel.shared` and wrote them back in tearDown. Its `@AppStorage` properties do not observe `setPersistentDomain`, so after an earlier suite calls `resetToDefaults()` the singleton keeps serving the defaults — those got captured as the originals and written to disk after the domain restore had already put the user's values back. After a full run `requirePinForPayments`, `enableQuickpay`, `quickpayAmount` and `quickpayDailyLimitMultiplier` were left at their defaults. The first of those is named in the isolation helper's own documentation as a key worth protecting. Snapshot the keys from disk instead and drop the tearDown writes, which ran after the teardown blocks and so could only ever overwrite them. Verified by seeding non-default values first — the previous check passed only because every key already held its default, so a stale restore was invisible. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T --- .../PaymentNavigationHelperTests.swift | 40 +++++++------------ .../QuickPayPaymentCoordinatorTests.swift | 29 ++++++-------- 2 files changed, 27 insertions(+), 42 deletions(-) diff --git a/BitkitTests/PaymentNavigationHelperTests.swift b/BitkitTests/PaymentNavigationHelperTests.swift index 10eb69378..0a2ac2110 100644 --- a/BitkitTests/PaymentNavigationHelperTests.swift +++ b/BitkitTests/PaymentNavigationHelperTests.swift @@ -6,12 +6,6 @@ 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! @@ -21,13 +15,19 @@ final class PaymentNavigationHelperTests: XCTestCase { // 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") - snapshotAppDefaults("primaryDisplay") - originalEnableQuickpay = settings.enableQuickpay - originalQuickpayAmount = settings.quickpayAmount - originalQuickpayDailyLimitMultiplier = settings.quickpayDailyLimitMultiplier - originalPinEnabled = settings.pinEnabled - originalRequirePinForPayments = settings.requirePinForPayments - originalCachedRates = UserDefaults.standard.data(forKey: "cached_fx_rates") + // 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) @@ -44,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 diff --git a/BitkitTests/QuickPayPaymentCoordinatorTests.swift b/BitkitTests/QuickPayPaymentCoordinatorTests.swift index ad6e8466a..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! @@ -23,11 +19,17 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { // 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") - snapshotAppDefaults("primaryDisplay") - originalEnableQuickpay = settings.enableQuickpay - originalQuickpayAmount = settings.quickpayAmount - originalQuickpayDailyLimitMultiplier = settings.quickpayDailyLimitMultiplier - originalCachedRates = UserDefaults.standard.data(forKey: "cached_fx_rates") + // 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" }) @@ -42,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 From 9895b440dd8d271026316a72a29dbc6e5908d2d1 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 13:26:34 -0300 Subject: [PATCH 15/21] fix: keep the address type reset so its tests still test something Removing `resetToDefaults()` from tearDown was wrong. The domain restore replaces it for disk, but not for `SettingsViewModel.shared`, whose cached `selectedAddressType` then carries from one test to the next. With it cached as taproot, `setMonitoring(.taproot, enabled: false)` returns at the "same as the selected type" guard before reaching the balance check, so `testSetMonitoringDisableWithBalanceFails` asserts false and passes without exercising what it is named for. `updateAddressType` returns early for the same reason. Reset in setUp instead, as the settings suite does, and let the domain restore handle disk. Also corrects the comment, which still described the old tearDown. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T --- BitkitTests/AddressTypeIntegrationTests.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/BitkitTests/AddressTypeIntegrationTests.swift b/BitkitTests/AddressTypeIntegrationTests.swift index ae8b3c0ee..c4feac17a 100644 --- a/BitkitTests/AddressTypeIntegrationTests.swift +++ b/BitkitTests/AddressTypeIntegrationTests.swift @@ -9,9 +9,14 @@ final class AddressTypeIntegrationTests: XCTestCase { override func setUp() async throws { try await super.setUp() - // tearDown calls `resetToDefaults()`, which writes ~30 real keys. The keychain wipe and LDK - // storage are namespaced under test; the app's preferences are not. + // `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() + // 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() } From f44d73bdea3d14edb49b2187fc22cb22d67fe85a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 13:26:34 -0300 Subject: [PATCH 16/21] fix: snapshot the currency keys the number pad tests overwrite `mockCurrency` sets the selected currency and the bitcoin display unit, both of which write through to the app's own preferences. A developer on EUR and classic units ended a run on USD and modern, with the app-group currency restored to EUR so the widget and the app disagreed until the next launch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T --- BitkitTests/NumberPadTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/BitkitTests/NumberPadTests.swift b/BitkitTests/NumberPadTests.swift index d9334c55c..2376c1720 100644 --- a/BitkitTests/NumberPadTests.swift +++ b/BitkitTests/NumberPadTests.swift @@ -8,7 +8,9 @@ final class NumberPadTests: XCTestCase { // 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") - snapshotAppDefaults("primaryDisplay", "cached_fx_rates") + // `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() { From 33ce76c1f9b2bf8e6502e93c4be33cae08f6fd64 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 13:26:34 -0300 Subject: [PATCH 17/21] fix: re-point bitkit-core before unlinking a test database MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bitkit-core keeps persistent SQLite connections in globals until the next `initDb`, so removing the directory out from under them leaves the next write failing with `attempt to write a readonly database`. In the integration lane BlocktankTests runs before PaymentFlowTests in one process, and PaymentFlowTests never calls `initDb` — so the directory cleanup added earlier in this branch would have broken it. Re-point the globals at the app's own storage, which is namespaced under test, before removing the directory. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T --- BitkitTests/ActivityListTest.swift | 4 ++++ BitkitTests/BlocktankTests.swift | 4 ++++ BitkitTests/TransferServiceActivityTests.swift | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/BitkitTests/ActivityListTest.swift b/BitkitTests/ActivityListTest.swift index ef2443d98..8cffdbb67 100644 --- a/BitkitTests/ActivityListTest.swift +++ b/BitkitTests/ActivityListTest.swift @@ -23,6 +23,10 @@ final class ActivityTests: XCTestCase { try await super.tearDown() // Remove the whole directory: init_db also creates blocktank.db next to activity.db + // Re-point bitkit-core's global connections before unlinking: they stay open on the old + // path, and a later write through core then fails with `attempt to write a readonly + // database`. The app's own storage is namespaced under test, so this is a safe target. + _ = try? initDb(basePath: Env.bitkitCoreStorage(walletIndex: 0).path) try? FileManager.default.removeItem(atPath: testDbPath) } diff --git a/BitkitTests/BlocktankTests.swift b/BitkitTests/BlocktankTests.swift index 0c070e56b..b8f635af3 100644 --- a/BitkitTests/BlocktankTests.swift +++ b/BitkitTests/BlocktankTests.swift @@ -21,6 +21,10 @@ final class BlocktankTests: XCTestCase { override func tearDown() async throws { try await super.tearDown() + // Re-point bitkit-core's global connections before unlinking: they stay open on the old + // path, and a later write through core then fails with `attempt to write a readonly + // database`. The app's own storage is namespaced under test, so this is a safe target. + _ = try? initDb(basePath: Env.bitkitCoreStorage(walletIndex: 0).path) try? FileManager.default.removeItem(atPath: testDbPath) } diff --git a/BitkitTests/TransferServiceActivityTests.swift b/BitkitTests/TransferServiceActivityTests.swift index cfd9a2e79..d5afd087e 100644 --- a/BitkitTests/TransferServiceActivityTests.swift +++ b/BitkitTests/TransferServiceActivityTests.swift @@ -32,6 +32,10 @@ final class TransferServiceActivityTests: XCTestCase { override func tearDown() async throws { try await super.tearDown() // Remove the whole directory: init_db also creates blocktank.db next to activity.db + // Re-point bitkit-core's global connections before unlinking: they stay open on the old + // path, and a later write through core then fails with `attempt to write a readonly + // database`. The app's own storage is namespaced under test, so this is a safe target. + _ = try? initDb(basePath: Env.bitkitCoreStorage(walletIndex: 0).path) try? FileManager.default.removeItem(atPath: testDbPath) } From c1347138138cbdb8ed9c3cac55b1a6e4d661b58e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 13:26:34 -0300 Subject: [PATCH 18/21] fix: drain both module copies of the core queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ServiceQueue` is compiled into the test target as well as the app, so each has its own `coreQueue`. The drain waited on the test target's copy, while `Bitkit.CoreService.shared` queues its init onto the app module's — so for the suite that reaches core through the qualified name it did not do what its own documentation said. No reachable failure today, since XCTest instantiates test cases up front and that init lands well before the suite runs. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T --- BitkitTests/AppStateIsolation.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/BitkitTests/AppStateIsolation.swift b/BitkitTests/AppStateIsolation.swift index 2bfb41203..91ad3ca5a 100644 --- a/BitkitTests/AppStateIsolation.swift +++ b/BitkitTests/AppStateIsolation.swift @@ -87,7 +87,11 @@ extension XCTestCase { /// 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 } } /// Skips the test unless `BITKIT_DESTRUCTIVE_TESTS=1` is set. For the handful of suites that From 4108031e9e64af5059da73fdc64a0244af244324 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 07:26:57 -0300 Subject: [PATCH 19/21] fix: restore the regtest Blocktank URL after re-pointing bitkit-core init_db rebuilds the Blocktank client with bitkit-core's default URL, which is mainnet (api1.blocktank.to). The tearDown re-point left it there, so every later suite's regtest faucet call returned 404. Co-Authored-By: Claude Opus 5 (1M context) --- BitkitTests/ActivityListTest.swift | 6 +----- BitkitTests/AppStateIsolation.swift | 14 ++++++++++++++ BitkitTests/BlocktankTests.swift | 5 +---- BitkitTests/TransferServiceActivityTests.swift | 6 +----- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/BitkitTests/ActivityListTest.swift b/BitkitTests/ActivityListTest.swift index 8cffdbb67..b3004a3e3 100644 --- a/BitkitTests/ActivityListTest.swift +++ b/BitkitTests/ActivityListTest.swift @@ -22,11 +22,7 @@ final class ActivityTests: XCTestCase { override func tearDown() async throws { try await super.tearDown() - // Remove the whole directory: init_db also creates blocktank.db next to activity.db - // Re-point bitkit-core's global connections before unlinking: they stay open on the old - // path, and a later write through core then fails with `attempt to write a readonly - // database`. The app's own storage is namespaced under test, so this is a safe target. - _ = try? initDb(basePath: Env.bitkitCoreStorage(walletIndex: 0).path) + await repointCoreToAppStorage() try? FileManager.default.removeItem(atPath: testDbPath) } diff --git a/BitkitTests/AppStateIsolation.swift b/BitkitTests/AppStateIsolation.swift index 91ad3ca5a..592cd2959 100644 --- a/BitkitTests/AppStateIsolation.swift +++ b/BitkitTests/AppStateIsolation.swift @@ -1,4 +1,5 @@ @testable import Bitkit +import BitkitCore import Foundation import XCTest @@ -94,6 +95,19 @@ extension XCTestCase { _ = 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. diff --git a/BitkitTests/BlocktankTests.swift b/BitkitTests/BlocktankTests.swift index b8f635af3..ddc71866e 100644 --- a/BitkitTests/BlocktankTests.swift +++ b/BitkitTests/BlocktankTests.swift @@ -21,10 +21,7 @@ final class BlocktankTests: XCTestCase { override func tearDown() async throws { try await super.tearDown() - // Re-point bitkit-core's global connections before unlinking: they stay open on the old - // path, and a later write through core then fails with `attempt to write a readonly - // database`. The app's own storage is namespaced under test, so this is a safe target. - _ = try? initDb(basePath: Env.bitkitCoreStorage(walletIndex: 0).path) + await repointCoreToAppStorage() try? FileManager.default.removeItem(atPath: testDbPath) } diff --git a/BitkitTests/TransferServiceActivityTests.swift b/BitkitTests/TransferServiceActivityTests.swift index d5afd087e..a0c810782 100644 --- a/BitkitTests/TransferServiceActivityTests.swift +++ b/BitkitTests/TransferServiceActivityTests.swift @@ -31,11 +31,7 @@ final class TransferServiceActivityTests: XCTestCase { override func tearDown() async throws { try await super.tearDown() - // Remove the whole directory: init_db also creates blocktank.db next to activity.db - // Re-point bitkit-core's global connections before unlinking: they stay open on the old - // path, and a later write through core then fails with `attempt to write a readonly - // database`. The app's own storage is namespaced under test, so this is a safe target. - _ = try? initDb(basePath: Env.bitkitCoreStorage(walletIndex: 0).path) + await repointCoreToAppStorage() try? FileManager.default.removeItem(atPath: testDbPath) } From 351519f8e0c2785b1facde59a35f2d47bcc48962 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 22 Sep 2026 10:47:52 -0300 Subject: [PATCH 20/21] fix: restore address type after suites that create a wallet Co-Authored-By: Claude Opus 5 (1M context) --- BitkitTests/AddressTypeIntegrationTests.swift | 10 ++++++++++ .../BlocktankRefundAddressLiveIntegrationTests.swift | 9 +++++++++ BitkitTests/ChannelPurchaseFlow.swift | 10 ++++++++++ BitkitTests/UtxoSelectionTests.swift | 10 ++++++++++ 4 files changed, 39 insertions(+) diff --git a/BitkitTests/AddressTypeIntegrationTests.swift b/BitkitTests/AddressTypeIntegrationTests.swift index c4feac17a..bd4665792 100644 --- a/BitkitTests/AddressTypeIntegrationTests.swift +++ b/BitkitTests/AddressTypeIntegrationTests.swift @@ -12,6 +12,16 @@ final class AddressTypeIntegrationTests: XCTestCase { // `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 diff --git a/BitkitTests/BlocktankRefundAddressLiveIntegrationTests.swift b/BitkitTests/BlocktankRefundAddressLiveIntegrationTests.swift index ad2845260..0deb4d7fa 100644 --- a/BitkitTests/BlocktankRefundAddressLiveIntegrationTests.swift +++ b/BitkitTests/BlocktankRefundAddressLiveIntegrationTests.swift @@ -14,6 +14,15 @@ final class BlocktankRefundAddressLiveIntegrationTests: XCTestCase { // `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() diff --git a/BitkitTests/ChannelPurchaseFlow.swift b/BitkitTests/ChannelPurchaseFlow.swift index 5cd3ed70f..75eb18bbc 100644 --- a/BitkitTests/ChannelPurchaseFlow.swift +++ b/BitkitTests/ChannelPurchaseFlow.swift @@ -9,6 +9,16 @@ final class PaymentFlowTests: XCTestCase { 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/UtxoSelectionTests.swift b/BitkitTests/UtxoSelectionTests.swift index f4a9bc90e..76128e20e 100644 --- a/BitkitTests/UtxoSelectionTests.swift +++ b/BitkitTests/UtxoSelectionTests.swift @@ -10,6 +10,16 @@ final class UtxoSelectionTests: XCTestCase { 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 From 64c81c63fe22c7f9a8d1dea0f87a9a84e8aa1fe3 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 22 Sep 2026 12:23:56 -0300 Subject: [PATCH 21/21] fix: restore weather widget cache after number pad tests Co-Authored-By: Claude Opus 5 (1M context) --- BitkitTests/NumberPadTests.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/BitkitTests/NumberPadTests.swift b/BitkitTests/NumberPadTests.swift index 2376c1720..a7187f0a4 100644 --- a/BitkitTests/NumberPadTests.swift +++ b/BitkitTests/NumberPadTests.swift @@ -6,8 +6,14 @@ 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. - snapshotAppGroupDefaults("home_screen_display_currency_code_v1", "home_screen_display_currency_symbol_v1") + // 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")