Skip to content

test: isolate remaining suites from the app's state - #758

Open
jvsena42 wants to merge 21 commits into
masterfrom
fix/733-isolate-remaining-suites
Open

jvsena42 wants to merge 21 commits into
masterfrom
fix/733-isolate-remaining-suites

Conversation

@jvsena42

@jvsena42 jvsena42 commented Sep 16, 2026

Copy link
Copy Markdown
Member

Stack — review in order; each targets the one before it.

  1. fix: keep transfer unit tests out of the app's UserDefaults #756 — keep transfer unit tests out of the app's UserDefaults (fixes fix: hardware transfer unit tests write mock transfers into the app's UserDefaults #733)
  2. fix: stop the test suite wiping the simulator's wallet #757 — stop the test suite wiping the simulator's wallet
  3. test: isolate remaining suites from the app's state #758 — isolate remaining suites from the app's state

This PR finishes isolating the unit test target from the app's own state, covering everything the audit turned up that is not the wallet itself.

Between them these suites were resetting around thirty real settings keys, including whether a PIN is enabled, whether biometrics are on and whether payments require a PIN. They were also dropping the user's Blocktank refund address, deleting the bolt11 the app published to their homeserver along with its payment hash and expiry, writing fake contacts into the real private Paykit cache, deleting the home-screen widget layout, clearing the address type settings, wiping the downloaded avatar cache, and overwriting the widget options the home-screen extension reads from the shared app group.

Most of it is snapshot and restore. Where a suite's code takes injected defaults it gets an isolated suite instead. For the settings resets there is a whole-domain snapshot, because enumerating thirty keys is a maintenance trap as settings are added, and restoring the domain also removes keys a test added.

One detail is load-bearing and cost a round trip to find: XCTest runs teardown blocks before tearDown(), not after. Several suites cleared the same key in tearDown that the snapshot had just restored, so the restore was silently defeated. The suites stayed green the whole time — only diffing the persistent domain showed it. Those teardowns are removed and the ordering is documented at the top of the helpers file.

Two audit findings turned out to be wrong on contact with the code, so the fixes differ from what was expected. The corrupt-cache test does clean up after itself; the real defect in that file was the setUp and tearDown clearing the user's own refund address. And the image cache needed no new production seam at all — it already takes a directory, and four of its five tests already injected one, so only the odd one out needed changing.

The currency view model could not be fixed by injection the way it looked like it could. Its initialiser syncs the display currency into the shared app group regardless of which service is passed, so a stub service stops the network call but not the write that outlives the test. Those suites snapshot the app-group keys instead.

Two things are deliberately left. The rate cache can still be written by a detached poll that lands after teardown — no snapshot can win that race, it needs a way to opt out of polling. And the core service fires its database init once synchronously and again asynchronously, so a suite's own init can still be overridden; closing that needs a way to drain the queue that does not exist yet. The database suites do now get their own directory per run, removed wholesale, which also catches the second database file that none of them were cleaning up.

No changelog fragment: test-only changes.

Linked Issues/Tasks

Follows #757. Found while auditing the suite for #733.

Design

N/A — no UI changes.

QA Notes

Manual Tests

  • 1a. Note your widgets, currency, address type and PIN settings → run the full unit lane → launch the app: all unchanged.
    • 1b. regression: home screen widget: still configured as before.
  • 2. regression: Contacts with a Pubky identity: avatars still present, not re-downloading.

Automated Checks

  • Unit tests modified: fifteen suites across settings, Paykit, widgets, address types, image cache, database and currency now snapshot or isolate what they touch.
  • Test helpers added: whole-domain and app-group snapshots in BitkitTests/AppStateIsolation.swift.
  • Verified locally on iPhone 17 against a live wallet: 1293 tests, 12 skipped, 0 failures, with the app's persistent domain and the shared app group unchanged apart from the rate cache noted above, and nothing removed.
  • CI: standard build and test checks run by the PR bot.

@greptile-apps

greptile-apps Bot commented Sep 16, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

The PR appears safe to merge; the previously reported background currency-write and queued database-initialization gaps are fully addressed.

Summary

This PR isolates unit tests from persistent application and extension state.

  • Snapshots and restores app-domain and shared app-group defaults touched by settings, Paykit, widgets, address-type, and currency tests.
  • Uses isolated image-cache and database directories with complete cleanup.
  • Drains queued core initialization before test database initialization.
  • Injects an offline currency service to prevent background rate refreshes from escaping teardown.
  • Both previous findings are fully addressed in the current code.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    Setup[Test setUp] --> Snapshot[Snapshot app and app-group state]
    Setup --> Drain[Drain queued core initialization]
    Setup --> Temp[Create isolated defaults/cache/database storage]
    Setup --> Offline[Inject offline currency service]
    Snapshot --> Test[Run test]
    Drain --> Test
    Temp --> Test
    Offline --> Test
    Test --> Restore[Teardown blocks restore persistent state]
    Test --> Cleanup[Remove temporary storage]
Loading

Reviews (2) · Last reviewed commit: "fix: stop paykit tests deleting the rece..."

Comment thread BitkitTests/QuickPayPaymentCoordinatorTests.swift Outdated
Comment thread BitkitTests/ActivityListTest.swift
@jvsena42
jvsena42 added this pull request to stack #759 September 16, 2026 14:26
@jvsena42 jvsena42 self-assigned this Sep 16, 2026
jvsena42 added a commit that referenced this pull request Sep 16, 2026
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
@jvsena42
jvsena42 marked this pull request as draft September 16, 2026 14:42
@jvsena42
jvsena42 marked this pull request as ready for review September 16, 2026 14:42
jvsena42 added a commit that referenced this pull request Sep 16, 2026
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
@jvsena42
jvsena42 force-pushed the fix/733-isolate-remaining-suites branch from bc67cc1 to 44a469b Compare September 16, 2026 15:12

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Four MEDIUM (all test-only) and one LOW, each independently verified. Details are inline. Two of them share a root cause: setPersistentDomain restores disk but not SettingsViewModel.shared's @AppStorage cache, so later suites read, and write back, the values a reset left in memory. SettingsViewModel.swift:211 and syncAppStorageFromDefaults() already exist because of this. Making that re-sync internal and calling it from the snapshotAppDefaultsDomain teardown block would close both.

Checked and clean:

  • CurrencyService.init widening: the only production construction is still static let shared, and the file is not in the widget or notification targets.
  • OfflineCurrencyService: refresh() fails, so neither the cached_fx_rates write nor the app-group mirror happens.
  • Host app during unit tests renders Text("Running tests..."), so there are no concurrent app writes for the domain restore to clobber.
  • persistentDomain / setPersistentDomain round-trip plist types and drop keys the test added. The app-group snapshot handles absent keys.
  • The widget suites cover savedWidgets and the four home_screen_*_widget_options_v1 keys. The SamRockSetupRequestTests nested snapshots restore LIFO.
  • In PrivatePaykitServiceTests, the in-test defer restores run before the domain restore. QuickPaySpendStore has no in-memory cache.
  • drainCoreServiceQueue works for ActivityTests and BlocktankTests: .core is serial and the queued initDb blocks on it.

Comment thread BitkitTests/PaymentNavigationHelperTests.swift Outdated
Comment thread BitkitTests/QuickPayPaymentCoordinatorTests.swift Outdated
Comment thread BitkitTests/AddressTypeIntegrationTests.swift Outdated
Comment thread BitkitTests/NumberPadTests.swift Outdated
Comment thread BitkitTests/BlocktankTests.swift
Comment thread BitkitTests/AppStateIsolation.swift
jvsena42 added a commit that referenced this pull request Sep 17, 2026
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
@jvsena42
jvsena42 force-pushed the fix/733-isolate-remaining-suites branch from 144ca24 to 669898a Compare September 17, 2026 10:07

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: ✅ Approve


Review: diff 23 files.

Findings:
N/A

Audit:
Awaits Manual Tests.

Coverage:
QA: Manual Tests await all reviewers to approve, author can run it now via comment: @ovi-reviewer test


Reviewed by grok-4.6-xhigh via gh-pr-review-loop skill
Commands: @ovi-reviewer test · retest · audit (author or owner)

jvsena42 added a commit that referenced this pull request Sep 17, 2026
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
@jvsena42
jvsena42 force-pushed the fix/733-isolate-remaining-suites branch from 669898a to 621d6c2 Compare September 17, 2026 10:55
ovi-reviewer[bot]

This comment was marked as resolved.

jvsena42 added a commit that referenced this pull request Sep 17, 2026
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
@jvsena42
jvsena42 force-pushed the fix/733-isolate-remaining-suites branch from 621d6c2 to a90e7c3 Compare September 17, 2026 11:55
@piotr-iohk

Copy link
Copy Markdown
Collaborator

QA reviewed on a90e7c3.

Reviewed the domain/app-group snapshot helpers, OfflineCurrencyService seam, core-queue drain, and the fifteen suites that stopped writing through to the host app; no device run this round, test-only / no UI.

Run Tests is green. Run Integration Tests is red on PaymentFlowTests / UtxoSelectionTests: Blocktank 404 on /regtest/chain/deposit. Those files only dropped an unused testDbPath; the deposit call is unchanged from bitkit-ios#757, which is green in the same window. Treating that as infra, not this isolation work.

No findings.

Checked and clean

  • Whole-domain snapshot plus the XCTest teardown-block-before-tearDown ordering; suites that used to clear the same key in tearDown dropped that path so the restore stands.
  • OfflineCurrencyService keeps CurrencyViewModel.refresh from writing cached_fx_rates / the app-group mirror after restore; production still only constructs CurrencyService.shared.
  • Widget suites snapshot savedWidgets and all four home_screen_*_widget_options_v1 keys; the image-cache test that used .shared now injects a temp directory like the rest of that file.
  • PrivatePaykitServiceTests snapshots the whole domain, so the reservation store deleting onchainAddress is covered, not only the one cache key.
  • Activity/Blocktank/Transfer DB suites use a unique temp dir, drain both ServiceQueue copies, then re-point initDb before unlink.

QA LGTM

piotr-iohk
piotr-iohk previously approved these changes Sep 17, 2026
ovitrif

This comment was marked as resolved.

@jvsena42
jvsena42 dismissed stale reviews from ovitrif and piotr-iohk via 2bc7b8f September 18, 2026 10:27
Base automatically changed from fix/733-stop-test-suites-wiping-the-wallet to master September 22, 2026 09:40
jvsena42 and others added 19 commits September 22, 2026 06:40
`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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
`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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
`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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
`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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
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) <noreply@anthropic.com>
@jvsena42
jvsena42 force-pushed the fix/733-isolate-remaining-suites branch from 490ff86 to 4108031 Compare September 22, 2026 09:40
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jvsena42

