Skip to content

fix: keep dashboard buy actions visible#321

Merged
TaprootFreak merged 4 commits into
RealUnitCH:developfrom
joshuakrueger-dfx:fix/always-show-dashboard-buy-actions
May 15, 2026
Merged

fix: keep dashboard buy actions visible#321
TaprootFreak merged 4 commits into
RealUnitCH:developfrom
joshuakrueger-dfx:fix/always-show-dashboard-buy-actions

Conversation

@joshuakrueger-dfx
Copy link
Copy Markdown
Collaborator

@joshuakrueger-dfx joshuakrueger-dfx commented May 13, 2026

Summary

  • always show dashboard buy/sell actions and the empty-state Buy RealUnit button
  • make Buy/Sell/BankAccount/Sell brokerbot requests auth-aware by using lazy DFX auth and a one-time 401 refresh retry
  • remove the now-dead HomeState.isFiatServiceAvailable setup path

Why

The dashboard must not hide fiat entry points behind a transient in-memory auth token. After making the entry points visible, the downstream services also need to avoid sending Authorization: Bearer null or stale tokens. Authenticated requests now call getAuthToken() at first use and retry once with refreshAuthToken() on 401.

Tests

  • flutter analyze
  • flutter test test/packages/service/dfx/dfx_auth_service_test.dart test/packages/service/dfx/real_unit_buy_payment_info_service_test.dart test/screens/buy/buy_page_test.dart test/screens/sell/sell_page_test.dart

@TaprootFreak
Copy link
Copy Markdown
Contributor

Review — deeper look at the implications

I like the user-facing intent (don't hide entry points behind a transient auth flag), but I think the PR ships the symptom fix without the supporting changes the Buy/Sell flow needs to actually handle the now-reachable states. A few things stood out when I traced through the code.

1. isFiatServiceAvailable becomes dead state

After this diff, the only references to HomeState.isFiatServiceAvailable are the writes in HomeBloc._setupFiatService and the field/copyWith plumbing in home_state.dart. There are zero reads anywhere in lib/. We're still paying for the BitBox/HTTP roundtrip in _setupFiatService, emitting state for it, and nobody listens. Either:

  • finish the cleanup (remove the field + _setupFiatService and let auth happen lazily on first use), or
  • keep the flag and use it for something useful (e.g. show a loading indicator on DashboardActions while the initial auth handshake is running).

The current middle ground is the worst of both worlds.

2. Inconsistent auth handling in the DFX layer is now user-visible

The codebase has at least three different patterns for the same problem:

  • DfxKycService.getUser() (dfx_kyc_service.dart:40-49) does a 401 retry via refreshAuthToken() — defensive, correct.
  • RealUnitBuyPaymentInfoService.getPaymentInfo (real_unit_buy_payment_info_service.dart:25), RealUnitSellPaymentInfoService.getPaymentInfo (real_unit_sell_payment_info_service.dart:46), DfxBrokerbotService.getSellPrice / getSellShares (lines 71, 96), and DfxBankAccountService.getBankAccounts (line 18) all read _appStore.sessionCache.authToken directly and send it without any refresh.
  • TransactionHistoryService.fetchPendingTransactions (line 115-116) has an explicit if (authToken == null) return [] early-return — a third pattern again.

The old isFiatServiceAvailable gate masked this inconsistency by hiding the Buy/Sell entry points whenever the token was missing. This PR exposes the inconsistency without fixing the underlying gap.

3. Real race conditions that this PR activates

a) _authToken is not persisted. session_cache.dart:11 keeps it as an in-memory String?, while _signature is persisted to the cache repository. That means every cold start has an auth-empty window equal to one /v1/auth HTTP roundtrip (≈200ms–5s, more on flaky networks).

b) _setupFiatService runs after the dashboard is rendered.
home_bloc.dart:66-73:

emit(state.copyWith(openWallet: wallet, isLoadingWallet: false));  // dashboard renders here
await _setupFiatService(emit);                                     // auth happens after

Before this PR, DashboardActions was gated, so the buttons popped in after the handshake succeeded. After this PR they're visible immediately, before authToken is set. A fast tap → Authorization: Bearer null → 401.

c) No retry on setup failure. _setupFiatService is only triggered from LoadCurrentWalletEvent, LoadWalletEvent, and DebugAuthCompleteEvent. There is no background retry — if the first attempt fails (transient network, BitBox sign cancelled, geo-403), authToken stays null until the next app restart or a SetNetworkModeEvent (which is the only place SettingsBloc calls getNewAuthToken, settings_bloc.dart:40).

4. What the user actually sees on the Buy page with authToken == null

Walking through the code:

  • BuyPaymentInfoCubit._runGetPaymentInfo (buy_payment_info_cubit.dart:65-76) catches the 401 as a generic ExceptionPaymentInfoError.unknown.
  • PaymentInformation (payment_information.dart:41-46) does render an info widget for unknown: „An error occurred while getting the payment information. Please try again later..." — so it's not entirely blank.
  • PaymentAdditionalActionNeededButton (payment_additional_action_needed_button.dart) only handles minAmountNotMet, registrationRequired, kycRequired. For unknown it returns SizedBox.shrink()no Next button, no retry button.
  • The PaymentConverter on top is driven by DfxBrokerbotService, which also sends Bearer null → values stay at 0 / spinner.

End result: the user lands on a screen with a non-functional converter, a generic „try again later" message, and no recovery action. „Try again later" doesn't work without an app restart, because authToken won't refresh on its own. This is a quieter dead end than I first thought, but still a dead end — and it disproportionately hits exactly the users this PR claims to help (restart / reinstall / auth-failure).

5. Sell flow is asymmetric

  • SellPage initial: SellConverterCubit.onSharesChanged('100')DfxBrokerbotService.getSellPrice with Bearer null → 401 propagates as a generic error inside the converter.
  • SellPaymentInfoCubit.validateMinAmount (line 74) is purely local — no auth.
  • SellPaymentInfoCubit.getPaymentInfo (line 32) is only fired when the user taps the Sell button; SellButton's BlocConsumer (sell_button.dart:21-44) at least shows a SnackBar for SellPaymentInfoFailure. Better than Buy, but the two flows now behave differently for the same root cause.
  • SellBankAccountField will hit DfxBankAccountService.getBankAccounts → empty list / error.

6. BitBox path is worse, not equal

The comment in dfx_auth_service.dart:92-95 says the BitBox short-circuit from #304 is no longer needed. Fine — but _setupFiatService still triggers a hardware confirmation on every LoadCurrentWalletEvent (with _signMessageTimeout = 3 min). If the user cancels at the device or the BitBox isn't connected, we land in the catch branch → authToken: null.

Pre-PR: BitBox-user without confirmation simply doesn't see Buy/Sell — implicit „please pair your BitBox first" signal.
Post-PR: BitBox-user taps Buy → lands on the dead-end screen with the generic error.

