Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
07314ec
test: add a whole-domain defaults snapshot helper
jvsena42 Sep 16, 2026
364de41
test: stop settings and Paykit suites clobbering app preferences
jvsena42 Sep 16, 2026
4d8ca19
test: stop widget suites deleting the user's widget layout
jvsena42 Sep 16, 2026
7bfe614
test: stop address-type and integration suites resetting live settings
jvsena42 Sep 16, 2026
5c03a99
test: isolate the image cache test from the real avatar cache
jvsena42 Sep 16, 2026
b45c229
test: give the bitkit-core DB suites their own temp directory
jvsena42 Sep 16, 2026
f4a9697
test: stop currency view model suites leaking display state
jvsena42 Sep 16, 2026
c65dab8
test: snapshot the app-group widget options the widget suites mirror
jvsena42 Sep 16, 2026
3ace727
fix: let tests substitute the currency service
jvsena42 Sep 16, 2026
eb0c6e3
test: add queue-drain and offline-currency helpers
jvsena42 Sep 16, 2026
fc7be78
fix: drain the core queue before pointing it at a temp database
jvsena42 Sep 16, 2026
855fb5b
fix: stop currency polling writing after the restore
jvsena42 Sep 16, 2026
fdb9f3e
fix: stop paykit tests deleting the receive address
jvsena42 Sep 16, 2026
18ef6fc
fix: stop quickpay suites restoring stale singleton values
jvsena42 Sep 16, 2026
9895b44
fix: keep the address type reset so its tests still test something
jvsena42 Sep 16, 2026
f44d73b
fix: snapshot the currency keys the number pad tests overwrite
jvsena42 Sep 16, 2026
33ce76c
fix: re-point bitkit-core before unlinking a test database
jvsena42 Sep 16, 2026
c134713
fix: drain both module copies of the core queue
jvsena42 Sep 16, 2026
4108031
fix: restore the regtest Blocktank URL after re-pointing bitkit-core
jvsena42 Sep 18, 2026
351519f
fix: restore address type after suites that create a wallet
jvsena42 Sep 22, 2026
64c81c6
fix: restore weather widget cache after number pad tests
jvsena42 Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Bitkit/Services/CurrencyService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
17 changes: 9 additions & 8 deletions BitkitTests/ActivityListTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
jvsena42 marked this conversation as resolved.
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)
Expand All @@ -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 {
Expand Down
19 changes: 18 additions & 1 deletion BitkitTests/AddressTypeIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand All @@ -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()
}

Expand Down
8 changes: 3 additions & 5 deletions BitkitTests/AddressTypeSettingsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
83 changes: 83 additions & 0 deletions BitkitTests/AppStateIsolation.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@testable import Bitkit
import BitkitCore
import Foundation
import XCTest

Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 }
Comment thread
jvsena42 marked this conversation as resolved.
_ = 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.
Expand Down Expand Up @@ -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()
}
}
13 changes: 12 additions & 1 deletion BitkitTests/BlocktankRefundAddressLiveIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
}

Expand Down
8 changes: 3 additions & 5 deletions BitkitTests/BlocktankRefundAddressProviderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion BitkitTests/BlocktankTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,26 @@ 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)
}

override func tearDown() async throws {
try await super.tearDown()
await repointCoreToAppStorage()
try? FileManager.default.removeItem(atPath: testDbPath)
Comment thread
jvsena42 marked this conversation as resolved.
}

func testGetInfo() async throws {
Expand Down
11 changes: 10 additions & 1 deletion BitkitTests/ChannelPurchaseFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 2 additions & 5 deletions BitkitTests/ContactsManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
12 changes: 11 additions & 1 deletion BitkitTests/NewsWidgetTitleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
18 changes: 17 additions & 1 deletion BitkitTests/NumberPadTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading