Conversation
|
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
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
bc67cc1 to
44a469b
Compare
jvsena42
left a comment
There was a problem hiding this comment.
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.initwidening: the only production construction is stillstatic let shared, and the file is not in the widget or notification targets.OfflineCurrencyService:refresh()fails, so neither thecached_fx_rateswrite 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/setPersistentDomainround-trip plist types and drop keys the test added. The app-group snapshot handles absent keys.- The widget suites cover
savedWidgetsand the fourhome_screen_*_widget_options_v1keys. TheSamRockSetupRequestTestsnested snapshots restore LIFO. - In
PrivatePaykitServiceTests, the in-testdeferrestores run before the domain restore.QuickPaySpendStorehas no in-memory cache. drainCoreServiceQueueworks forActivityTestsandBlocktankTests:.coreis serial and the queuedinitDbblocks on it.
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
144ca24 to
669898a
Compare
There was a problem hiding this comment.
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)
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
669898a to
621d6c2
Compare
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
621d6c2 to
a90e7c3
Compare
|
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
QA LGTM |
`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>
490ff86 to
4108031
Compare
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Test 1a, fixed in 351519f. Root cause. The leak came from The same lane also left Fix (test-only). In
Verification (iPhone 17, iOS 26, Taproot baseline, diffing
After the lane, relaunching the app shows Address Type still set to Taproot: The |
There was a problem hiding this comment.
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>
|
Weather widget note: fixed in 64c81c6.
Verification on iPhone 17, with the group seeded with the
The on-disk plists of both domains are also byte-identical after the lane. |
There was a problem hiding this comment.
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)
|
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
QA LGTM |
There was a problem hiding this comment.
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
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)










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 intearDownthat 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
regression:home screen widget: still configured as before.regression:Contacts with a Pubky identity: avatars still present, not re-downloading.Automated Checks
BitkitTests/AppStateIsolation.swift.