7. Tests don't cover the change

  • buy_page_test.dart is byte-identical to develop (the PR diff only touches dashboard_page.dart). Listing it as the test for this change is misleading — the test fully mocks the cubits and exercises nothing related to the dashboard gate.
  • There is no dashboard_page_test.dart in test/ at all (find test -name 'dashboard*' is empty), so the newly always-visible actions have no test coverage.
  • grep -rn isFiatServiceAvailable test/ is empty — even home_page_test.dart doesn't exercise the auth-flag branches.

8. Diff hygiene

Roughly 70% of the lines in the diff are pure dart format reflow (trailing commas, arrow-returns, parameter inlining). It looks like the formatter is running with different settings than the rest of the codebase was last formatted with. That hurts review and pollutes git blame on the formatted lines. Please split formatting into its own PR (or rebase with --no-renames and a separate formatting commit).

9. PR description claim vs. reality

the Buy flow can handle auth/KYC/service state itself

Today that's only true for KYC_LEVEL_REQUIRED and REGISTRATION_REQUIRED. For 401 (auth missing), 403 (geo-block without a typed code), and generic network errors, the flow degrades to PaymentInfoError.unknown with no recovery affordance.

after app restart, reinstall, or auth failure

These are exactly the three scenarios where authToken == null at the time of the tap, and where the Buy flow currently has no way to refresh it.

Suggested path forward

I'd love to land the user-facing improvement, but I think it needs at least the first item below to avoid a worse UX than what we have today:

  1. Make the Buy/Sell services auth-aware. Either call await getAuthToken() instead of reading sessionCache.authToken directly, or copy the 401-retry pattern from DfxKycService.getUser() (dfx_kyc_service.dart:40-49) into RealUnitBuyPaymentInfoService, RealUnitSellPaymentInfoService, DfxBrokerbotService, and DfxBankAccountService.
  2. Drop the dead state. If we keep this behavior, remove isFiatServiceAvailable from HomeState and either delete _setupFiatService or convert it into a lazy ensureAuth() triggered from the services in (1).
  3. Render a retry affordance in PaymentAdditionalActionNeededButton / the Sell flow for PaymentInfoError.unknown — at minimum a button that re-runs getPaymentInfo after invalidating the token, so the user has a way out without restarting the app.
  4. Split the formatting diff out so the behavioral change can be reviewed cleanly.
  5. Add a dashboard_page_test.dart covering both balance states with the actions now always visible, and ideally a unit test for the Buy cubit verifying the auth-refresh path.

Happy to pair on (1) — that's the one piece I'd really like to see before this ships, since the whole premise of the PR rests on the Buy flow being able to handle these states on its own.

@joshuakrueger-dfx joshuakrueger-dfx force-pushed the fix/always-show-dashboard-buy-actions branch from c524628 to 5cc061a Compare May 13, 2026 12:05
@joshuakrueger-dfx
Copy link
Copy Markdown
Collaborator Author

Addressed the review in the latest force-push:

  • Dashboard entry points remain always visible, with the formatting-only churn removed from that file.
  • Added authenticated request helpers on DFXAuthService. They call getAuthToken() lazily and retry once with refreshAuthToken() on 401.
  • Updated RealUnitBuyPaymentInfoService, RealUnitSellPaymentInfoService, DfxBrokerbotService sell endpoints, and DfxBankAccountService to use those helpers instead of reading sessionCache.authToken directly.
  • Removed the now-dead HomeState.isFiatServiceAvailable field and HomeBloc._setupFiatService path.
  • Added dfx_auth_service_test coverage for the 401 refresh retry.

Verified with:

  • flutter analyze
  • flutter test test/packages/service/dfx/dfx_auth_service_test.dart test/packages/service/dfx/real_unit_buy_payment_info_service_test.dart test/screens/buy/buy_page_test.dart test/screens/sell/sell_page_test.dart

@joshuakrueger-dfx joshuakrueger-dfx force-pushed the fix/always-show-dashboard-buy-actions branch from 5cc061a to a0ac46b Compare May 13, 2026 12:30
TaprootFreak added a commit that referenced this pull request May 15, 2026
)

## Summary
Adds 38 unit tests across six previously-untested files in
`lib/packages/utils/` and `lib/packages/wallet/`. Each spec lives in the
mirror path under `test/` per the project convention.

| File under test | Test file | Cases |
| --- | --- | --- |
| `lib/packages/utils/fast_hash.dart` |
`test/packages/utils/fast_hash_test.dart` | 5 |
| `lib/packages/utils/jwt_decoder.dart` |
`test/packages/utils/jwt_decoder_test.dart` | 7 |
| `lib/packages/wallet/payment_uri.dart` |
`test/packages/wallet/payment_uri_test.dart` | 4 |
| `lib/packages/wallet/wallet_account.dart` |
`test/packages/wallet/wallet_account_test.dart` | 7 |
| `lib/packages/wallet/wallet.dart` |
`test/packages/wallet/wallet_test.dart` | 11 |
| `lib/packages/wallet/eip7702_signer.dart` |
`test/packages/wallet/eip7702_signer_test.dart` | 4 |

## What each file covers
- **fast_hash:** FNV-1a determinism, distinct inputs differ, ordering
matters, unicode safety, empty-string offset basis.
- **jwt_decoder:** well-formed payload parsing, segment-count errors,
non-map payload rejection, all three valid base64url padding lengths,
illegal length-mod-4 rejection.
- **payment_uri:** empty-amount short form, dotted and comma-locale
amounts, preservation of decimal precision.
- **wallet_account:** BIP-44 derivation path format, deterministic
Hardhat account #0 address from the standard test mnemonic, distinct
indices, signMessage shape + determinism + sensitivity to addressIndex.
- **wallet:** `SoftwareWallet` walletType + primary/current account
identity + `selectAccount` semantics + id/name mutability; `DebugWallet`
sign refusal.
- **eip7702_signer:** hardware-credential refusal (BitBox cannot sign
EIP-7702), software-credential signing, signature determinism,
nonce-sensitivity.