Copy link
Copy Markdown
Member Author

Test 1a, fixed in 351519f.

Root cause. The leak came from PaymentFlowTests (ChannelPurchaseFlow.swift), not from AddressTypeSettingsTests. testchannelPurchaseFlow calls StartupHandler.createNewWallet, which writes selectedAddressType = nativeSegwit and addressTypesToMonitor = nativeSegwit for the new wallet. The suite had no defaults snapshot, so nothing put the values back. UtxoSelectionTests calls the same function and had the same gap. I polled the app's plist during a full lane: the value flipped from taproot to nativeSegwit during PaymentFlowTests and did not change back.

The same lane also left addressSearch_lastUsedReceiveIndex_taproot behind. A running node writes that key when a transaction arrives. The integration suites stop the node in tearDown(), and XCTest runs tearDown() after the teardown blocks, so the node could still write after the domain restore had already run.

Fix (test-only). In PaymentFlowTests and UtxoSelectionTests, setUp now calls snapshotAppDefaultsDomain() and then registers a teardown block that stops the node. AddressTypeIntegrationTests and BlocktankRefundAddressLiveIntegrationTests already had the snapshot and now get the same stop block. Teardown blocks run last-in, first-out, so the node stops before the restore. StartupHandler behaviour is unchanged: a new wallet should reset the address type.

