Skip to content

fix: keep transfer unit tests out of the app's UserDefaults - #756

Merged
jvsena42 merged 6 commits into
masterfrom
fix/733-transfer-test-userdefaults-pollution
Sep 21, 2026
Merged

jvsena42 merged 6 commits into
masterfrom
fix/733-transfer-test-userdefaults-pollution

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

Fixes #733

This PR stops the hardware transfer unit tests writing mock transfer records into the app's own preferences, where they show up as a permanent "TRANSFER IN PROGRESS" banner and an inflated balance.

The tests build a real transfer service, so any test that drives a successful mock broadcast persists a transfer record built from the fixtures. Those records name an order that does not exist at Blocktank, so the channel id never resolves, the transfer never settles, and the banner never clears. The test bundle is hosted in the app, so the preferences it writes to are the app's own.

Most of the seam already existed. The transfer service takes a storage object, but the storage initialiser was private, so the shared instance was the only one that could ever exist and the parameter was unreachable. This replaces it with the injected-defaults initialiser used elsewhere in the app, and lets the view model's convenience initialisers pass one through. Both keep defaulting to the standard store, so nothing shipped changes behaviour.

It also adds a small set of shared helpers for keeping a suite off the app's state, since the repo already solves this three different ways across a dozen files: an isolated defaults suite, a snapshot-and-restore that handles the absent case, and a guard that fails a test which leaves a key changed. The three transfer suites adopt the isolated suite and the guard.

The guard matters more than it first looks. A suite that quietly writes to the app's preferences still passes; the only thing that catches it is diffing real state afterwards. The guard turns that into a red test.

This is the first of three PRs. The second stops the wider test suite destroying the wallet on the simulator, and the third isolates the remaining suites.

No changelog fragment: nothing shipped changes for a user. The shared storage still binds to the standard defaults and every production call site is untouched.

Linked Issues/Tasks

Fixes #733

Design

N/A — no UI changes.

QA Notes

Manual Tests

  • 1. With a wallet on the simulator, note the balance and that no transfer banner shows → run the three transfer suites → launch the app: balance unchanged, still no banner.

Automated Checks

  • Unit tests modified: BitkitTests/TransferViewModelHwTests.swift, BitkitTests/TransferViewModelTests.swift and BitkitTests/TransferServiceActivityTests.swift now take an isolated defaults suite and assert the transfers key is untouched.
  • Test helpers added: BitkitTests/AppStateIsolation.swift collects the isolation patterns already used across the target.
  • Verified locally on iPhone 17, on a simulator that was carrying two of the phantom records: 63 tests pass and the transfers key is byte-identical afterwards. Removing the forwarding again fails exactly the two tests that reach the funding path, which is the guard working.
  • 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 production storage path remains unchanged and the affected tests consistently isolate write-producing transfer flows.

Summary

This PR adds injectable transfer persistence and uses it to prevent hosted transfer tests from writing mock records into the app’s preferences.

  • Replaces the private suite-name storage initializer with a defaults-injection seam while preserving .standard as the production default.
  • Forwards isolated defaults through the transfer view model’s testing convenience initializers.
  • Adds reusable defaults isolation, restoration, and mutation-detection test helpers.
  • Migrates the three affected transfer suites to isolated storage and guards the app’s transfers key.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Test setup] --> B[Unique UserDefaults suite]
  B --> C[TransferStorage]
  C --> D[TransferService]
  D --> E[TransferViewModel]
  E --> F[Mock transfer record]
  F --> B
  G[UserDefaults.standard] --> H[Guard snapshots transfers key]
  H --> I{Changed after test?}
  I -->|No| J[Test passes]
  I -->|Yes| K[Test fails]
Loading

Reviews (1) · Last reviewed commit: "fix: keep transfer unit tests out of the..."

@jvsena42
jvsena42 added this pull request to stack #759 September 16, 2026 14:26
@jvsena42 jvsena42 self-assigned this Sep 16, 2026

@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.

No HIGH/MEDIUM. One LOW observation inline, single-pass (not independently verified).

Checked and clean:

  • Production: TransferStorage keeps the transfers key, the JSON encoding and the .standard default. The old private init(suiteName:) was only ever reached via shared, so nothing moves on upgrade. Every production user still binds to .shared: AppScene's single TransferService, HwWalletManager, BackupService, MigrationsService and ChannelDetailsViewModel. The TransferViewModel convenience inits that now build TransferStorage(defaults:) are only reached from #Preview blocks and tests.
  • TransferStorage has no in-memory cache (getAll() reads defaults every call), so a second instance could not hold a divergent view of pending transfers anyway.
  • guardAppDefaults is not vacuous. It compares via isEqual across nil/value/other, is registered before the body, and runs before the isolated-suite removal (LIFO).
  • The write path in the HW suite (hwSignTaskfundPaidOrdercreateTransferstorage.insert) finishes before the isSigning defer that the tests poll on.
  • All TransferViewModelTests and TransferServiceActivityTests construction sites use the isolated suite.

Comment thread BitkitTests/TransferViewModelHwTests.swift
jvsena42 and others added 5 commits September 17, 2026 07:07
The initializer took a `suiteName` but was `private`, so `shared` was the only
instance that could ever exist and the parameter was unreachable. That left
`TransferService(storage:)` with no usable seam.

Replace it with the injected-defaults initializer used elsewhere in the app
(QuickPaySpendStore, BlocktankRefundAddressProvider, WatchOnlyAccountService).
`shared` still binds to `.standard`, so no shipped behaviour changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
Both convenience initializers hardcoded `TransferService(...)` with `storage:`
omitted, so every caller — tests included — wrote through `TransferStorage.shared`
to `UserDefaults.standard`.