## Why
Stage 2 of the coverage push tracked in the README features matrix
([#322](#322)). These six
files are pure-Dart with no platform dependencies, so they were the
cheapest meaningful coverage win available — no service mocks, no widget
rendering, no merge-conflict risk with the three in-flight test PRs
([#319](#319),
[#320](#320),
[#321](#321)).

## Test plan
- [x] `flutter analyze` clean on all six new test files (locally on
Flutter 3.38.5)
- [x] `flutter test test/packages/utils/ test/packages/wallet/` — 38 /
38 passing
- [ ] CI green on this branch
- [ ] Spot-check: coverage artifact from #323 (once that lands) shows
non-zero coverage on the six files above
TaprootFreak added a commit that referenced this pull request May 15, 2026
## Summary
Replace the bare README with a full project-overview document modelled
on the `zk-coins/app` README. Adds:

1. **Contributing rule:** 100% test coverage on the activated surface is
required to merge into `develop`. Defensive code is exempted via `//
coverage:ignore-*`. Branch protection enforces.
2. **Coverage scope:** `lib/packages/**` + every `cubits/`/`bloc/`
directory under `lib/screens/<feature>/`. Widget files render-tested via
`testWidgets` only.
3. **Coverage infrastructure roadmap:** honest list of what still needs
to land before the rule is enforceable (CI `--coverage` step, lcov
threshold gate, branch protection, build-time feature flags, inline
ignore annotations).
4. **Features matrix:** every user-facing function, its activation
status, the triage decision (`mvp` / `defer` / `planned`), and the tests
that currently cover it.
5. **Triage gaps:** explicit list of `mvp` features still below 100%,
with links to the in-flight PRs
([#319](#319),
[#320](#320),
[#321](#321)) that close
them.
6. **Testing tiers:** ties the matrix to the 4-tier model in
[#314](#314).
7. Original **Getting started** section preserved at the end.

## Why
The repo has no source of truth for "what does this wallet actually do"
or "what is its test-coverage commitment". Tests in `test/` exist but
are unbound to features — a reviewer cannot tell at a glance whether a
feature added in a PR has the required coverage. This document closes
that gap.

The coverage rule deliberately mirrors `zk-coins/app` exactly (same
wording, same scope shape) so that reviewers familiar with one project
transfer their expectations to the other. The 4-tier testing model is
owned by [#314](#314);
this README references it but does not duplicate it.

## What this PR is _not_
- It does **not** wire `flutter test --coverage` into CI. That is item 1
of the roadmap and will land in a follow-up.
- It does **not** add branch protection. That is item 3 of the roadmap.
- It does **not** introduce build-time feature flags. That is item 4 of
the roadmap and is a prerequisite for the rule being realistic across
all features.
- It does **not** add any new tests — the in-flight test PRs
([#319](#319),
[#320](#320),
[#321](#321)) own that
work.

Until the roadmap items land, the rule is aspirational, explicitly noted
as such in the README, and not yet a merge blocker.

## Test plan
- [ ] Visual review of the rendered README on GitHub (tables align, all
links resolve)
- [ ] Confirm every `widget`-tagged row points at an existing
`*_test.dart` file in `test/`
- [ ] Confirm every "in flight" reference points at an open PR
- [ ] Confirm "Coverage scope" wording matches what we want to measure
once the lcov gate lands
TaprootFreak added a commit that referenced this pull request May 15, 2026
## Summary
Stage 4 of the coverage push. Adds bloc_test specs for five screen-level
cubits/blocs that previously only had widget-level coverage. All run in
pure Dart (no widget pumping); mocktail for the service / repository /
SoftwareWallet boundaries.

| Cubit | Test file | Cases |
| --- | --- | --- |
| \`legal_disclaimer_cubit.dart\` |
\`test/screens/legal/cubit/legal_disclaimer_cubit_test.dart\` | 7 |
| \`restore_wallet/cubit/validate_seed/validate_seed_cubit.dart\` |
\`test/screens/restore_wallet/cubit/validate_seed_cubit_test.dart\` | 7
|
|
\`transaction_history/cubits/filter/transaction_history_filter_cubit.dart\`
|
\`test/screens/transaction_history/cubits/transaction_history_filter_cubit_test.dart\`
| 6 |
| \`verify_seed/cubit/verify_seed_cubit.dart\` |
\`test/screens/verify_seed/cubit/verify_seed_cubit_test.dart\` | 6 |
| \`pin/bloc/setup_pin/setup_pin_cubit.dart\` |
\`test/screens/pin/setup_pin_cubit_test.dart\` | 11 |

## What each file covers

- **legal_disclaimer_cubit:** initial step-0 state, nextStep advance,
full walk to last step, onComplete callback fires on last step (no
emit), no-op without callback on last step, previousStep moves back,
no-op at step 0.
- **validate_seed_cubit:** initial uncomplete; \`checkSeedLength\` for
12 valid words / fewer-than-12 / 12 words with an out-of-wordlist token
/ extra inner whitespace tolerated; \`validateSeed\` delegates to
\`WalletService\` for both branches.
- **transaction_history_filter_cubit:** subscribes to the repo stream
with the configured asset + address, default 1-year-back \`startDate\`,
stream pushes populate \`all\` + \`filtered\`, date-window filter
narrows \`filtered\` without touching \`all\`, boundaries are inclusive,
filter re-applies on subsequent stream emissions.
- **verify_seed_cubit:** 4 random ascending word indices within seed
length, debug-mode pre-fill, \`canVerify\` reflects all four slots
filled, \`updateWord\` trims + lowercases + clears \`hasError\`,
\`verify\` returns true and marks the wallet current on match,
\`verify\` returns false / flags \`hasError\` / does NOT mark current on
mismatch.
- **setup_pin_cubit:** initial state, \`addDigit\` appends / 6-digit
cap, \`deleteDigit\` / no-op on empty, create→confirm transition on 6th
digit, matching confirm-pin persists salt + hash and emits
\`isComplete\` (exercises real PBKDF2 via \`compute()\`), mismatching
confirm-pin resets + flags \`mismatch\`, \`reset\` returns to initial,
\`isBiometricAvailable\` + \`enableBiometrics\` passthrough to
\`BiometricService\`.

## Notes
- The \`setup_pin_cubit\` matching-pin test runs a real **600k-iteration
PBKDF2** through \`compute()\`. On the Flutter-test isolate shim this
takes ~12 s on a Mac Studio. The test uses a 30 s timeout to keep CI
honest without flaking.
- \`SecureStorage.setPinSalt\` takes a \`Uint8List\`, which is a
restricted type — a \`Fake\` subclass is illegal, so the mocktail
fallback is registered with a real \`Uint8List(0)\` instance instead.

## Excluded (and why)
- \`pin/bloc/verify_pin/verify_pin_cubit.dart\` — talks to
\`SecureStorage.verifyPin\` which already exercises PBKDF2. Adding a
full happy-path test would compound the slow-test cost. Will be covered
in a follow-up.
- \`restore_wallet/cubit/restore_wallet_cubit.dart\` — coupled to
\`WalletService.restoreWallet\` (already covered in
\`wallet_service_test.dart\`) plus screen navigation; small marginal
value here, fits better in a Tier 1 integration test.
- Buy / sell / KYC cubits — owned by other recent PRs (#312/#319/#321
area) and would invite merge conflicts.
- Bitbox-coupled cubits (\`sell_bitbox\`, \`hardware_connect_bitbox\`) —
belong with Tier 1 \`FakeBitboxCredentials\` (now landed via #319/#320)
in a follow-up.

## Test plan
- [x] \`flutter analyze test/screens/\` clean
- [x] \`flutter test\` on all 5 new files — 37 / 37 passing (~8 s total
locally)
- [ ] CI green
TaprootFreak added a commit that referenced this pull request May 15, 2026
## Summary
Stage 5 of the coverage push. Adds 32 unit tests for the PIN unlock
cubit and three previously-untested DFX backend services. mocktail mocks
for AppStore + repository boundaries; \`http.testing.MockClient\` for
the HTTP surface.

| File under test | Test file | Cases |
| --- | --- | --- |
| \`lib/screens/pin/bloc/verify_pin/verify_pin_cubit.dart\` |
\`test/screens/pin/verify_pin_cubit_test.dart\` | 18 |
| \`lib/packages/service/dfx/dfx_country_service.dart\` |
\`test/packages/service/dfx/dfx_country_service_test.dart\` | 5 |
| \`lib/packages/service/dfx/dfx_blockchain_api_service.dart\` |
\`test/packages/service/dfx/dfx_blockchain_api_service_test.dart\` | 5 |
| \`lib/packages/service/dfx/dfx_faucet_service.dart\` |
\`test/packages/service/dfx/dfx_faucet_service_test.dart\` | 4 |

## What each file covers
- **verify_pin_cubit:** initial state; \`addDigit\` / \`deleteDigit\`
including no-ops at boundaries + while \`VerifyPinTemporarilyLocked\` /
\`VerifyPinLocked\`; correct pin resets lockout and emits
\`VerifyPinSuccess\` (real 600k-iter PBKDF2 via \`compute()\`);
wrong-pin first-attempt path; \`enableLockout: false\` never persists
attempts; 5th wrong attempt triggers a 1-minute temporary lockout;
reaching \`permanentLockoutThreshold\` emits \`VerifyPinLocked\` and
skips the temporary-lockout write; \`onLockExpired\` preserves
\`failedAttempts\`; \`checkBiometricAvailability\` for the in-window /
expired / threshold / success / fail / unavailable branches.
- **dfx_country_service:** list mapping from the DTO + cache (single
fetch even on repeated reads), non-200 throws, case-insensitive symbol
lookup, unknown-symbol error, public \`cachedCountries\` field is
populated post-fetch.
- **dfx_blockchain_api_service:** POST shape (address + Bearer JWT +
chain name + asset id), testnet flips \`blockchain\` to \`'Sepolia'\`,
empty balances list returns \`0.0\`, 201 acceptance in addition to 200,
\`ApiException\` on non-2xx with the JSON body.
- **dfx_faucet_service:** POST + Bearer JWT to \`/v1/faucet\`, 201
acceptance, \`ApiException\` on non-2xx, **documents** the current
behaviour of sending the literal string \`'Bearer null'\` when no auth
token is set — useful as a regression marker if/when the service grows a
guard.

## Notes
- The verify-pin happy-path test exercises a real PBKDF2 (600k
iterations) via \`compute()\`. On the Flutter-test isolate shim this
takes a few seconds; the test uses a 30 s timeout, same approach as in
PR #327's setup_pin test.
- \`MockClient\` lets us stub \`AppStore.httpClient\` without giving the
service its own real \`http.Client\`; the production code is unchanged.

## Excluded (and why)
- \`dfx_kyc_service\`, \`real_unit_registration_service\` — overlap with
the KYC cubits that #319 already covers; want to avoid a stack on top of
that work mid-review.
- \`real_unit_account_service\`, \`transaction_history_service\` — hit
\`appStore.wallet.currentAccount.primaryAddress.address.hexEip55\`,
which needs a real \`SoftwareWallet\` plumbed through the mock. Doable
but more setup than the surface justifies; will be covered in a
follow-up alongside hook tests.
- \`dfx_brokerbot_service\`, \`real_unit_sell_payment_info_service\` —
possibly touched by #321 (dashboard buy/sell auth). Hold to avoid
review-time conflicts.
- \`biometric_service\`, \`price_service\` — same constraints documented
in #326.

## Test plan
- [x] \`flutter analyze\` on the four new files — clean
- [x] \`flutter test\` — 32 / 32 passing locally
- [ ] CI green
TaprootFreak added a commit that referenced this pull request May 15, 2026
…sts) (#329)

## Summary
Stage 6 of the coverage push. Adds 23 unit tests for two more DFX
services and two screen-level cubits whose only previous coverage was at
the widget layer.

| File under test | Test file | Cases |
| --- | --- | --- |
| \`lib/packages/service/dfx/dfx_bank_account_service.dart\` |
\`test/packages/service/dfx/dfx_bank_account_service_test.dart\` | 7 |
| \`lib/packages/service/dfx/dfx_price_service.dart\` |
\`test/packages/service/dfx/dfx_price_service_test.dart\` | 8 |
| \`lib/screens/settings_contact/cubit/settings_contact_cubit.dart\` |
\`test/screens/settings_contact/settings_contact_cubit_test.dart\` | 4 |
|
\`lib/screens/support/cubits/support_tickets/support_tickets_cubit.dart\`
| \`test/screens/support/cubits/support_tickets_cubit_test.dart\` | 4 |

## What each file covers
- **dfx_bank_account_service:** \`getBankAccounts\` GET shape (path, JWT
header, list mapping) + \`ApiException\` on non-2xx;
\`createBankAccount\` POST body, optional-label omission, error path;
\`updateBankAccount\` PUT to \`/v1/bankAccount/{id}\` with only the
provided fields (also pins the \`isDefault → "default"\` wire mapping).
- **dfx_price_service:** \`getPriceOfAsset\` CHF / EUR scaled by 100
(rappen / cents); \`getPriceChart\` maps each entry with asset, scaled
price, and UTC time; \`getChfToEurRate\` returns \`eur / chf\` or
\`0.0\` when chf == 0 (no division by zero); all three paths throw on
non-200.
- **settings_contact_cubit:** Success with \`emailSet: true\` /
\`false\`, Failure on async-throw, and a manual \`init()\` call
recovering from a transient failure.
- **support_tickets_cubit:** Loaded with DTO → \`SupportIssue\` mapping,
Loaded(empty), Error on async-throw, and a manual \`loadTickets()\` call
recovering from a transient failure.

## Notes
- Both cubits fire their work (\`init()\` / \`loadTickets()\`) in the
constructor, so by the time a stream subscriber attaches the synchronous
Loading emit has already passed on the broadcast stream. The tests
therefore assert the **final state** via \`stream.firstWhere\` rather
than the full sequence — a deliberate documented choice in the test file
headers.
- Mocked \`getUser\`/\`getTickets\` use \`thenAnswer((_) async => throw
…)\` instead of \`thenThrow\` so the throw goes through a microtask
boundary (otherwise the cubit's \`catch\` block emits Failure before any
listener can hear it).
- Service tests use \`http.testing.MockClient\` against
\`AppStore.httpClient\`, same pattern as #328.

## Excluded (and why)
- \`dfx_kyc_service\` — large surface (≈ 260 lines), heavy overlap with
the KYC cubit logic already covered by #319; would invite review-time
conflicts.
- \`dfx_brokerbot_service\`, \`real_unit_sell_payment_info_service\` —
likely touched by #321 (dashboard buy/sell auth).
- \`transaction_history_service\`, \`real_unit_account_service\` —
depend on \`AppStore.wallet.currentAccount\` plumbing; will be covered
alongside the wallet-coupled hook tests in a follow-up.
- \`support_chat_cubit\`, \`support_create_ticket_cubit\` — share
\`DfxSupportService\` with this PR; held back to keep the diff focused.

## Test plan
- [x] \`flutter analyze\` on the four new files — clean
- [x] \`flutter test\` — 23 / 23 passing locally
- [ ] CI green
TaprootFreak added a commit that referenced this pull request May 15, 2026
…21 tests) (#331)

## Summary
Stage 8 of the coverage push. Adds 21 unit tests across five
previously-untested cubits.

| Cubit under test | Test file | Cases |
| --- | --- | --- |
| \`create_wallet/bloc/create_wallet_cubit.dart\` |
\`test/screens/create_wallet/create_wallet_cubit_test.dart\` | 4 |
| \`restore_wallet/cubit/restore_wallet/restore_wallet_cubit.dart\` |
\`test/screens/restore_wallet/restore_wallet_cubit_test.dart\` | 3 |
| \`debug_auth/cubit/debug_auth_cubit.dart\` |
\`test/screens/debug_auth/debug_auth_cubit_test.dart\` | 7 |
| \`dashboard/bloc/balance_cubit.dart\` |
\`test/screens/dashboard/balance_cubit_test.dart\` | 4 |
| \`dashboard/bloc/pending_transactions_cubit.dart\` |
\`test/screens/dashboard/pending_transactions_cubit_test.dart\` | 3 |

## What each file covers
- **create_wallet_cubit:** initial hides the seed + has no wallet,
\`createWallet\` stores the new \`SoftwareWallet\` (pinning the
\`'Obi-Wallet-Kenobi'\` default name), \`toggleShowSeed\` flips and
returns to hidden after two toggles, \`toggleShowSeed\` preserves the
wallet field.
- **restore_wallet_cubit:** initial state, \`restoreWallet\`
canonicalises mixed whitespace (\` x y \` → \`x y\`) before delegating
to \`WalletService.restoreWallet\`, the interim \`isLoading=true\` state
is observable.
- **debug_auth_cubit:** seeds \`address\` + \`savedSignature\` from the
service; empty-address fallback when service has none;
\`fetchSignMessage\` success + error; \`authenticate\` success +
uses-state-address + error.
- **balance_cubit:** initial zero-balance state shape \`(chain,
contract, wallet, asset)\`; subscribes to
\`BalanceRepository.watchBalance\` on init; emits each pushed balance
through the stream; \`close()\` cancels the subscription cleanly.
- **pending_transactions_cubit:** initial empty list; emits fetched list
on construction; falls back to empty list (not state-error) when the
service throws.

## Excluded (and why)
- **dashboard_bloc** + transaction-history sub-cubits + portfolio/price
chart cubits — pull in multiple services or larger event-driven flows;
would dwarf this PR.
- **transaction_history_receipt_cubit** / **settings_tax_report_cubit**
— both use \`getTemporaryDirectory()\` + real \`File\` IO; needs
path_provider platform-channel plumbing.
- **sell_***, **sell_bitbox_***, **hardware_connect_bitbox_*** —
Bitbox-coupled or DFX-sell-flow-coupled; held back to avoid review
conflicts with #321 area.
- **transaction_history_filter_cubit** — already covered by #327.

## Test plan
- [x] \`flutter analyze\` on the five new files — clean
- [x] \`flutter test\` — 21 / 21 passing locally
- [ ] CI green
TaprootFreak added a commit that referenced this pull request May 15, 2026
…oc (+22 tests) (#333)

## Summary
Stage 9 of the coverage push. Adds 22 unit tests for the remaining
dashboard cubits/bloc plus the global settings bloc.

| Cubit / Bloc under test | Test file | Cases |
| --- | --- | --- |
| \`dashboard/bloc/price_chart/price_chart_cubit.dart\` |
\`test/screens/dashboard/price_chart_cubit_test.dart\` | 4 |
| \`dashboard/bloc/portfolio_chart/portfolio_chart_cubit.dart\` |
\`test/screens/dashboard/portfolio_chart_cubit_test.dart\` | 5 |
| \`dashboard/bloc/dashboard_transaction_history_cubit.dart\` |
\`test/screens/dashboard/dashboard_transaction_history_cubit_test.dart\`
| 4 |
| \`dashboard/bloc/dashboard_bloc.dart\` |
\`test/screens/dashboard/dashboard_bloc_test.dart\` | 3 |
| \`settings/bloc/settings_bloc.dart\` |
\`test/screens/settings/settings_bloc_test.dart\` | 6 |

## What each file covers
- **price_chart_cubit:** empty-input zero-window state, all-period spots
scaled by 100 + 10% Y-padding, \`selectPeriod\`-same is a no-op (no
emit), \`oneWeek\` filter narrows to recent points.
- **portfolio_chart_cubit:** empty-input zero-window state, all-period
scaling + 6 horizontal-line values, flat-value series spreads via the 5%
floor (no Y-collapse — pins the \`average * 0.05\` lower bound and the
rounding to nice numbers \`{1,2,5,10}\`), \`selectPeriod\`-same no-op,
\`oneWeek\` narrows visibleSpots.
- **dashboard_transaction_history_cubit:** initial empty list,
subscribes to \`watchTransactionsOfAssets\` with limit \`3\`, forwards
every stream emission into state, \`close()\` cancels the subscription.
- **dashboard_bloc:** initial state carries the supplied currency, the
constructor \`refresh()\` populates price + priceChart +
portfolioHistory via the services, \`CurrencyChangedEvent\` updates
state and re-fetches all three datasets in the new currency.
- **settings_bloc:** initial state reads through the repo,
\`SetLanguageEvent\` writes \`'de'\` + emits, \`SetCurrencyEvent\`
writes \`'EUR'\` + emits, \`SetNetworkModeEvent\` writes + calls
\`getNewAuthToken\` + emits, \`ToggleHideAmountEvent\` flips both ways,
a single toggle sets \`hideAmounts=true\`.

## Notes
- The \`dashboard_bloc\` CurrencyChangedEvent test attaches a listener
BEFORE adding the event because \`Bloc.stream\` is broadcast (no replay)
and the event-driven re-fetch can complete before a follow-on
\`firstWhere\` subscribes — same constraint we've now hit a few times in
this push.

## Excluded (and why)
- \`dashboard_bloc\` refresh after a service throw — non-trivial to test
cleanly because the bloc lets the exception propagate out of the handler
(which then surfaces as an unhandled bloc error in tests). Leaving as a
follow-up.
- \`transaction_history_receipt_cubit\`,
\`transaction_history_multi_receipt_cubit\`,
\`settings_tax_report_cubit\` — all use \`getTemporaryDirectory()\` +
real \`File\` IO.
- Buy / sell / sell_bitbox / hardware_connect_bitbox cubits — touched by
the still-open PRs (#321 dashboard buy actions, #332 bitbox sign
hardening); held back to avoid review conflicts.
- \`settings_user_data_cubit\` — coordinates 3 services + country-lookup
branches; deserves its own focused PR.

## Test plan
- [x] \`flutter analyze\` on all five new files — clean
- [x] \`flutter test\` — 22 / 22 passing locally
- [ ] CI green
Resolves the add/add conflict in test/packages/service/dfx/dfx_auth_service_test.dart:
develop now contains the Phase 0 signature-flow tests from RealUnitCH#319 and this branch
adds the authenticated-request retry-on-401 tests. Combined both test suites
into one file with disambiguated helpers (_SignatureTestAuthService vs
_RetryTestAuthService), grouped under "$DFXAuthService getSignature /
getAuthToken" and "$DFXAuthService authenticated requests" respectively.

flutter analyze clean; flutter test 321 / 321 passing.
TaprootFreak added a commit that referenced this pull request May 15, 2026
Adds full coverage for the sell-side payment-info cubit (no PR conflict
with #321 — the cubit source file is not in its diff, only the
underlying service is).

- initial state
- getPaymentInfo (5): happy with isBitbox=false for SoftwareWallet,
  isBitbox=true for BitboxWallet, KycLevelRequiredException,
  RegistrationRequiredException, generic exception
- validateMinAmount (6): CHF below 10 emits MinAmountNotMet; above
  leaves Initial; EUR scaled by getChfToEurRate (ceil); prior
  MinAmountNotMet is cleared back to Initial when amount rises;
  comma decimal normalised to dot; empty string treated as 0
The Phase 1 service-test sweep (RealUnitCH#326 + follow-ups) ships
`dfx_bank_account_service_test.dart` against the old AppStore-based
service. After this PR's refactor the service inherits from
DFXAuthService and routes every request through
`authenticatedGet/Put/Post`, which calls `getAuthToken()` and, when no
JWT is cached, walks the full `loadSignature` → `getAuthResponse`
sign-in path.

Seeding `sessionCache.setAuthToken('test-jwt')` in the shared `setUp`
short-circuits that path so the existing tests continue to assert only
the wire behaviour they were written to assert (verb, path, headers,
body, ApiException on non-2xx). The token assertions remain accurate
because the only test that pins the bearer value sets its own JWT
explicitly inside the test body.

`flutter test` 490 / 490 passing locally; `flutter analyze` clean.
TaprootFreak added a commit that referenced this pull request May 15, 2026
## Summary
Stage 10 of the coverage push. Adds 21 unit tests for the two remaining
easy-to-mock DFX backend services.

| Service under test | Test file | Cases |
| --- | --- | --- |
| \`lib/packages/service/dfx/dfx_support_service.dart\` |
\`test/packages/service/dfx/dfx_support_service_test.dart\` | 9 |
| \`lib/packages/service/dfx/dfx_brokerbot_service.dart\` |
\`test/packages/service/dfx/dfx_brokerbot_service_test.dart\` | 12 |

## What each file covers
- **dfx_support_service:** \`getTickets\` GET shape (path, Bearer JWT) +
ApiException on non-200; \`getTicket\` by uid + ApiException;
\`createTicket\` POST body (type/reason/name + optional message) + omits
message when null + requires status \`201\` (200 is rejected as
ApiException); \`sendMessage\` POST shape + ApiException on non-201.
\`getAuthToken\` is short-circuited by pre-populating
\`sessionCache.authToken\` so the signing flow stays out of these unit
tests.
- **dfx_brokerbot_service:** \`getBuyPrice\` GET + currency-code query +
invalid-input guards (non-numeric, zero, negative, non-200);
\`getBuyShares\` GET + currency + invalid-input guards; \`getSellPrice\`
with Bearer JWT + ApiException on non-200 + the invalid-input case skips
the HTTP call entirely; same matrix for \`getSellShares\`.

## Notes
- Same mocktail + \`http.testing.MockClient\` pattern as the previous
DFX-service PRs (#326 / #328 / #329).
- The "invalid input never reaches HTTP" assertions are a small but
meaningful contract: callers can rely on these methods to fail fast
before any network round-trip.

## Excluded (deferred)
- \`settings_user_data_cubit\` was on the original Stage-10 plan but
coordinates 3 services + Country lookups + multi-branch KYC-step-status
handling. It deserves its own focused PR rather than tagging it onto
these two service tests.
- \`dfx_kyc_service\` — held back to avoid review conflicts with open PR
#332 (KYC routing / bitbox sign hardening).
- Buy / sell / Bitbox cubits — held back while #321 and #332 are open.

## Test plan
- [x] \`flutter analyze\` on the two new files — clean
- [x] \`flutter test\` — 21 / 21 passing locally
- [ ] CI green
TaprootFreak added a commit that referenced this pull request May 15, 2026
…ts) (#336)

## Summary
Stage 12 of the coverage push. Covers the previously-deferred
\`settings_user_data_cubit\` (3-service coordination + Country lookups)
and the read-side of \`transaction_history_service\`.

| File under test | Test file | Cases |
| --- | --- | --- |
| \`lib/screens/settings_user_data/cubit/settings_user_data_cubit.dart\`
| \`test/screens/settings_user_data/settings_user_data_cubit_test.dart\`
| 6 |
| \`lib/packages/service/transaction_history_service.dart\`
(\`fetchPendingTransactions\` only) |
\`test/packages/service/transaction_history_service_test.dart\` | 6 |

## What each file covers
- **settings_user_data_cubit:** the cubit fans out to
\`RealUnitWalletService.getWalletStatus\` and
\`DfxKycService.getKycStatus\` in parallel, then either falls back to
\`getUser\` (when userData is missing) or runs two more
\`getCountryBySymbol\` lookups. Tests pin:
- Full Success when userData is present (with nationality +
addressCountry country lookups resolved to distinct \`Country\`
instances).
- \`pendingSteps\` only contains the three change steps
(name/address/phone) that are in \`inReview\` — other inReview steps
(e.g. \`contactData\`) are ignored.
- \`userData == null\` + \`getUser\` returns mail → \`Success(email)\`,
no country lookups happen.
- \`userData == null\` + \`getUser\` throws → \`Success()\` with both
userData and email null.
- Failure when \`getWalletStatus\` throws (the \`Future.wait\`
propagation).
- Failure when \`getCountryBySymbol\` throws on a userData with a
country code.
- **transaction_history_service.fetchPendingTransactions:** no auth
token short-circuits to \`[]\` without any HTTP call (verified via a
MockClient that records calls); GET shape with the Bearer JWT to
\`/v1/transaction/detail\`; non-200 returns \`[]\` (does not throw —
intentional UX); filters out \`Completed\`/\`Failed\`/\`Returned\`
(\`isPending=false\` per the enum extension); filters out transactions
whose \`sourceAccount\` / \`targetAccount\` don't match the current
wallet; wallet-match is **case-insensitive** (covers the lower-casing on
both sides).

## Notes
- \`RealUnitUserDataDto.type\` must use \`'HUMAN'\` / \`'CORPORATION'\`
(the jsonName values of \`RegistrationUserType\`), not a friendly label
— \`fromName\` throws \`StateError\` otherwise.
- Mocktail's \`stream.firstWhere\` pattern is reused for the cubit
(constructor fires \`getUserData\` synchronously, so we can't use
\`blocTest\`'s sequence model reliably here — same as #329, #330, #333).

## Excluded (and why)
- \`transaction_history_service.apiBasedSync\` — writes through
\`TransactionRepository.insertDfxTransaction\`/\`updateTransaction\` and
depends on \`AppStore.primaryAddress\` + \`apiConfig.asset.chainId\`.
Doable but adds repository mock plumbing for a method that's almost
entirely orchestration; will be its own focused PR.
- Buy / sell cubits — still held while #321 (dashboard buy actions) and
#332 (bitbox sign hardening) are open.

## Test plan
- [x] \`flutter analyze\` on the two new files — clean
- [x] \`flutter test\` — 12 / 12 passing locally
- [ ] CI green
TaprootFreak added a commit that referenced this pull request May 15, 2026
## Summary
Stage 13 of the coverage push. Covers four cubits in the sell and buy
flows whose source files are NOT touched by the currently-open #321
(dashboard buy actions, sell_page) or #332 (bitbox sign).

| Cubit under test | Test file | Cases |
| --- | --- | --- |
|
\`sell/cubits/sell_selected_bank_account/sell_selected_bank_account_cubit.dart\`
|
\`test/screens/sell/cubits/sell_selected_bank_account_cubit_test.dart\`
| 3 |
| \`sell/cubits/sell_balance/sell_balance_cubit.dart\` |
\`test/screens/sell/cubits/sell_balance_cubit_test.dart\` | 4 |
| \`sell/cubits/sell_bank_accounts/sell_bank_accounts_cubit.dart\` |
\`test/screens/sell/cubits/sell_bank_accounts_cubit_test.dart\` | 6 |
| \`buy/cubits/buy_converter/buy_converter_cubit.dart\` |
\`test/screens/buy/cubits/buy_converter_cubit_test.dart\` | 9 |

## What each file covers
- **sell_selected_bank_account_cubit:** initial null,
\`selectBankAccount\` emits the provided account,
\`selectBankAccount(null)\` clears the selection.
- **sell_balance_cubit:** initial zero-balance derived from
\`AppStore.apiConfig.asset\` + \`primaryAddress\`; subscribes to
\`BalanceRepository.watchBalance\` on init; emits each pushed balance;
\`close()\` cancels the subscription.
- **sell_bank_accounts_cubit:** Success with DTO → BankAccount mapping
on init; LoadFailure on \`getBankAccounts\` throw; \`add()\` calls
\`createBankAccount\` + re-fetches; \`AddFailure\` preserves prior
accounts + carries error message; \`deactivate()\` calls
\`updateBankAccount(isActive=false)\` + re-fetches; \`UpdateFailure\`
preserves prior accounts.
- **buy_converter_cubit:** initial empty state; \`onFiatChanged\`
debounces (100ms) and writes the converted shares; multiple rapid
keystrokes only fire the LAST service call (pins the debounce contract);
service error keeps state stable; \`onSharesChanged\` matches the
output's fractional digits to the input (\`'5'\` → 2 digits, \`'5.000'\`
→ 3 digits — pins \`_fractionDigits\` behaviour); \`onCurrencyChanged\`
re-fetches in the new currency and emits both fields; currency still
flips on service error; \`close()\` cancels pending debounce timers so
no service call happens after close.

## Excluded (and why)
- \`sell_payment_info\`, \`sell_confirm\`, \`sell_converter\` (the
parallel cubit to buy_converter), \`buy_confirm\`, \`buy_payment_info\`
— each pulls in \`real_unit_buy_payment_info_service\` /
\`real_unit_sell_payment_info_service\` / signing flows that are likely
on PR #321's path. Held back to avoid review conflicts.
- \`sell_bitbox_cubit\`, \`hardware_connect_bitbox_cubit\` — both
directly touch BitBox; held while #332 is open.

## Test plan
- [x] \`flutter analyze\` on the four new files — clean
- [x] \`flutter test\` — 22 / 22 passing locally
- [ ] CI green
TaprootFreak added a commit that referenced this pull request May 15, 2026
## Summary
Stage 14 of the coverage push. Adds 14 unit tests for the two
confirm/converter cubits where the source files are not on PR #321's or
#332's path.

| Cubit | Test file | Cases |
| --- | --- | --- |
| \`buy/cubits/buy_confirm/buy_confirm_cubit.dart\` |
\`test/screens/buy/cubits/buy_confirm_cubit_test.dart\` | 5 |
| \`sell/cubits/sell_converter/sell_converter_cubit.dart\` |
\`test/screens/sell/cubits/sell_converter_cubit_test.dart\` | 9 |

## What each file covers
- **buy_confirm_cubit:** initial \`BuyConfirmInitial\`; happy path emits
\`BuyConfirmSuccess(reference)\`; \`ApiException(statusCode: 503)\` →
\`BuyConfirmFailure(BuyConfirmError.aktionariat)\` (pins the
Aktionariat-down branch); other \`ApiException\` →
\`BuyConfirmFailure(BuyConfirmError.unknown)\`; generic exception →
\`unknown\`.
- **sell_converter_cubit:** initial empty + CHF; \`onFiatChanged\`
debounces (100ms) and writes shares from \`getSellShares\`; respects an
explicit \`currency\` argument; debounce keeps only the last value (pins
the per-keystroke contract); state stable on service error;
\`onSharesChanged\` writes \`estimatedAmount\` with matching fractional
digits (\`'10.000'\` → 3 digits, \`'10'\` → 2 digits);
\`onCurrencyChanged\` calls \`getBuyPrice\` — NOT \`getSellPrice\` —
with the current \`sharesText\` (pins the intentional buy-side
estimation on currency switch); currency still flips even when
\`getBuyPrice\` throws; \`close()\` cancels pending debounce timers so
no service call after close.

## Notes
- The \`sell_converter\` "currency switch uses BUY price" pin documents
a non-obvious behaviour in the production code — leaving it untested
would let a future refactor silently switch to \`getSellPrice\` and lose
the no-fee preview.

## Excluded (still deferred)
- \`buy_payment_info_cubit\`, \`sell_payment_info_cubit\`,
\`sell_confirm_cubit\` — all touch
\`real_unit_buy_payment_info_service\` /
\`real_unit_sell_payment_info_service\` more deeply; #321 modifies these
services + their tests, so I'm holding them back to avoid review
conflicts.
- \`sell_bitbox_cubit\`, \`hardware_connect_bitbox_cubit\` —
BitBox-coupled; held while #332 is open.

## Test plan
- [x] \`flutter analyze\` on the two new files — clean
- [x] \`flutter test\` — 14 / 14 passing locally
- [ ] CI green
TaprootFreak added a commit that referenced this pull request May 15, 2026
…#340)

## Summary
Stage 16 of the coverage push.

| File | Cases (new) |
| --- | --- |
| \`buy/cubits/buy_payment_info/buy_payment_info_cubit.dart\` | 10 (new
file) |
| \`packages/service/balance_service.dart\` | 4 (appended to existing
test) |

## What each file covers
- **buy_payment_info_cubit:** initial \`BuyPaymentInfoInitial\`; happy
CHF path emits Success; amount below 100 CHF minimum →
\`MinAmountNotMetFailure\` and the service is NOT called; EUR minimum is
scaled by \`getChfToEurRate\` and ceil'd (e.g. 100 × 0.92 → 92); EUR
amount above scaled minimum proceeds to the service; empty amount string
is treated as 0; comma decimal separator normalised to dot (e.g.
\`'300,75'\` → 301); \`KycLevelRequiredException\` →
\`Failure(kycRequired, requiredLevel)\`;
\`RegistrationRequiredException\` → \`Failure(registrationRequired)\`;
generic exception → \`Failure(unknown)\`.
- **balance_service (4 new cases on top of the existing 2):** skips
saving when the response JSON has no \`balance\` field; catches a
non-numeric balance string and skips saving (production code does
\`BigInt.parse\` inside try/catch); \`getBalance\` delegates straight
through to \`BalanceRepository.getBalance\`; \`cancelSync\` is a safe
no-op when called before \`startSync\`.

## Notes
- \`buy_payment_info_cubit\` reads \`DFXPriceService.getChfToEurRate\`
only for the EUR branch, so the CHF tests don't need to stub it.
- The cubit's source file is NOT touched by PR #321 — only the
underlying service is — so this PR is conflict-free.

## Test plan
- [x] \`flutter analyze\` on the two changed files — clean
- [x] \`flutter test\` — 16 / 16 passing locally (12 in
buy_payment_info_cubit + 6 in balance_service incl. the 2 pre-existing)
- [ ] CI green
TaprootFreak added a commit that referenced this pull request May 15, 2026
## Summary
Two sell-side cubits whose source files are NOT in PR #321's diff (only
the underlying services are).

| Cubit | Test file | Cases |
| --- | --- | --- |
| \`sell/cubits/sell_confirm/sell_confirm_cubit.dart\` |
\`test/screens/sell/cubits/sell_confirm_cubit_test.dart\` | 3 |
| \`sell/cubits/sell_payment_info/sell_payment_info_cubit.dart\` |
\`test/screens/sell/cubits/sell_payment_info_cubit_test.dart\` | 12 |

## What each covers
- **sell_confirm_cubit:** initial \`SellConfirmInitial\`; happy path
passes through Loading and ends in Success (service mock suspends 10ms
so the broadcast-stream listener can observe Loading); service throw →
Failure carrying the error string.
- **sell_payment_info_cubit:**
- \`getPaymentInfo\` happy path emits Success with \`isBitbox=false\`
for a \`SoftwareWallet\`; \`isBitbox=true\` for a \`BitboxWallet\` stub.
- Exception mapping: \`KycLevelRequiredException\` →
\`Failure(kycRequired, requiredLevel)\`;
\`RegistrationRequiredException\` → \`Failure(registrationRequired)\`;
generic → \`Failure(unknown, message)\`.
- \`validateMinAmount\` (the parallel to buy_payment_info_cubit's min
check, but here it's a CHF-10 floor): below floor emits
\`MinAmountNotMet\`; above leaves the state untouched; EUR scaled by
\`getChfToEurRate\` (ceil); a prior \`MinAmountNotMet\` is cleared back
to \`Initial\` when the amount rises; comma decimal normalised to dot;
empty string treated as 0.

## Notes
- Both cubits use \`SellPaymentInfo\` which has 13 required fields.
Tests construct one real instance with stub addresses + a no-op
Eip7702Data so the value can be passed around without mocktail fallback
registration.
- For the BitBox-wallet test, a tiny \`_BitboxStubWallet extends
AWallet\` is used (only \`walletType\` is read; \`primaryAccount\` /
\`currentAccount\` throw to make the intent explicit).

## Test plan
- [x] \`flutter analyze\` on both new files — clean
- [x] \`flutter test\` — 15 / 15 passing locally
- [ ] CI green
@TaprootFreak TaprootFreak merged commit 4b0f1f2 into RealUnitCH:develop May 15, 2026
1 check passed
TaprootFreak added a commit that referenced this pull request May 15, 2026
Closes Acceptance Criterion #6 of #314 — _\"Documentation:
\`docs/testing.md\` explains when to use which tier, with examples.\"_

## Summary

A single page that captures everything we've learned across Phase 0
(#319) and Phase 1 Foundation (#320 / #321) about how to test
BitBox-touching code without re-deriving the rules every PR:

- **Tier matrix** (0–4) with hardware / CI status for each, so reviewers
can tell at a glance what a test does and doesn't prove
- **Decision tree** for picking a tier — \"if a Tier 0 test would have
to mock the very thing under test, drop down a tier\"
- **Tier 0 patterns** with concrete excerpts from real test files:
- Cubit / Bloc tests (\`bloc_test\` + \`mocktail.Mock\`) pointing at
\`test/screens/kyc/cubits/kyc/kyc_cubit_test.dart\`
- Widget tests (\`pumpApp\` + \`MockCubit\`) pointing at
\`test/screens/kyc/steps/kyc_email_page_test.dart\`
- Service + HTTP tests (\`http/testing\` \`MockClient\` +
\`_MockAppStore\`) pointing at
\`test/packages/service/dfx/dfx_bank_account_service_test.dart\`,
including the **\`setAuthToken('test-jwt')\` pre-seed** trick for
DFXAuthService-derived services (the gotcha that broke CI on #321)
- **Tier 1 patterns** for \`FakeBitboxCredentials\` with the
\`FakeBitboxBehavior\` matrix and the disconnect-flip-to-success
reconnect pattern, pointing at
\`test/integration/kyc_sign_flow_test.dart\`
- **Tiers 2–4** marked deferred with status pointers back to the
corresponding phase of #314
- **Mocktail gotchas** (\`Future\` + \`thenAnswer\`,
\`registerFallbackValue\`, private mocks)
- **Add-tests checklist** for PRs touching \`KycCubit\` /
\`Eip712Signer\` / \`DFXAuthService\` / \`BitboxCredentials\` /
\`bitbox_flutter\`

CONTRIBUTING.md's Testing section gets a one-line cross-link.

## Test plan

- [x] Doc compiles / renders (no broken intra-repo links in the
markdown)
- [x] Every code example matches a real file already on \`develop\`
- [x] \`flutter analyze\` — no new issues (4 pre-existing errors in
\`test/screens/home/home_bloc_test.dart\` are unrelated to this PR;
verified they exist on \`develop\` too)
TaprootFreak added a commit that referenced this pull request May 15, 2026
PR #321 removed DfxWidgetService from HomeBloc but didn't update the
test, leaving develop's home_bloc_test.dart broken — CI on every
subsequent PR fails the compile step.

This bundles the fix into the stage 23 PR so unblocking can happen in
one merge instead of a separate fix PR.
TaprootFreak added a commit that referenced this pull request May 15, 2026
PR #321 removed DfxWidgetService from HomeBloc but didn't update the
test, leaving develop's home_bloc_test.dart broken — CI on every
subsequent PR fails the compile step.

This bundles the fix into the stage 23 PR so unblocking can happen in
one merge instead of a separate fix PR.
TaprootFreak added a commit that referenced this pull request May 23, 2026
- fake_async example: add bitbox_flutter.dart import and inline the
  pairedServiceSync idiom so the snippet is copy-paste compilable
- platform-coupled section: real-plugin counterpart is Tier 2/3, not
  Tier 1/2 (Tier 1 is FakeBitboxCredentials by definition); add the
  CONTRIBUTING.md footnote-165 caveat that the annotation is the
  documenting form today
- README: PRs #319/#320/#321/#322/#323 are all merged since
  2026-05-15; replace "in flight"/"landing in"/"extended in" with
  "added via" / "have closed" / "partially covered after"
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.

2 participants