AddressTypeSettingsTests on its own. I could not reproduce the leak. With a Taproot/EUR/requirePinForPayments = true baseline, I ran the suite alone (28 tests, 0 failures) and the app's plist was byte-identical afterwards, both before and after the fix. xcrun simctl spawn <udid> defaults read to.bitkit reads the simulator's global domain, not the app container, and reports every key as absent. That may explain the "absent" reading. Reading <data container>/Library/Preferences/to.bitkit.plist shows the real values.

Verification (iPhone 17, iOS 26, Taproot baseline, diffing plutil -convert xml1 of the container plist):

Run Result Domain diff
Full BitkitTests lane at 4108031 1327 tests, 12 skipped, 0 failures selectedAddressType taproot → nativeSegwit, addressTypesToMonitor nativeSegwit,taprootnativeSegwit, addressSearch_lastUsedReceiveIndex_taproot added
PaymentFlowTests + UtxoSelectionTests at fix 3 tests, 0 failures none
AddressTypeSettingsTests at fix 28 tests, 0 failures none
Full BitkitTests lane at fix 1327 tests, 12 skipped, 0 failures none (plist and defaults export both identical)

After the lane, relaunching the app shows Address Type still set to Taproot:

Address Type still Taproot after the full lane

The weather_widget_latest_v1 app-group note is not covered by this commit.

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Advice: ✅ Approve


