fix: keep dashboard buy actions visible#321
Conversation
Review — deeper look at the implicationsI 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.
|
c524628 to
5cc061a
Compare
|
Addressed the review in the latest force-push:
Verified with:
|
5cc061a to
a0ac46b
Compare
) ## 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
## 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
## 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
## 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
…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
…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
…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.
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
…shboard-buy-actions
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.
## 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
…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
## 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
## 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
…#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
## 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
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)
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.
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.
- 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"
Summary
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