Take `transferDefaults` and forward it into `TransferService(storage:)`. It is a
`UserDefaults` rather than a `TransferStorage` for the same reason the hardware
initializer builds the service internally: `TransferStorage.swift` is compiled
into BitkitTests as well, so passing the type across the module boundary would
need `Bitkit.`-qualification at every call site. Defaults to `.standard`, so
production call sites are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
BitkitTests is hosted in the Bitkit app, so `UserDefaults.standard` is the app's
own preferences and anything a test writes there lands in the developer's wallet.
The repo already solves this three different ways in a dozen files; collect the
best of each into one place:

- `makeIsolatedDefaults()` — a per-test UUID suite, emptied before and removed
  after, from the pattern in PublicPaykitServiceTests.
- `snapshotAppDefaults(_:)` — restore-or-remove, handling the absent case, from
  PubkyAuthURLSchemeTests (the only correct version in the target).
- `guardAppDefaults(_:)` — fails a test that leaves a key changed, so a
  regression of this kind goes red instead of corrupting the simulator.

The guard reports only the key name. These keys hold large encoded blobs and
asserting on equality dumps both of them as hex, burying the one line that says
what to do about it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
Closes #733.

TransferViewModelHwTests built a real TransferService, so any test driving a
successful mock broadcast reached `fundPaidOrder` and persisted a transfer record
into the app's own preferences. Built from the fixtures, those records name
`order123`, which is not a real Blocktank order — `resolveChannelId` fails on
every sync, `isSettled` never flips, and the wallet is left showing a permanent
"TRANSFER IN PROGRESS" banner with a balance inflated by the phantom amount.

Point the three transfer suites at an isolated defaults suite and guard the
`transfers` key so they cannot drift back.

Verified on a simulator that was carrying two of these phantom records: 63 tests
pass and `transfers` is byte-identical afterwards. Removing the forwarding again
fails exactly the two tests that reach `fundPaidOrder`, which is the guard doing
its job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
Two sites still built a view model against the standard defaults: the
no-capabilities error test and the funding budget test. Neither writes today —
the first returns at the signer guard and the second only reads availability — so
this is not a fix for a live leak, but the class comment says every view model in
the suite uses the isolated store, and now they do.

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-transfer-test-userdefaults-pollution branch from f135b41 to de059da Compare September 17, 2026 10:07
ovi-reviewer[bot]

This comment was marked as resolved.

The convenience initialisers built `TransferStorage(defaults: .standard)` when no
suite was passed. That reads and writes the same key as `TransferStorage.shared`
but is a second instance with its own change subject, and `BackupService` only
listens to `.shared` — so a transfer saved through a default-constructed view
model would never mark the wallet backup as required.

Nothing shipped goes through these initialisers today (the app uses the designated
one with an explicit service; the rest are previews and tests), so no user is
affected. But the default should behave as it did before the storage seam was
added: `transferDefaults` is now optional, nil means `.shared`, and only a test
suite gets its own instance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
ovi-reviewer[bot]

This comment was marked as resolved.

@piotr-iohk

Copy link
Copy Markdown
Collaborator

QA reviewed on a721ea0.

Reviewed the TransferStorage UserDefaults seam, TransferViewModel convenience inits (nil → TransferStorage.shared), AppStateIsolation helpers, and the three transfer suites; no device run this round, test-only / no UI.

No findings.

Checked and clean

  • AppScene still constructs TransferService / TransferViewModel on TransferStorage.shared.
  • Convenience inits leave transferDefaults nil → .shared, so BackupService still listens to the shared publisher.
  • TransferStorage still uses the transfers key and JSON encode/decode; shared still defaults to .standard.
  • TransferViewModelHwTests, TransferViewModelTests, and TransferServiceActivityTests all inject makeIsolatedDefaults() and guardAppDefaults("transfers").
  • SavingsSwapTests still uses the convenience init (now .shared); those tests set swap mode and do not hit fundPaidOrder. Remaining suite isolation is test: isolate remaining suites from the app's state #758.

QA LGTM

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Verdict: ✅ Approve


Reaudit: diff 1 file.

Findings:
N/A

Audit:
Skipped - only storage touched (score 2, threshold 3).

QA: sim-1 iPhone 17 Pro simulator on iOS 26.5, Debug build

  1. passed: 100 000 sats in Savings before and after the three transfer suites, no transfer banner across a 96 s poll, and the app's transfers key still absent.

    Test 1:
    With a wallet on the simulator, note the balance and that no transfer banner shows → run the three transfer…
    1.mp4

Tip

Test 1 worth a journey:

  • Create a new wallet: accept the terms, skip the intro, tap New Wallet
  • Tap Receive and copy the on-chain address
  • Fund the address with 100 000 sats on regtest and mine 6 blocks
  • Close the Receive sheet and note the home balance and that no transfer banner shows
  • Read the app's transfers preference key and note it is absent
  • Run only the three transfer test suites against the same simulator
  • Terminate the app and launch it again
  • Watch the home screen for at least 30 seconds: balance unchanged and no transfer banner
  • Read the transfers preference key again and compare it with the before value

Coverage:
QA: 1 of 1 manual tests passed


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

@jvsena42
jvsena42 merged commit f4d4dbe into master Sep 21, 2026
13 checks passed
@jvsena42
jvsena42 deleted the fix/733-transfer-test-userdefaults-pollution branch September 21, 2026 10:28
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