Reaudit: diff 4 files.

Findings:
N/A

Audit:
Already done in comment.

Coverage:
QA: journeys and manual tests await all reviewers to approve, author can run it now via comment: @ovi-reviewer test


Reviewed by claude-opus-5-xhigh via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest · audit (author or owner)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jvsena42

Copy link
Copy Markdown
Member Author

Weather widget note: fixed in 64c81c6.

NumberPadTests.mockCurrency sets selectedCurrency = "USD". The setter calls WeatherViewModel.shared.handleCurrencyChange(), which re-formats the cached fee and rewrites weather_widget_latest_v1 and weather_widget_latest_timestamp_v1 in group.bitkit. The suite now adds both keys to its existing snapshotAppGroupDefaults call. The write is synchronous inside the test body, so the teardown restore always runs after it.

Verification on iPhone 17, with the group seeded with the € 0.11 payload and both domains read through defaults export:

Run Tests group.bitkit diff app domain diff
NumberPadTests before fix 28, 0 failures € 0.11$ 0.12, timestamp rewritten n/a
NumberPadTests at fix 28, 0 failures none n/a
Full BitkitTests lane at fix 1327, 12 skipped, 0 failures none none

The on-disk plists of both domains are also byte-identical after the lane.

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Advice: ✅ Approve


Reaudit: diff 1 file.

Findings:
N/A

Audit:
Already done in comment.

Coverage:
QA: journeys and manual tests await all reviewers to approve, author can run it now via comment: @ovi-reviewer test


Reviewed by claude-opus-5-xhigh via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest · audit (author or owner)

@piotr-iohk

Copy link
Copy Markdown
Collaborator

QA reviewed on 64c81c6.

Reviewed the address-type restore on the four wallet-creating suites and the weather-widget keys on NumberPadTests; no device run this round, test-only / no UI.

Run Tests is green. Run Integration Tests is green.

No findings.

Checked and clean

  • PaymentFlowTests and UtxoSelectionTests snapshot the whole defaults domain, then register a node stop that runs before that restore. tearDown still stops the node, which is a no-op once the first stop finished, and wipeStorage does not write preferences.
  • AddressTypeIntegrationTests and BlocktankRefundAddressLiveIntegrationTests use the same stop-before-restore order. createNewWallet is only called from these four suites.
  • NumberPadTests is the only suite that sets selectedCurrency. That setter rewrites weather_widget_latest_v1 and weather_widget_latest_timestamp_v1, and both keys are in the app-group snapshot. The fee-percentile key is not on this path.
  • The isolation helpers and the CurrencyService test seam are unchanged since the last pass. No Android twin.

QA LGTM

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Advice: ✅ Approve


Reaudit: diff 1 file.
No new findings; the rest is in the review.

Audit:
Already done in review.

QA:
Tested on iOS 26.5 simulator (iPhone 17 Pro)
Test 2 already done in review.

Test 1 ✅ passed

evidence
1.mp4

Tip

Worth a journey

Test 1
  • Set local currency to EUR
  • Set address type to Taproot
  • Enable PIN 1234 and require PIN for payments
  • Remove Bitcoin Facts from the Home widget layout
  • Set Bitcoin Price to BTC/EUR over Week
  • Verify the Weather current fee is shown in euros
  • Run the full BitkitTests target
  • Relaunch Bitkit and enter PIN 1234
  • Verify currency, address type, and PIN settings are unchanged
  • Verify the widget layout, Bitcoin Price options, and Weather euro fee are unchanged

Coverage:
Unit tests: 100% - snapshots and restores the two weather-widget app-group keys rewritten when the selected currency changes.
QA: 2 of 2 manual tests passed


Reviewed by gpt-5.6-sol-high via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest · audit (author or owner)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: hardware transfer unit tests write mock transfers into the app's UserDefaults

3 participants