diff --git a/.github/workflows/handbook-build-check.yaml b/.github/workflows/handbook-build-check.yaml index 89c0547d7..31d752a01 100644 --- a/.github/workflows/handbook-build-check.yaml +++ b/.github/workflows/handbook-build-check.yaml @@ -96,8 +96,8 @@ jobs: set -euo pipefail bash scripts/assemble-handbook-screenshots.sh /tmp/handbook-shots count=$(ls -1 /tmp/handbook-shots/*.png | wc -l | tr -d ' ') - if [ "$count" != "301" ]; then - echo "expected 301 screenshots, got $count" >&2 + if [ "$count" != "304" ]; then + echo "expected 304 screenshots, got $count" >&2 exit 1 fi @@ -177,10 +177,11 @@ jobs: exit 1 fi - # Spot-check handbook screenshots assembled from Goldens (301 PNGs). - # Mix of original 01-61 slots, later Golden batches, and referral - # baselines so a regression in any range surfaces here. - for name in 01-welcome 11-dashboard 26-terms 35-dashboard-with-balance 46-buy-kyc-required 52-sell-unknown-error 53-buy-payment-details 61-kyc-registration-tax-tin-error 62-welcome-page-android 219-settings-security-page-default 268-phone-number-field-default 269-dashboard-insider-unlocked 270-handbook-persona-dca 275-handbook-persona-exit-1j 276-referral-overview 282-referral-payout-row 283-referral-terms-readonly 287-kyc-registration-referral-recognized 288-transaction-history-referral-payout 289-dashboard-referral-entry-and-payout 290-kyc-registration-referral-promo 291-referral-bind-error-invalid 292-referral-bind-error-already-registered; do + # Spot-check handbook screenshots assembled from Goldens (304 PNGs). + # Mix of original 01-61 slots, later Golden batches, referral + # baselines, bind-error overlays, and pay whole-share rounding so a + # regression in any range surfaces here. + for name in 01-welcome 11-dashboard 26-terms 35-dashboard-with-balance 46-buy-kyc-required 52-sell-unknown-error 53-buy-payment-details 61-kyc-registration-tax-tin-error 62-welcome-page-android 219-settings-security-page-default 268-phone-number-field-default 269-dashboard-insider-unlocked 270-handbook-persona-dca 275-handbook-persona-exit-1j 276-referral-overview 282-referral-payout-row 283-referral-terms-readonly 287-kyc-registration-referral-recognized 288-transaction-history-referral-payout 289-dashboard-referral-entry-and-payout 290-kyc-registration-referral-promo 291-referral-bind-error-invalid 292-referral-bind-error-already-registered 293-pay-info 294-pay-quote-ready 295-pay-quote-ready-merchant; do # Auth is checked before the file exists: a missing PNG also # returns 401, so HTTP status cannot prove assembly. Look on disk. if ! docker exec handbook test -f "/usr/share/nginx/html/screenshots/${name}.png"; then diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 58765c8ca..9d3fd5699 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -248,7 +248,7 @@ The app supports three wallet modes (`software`, `bitbox`, `debug`) with differe ```bash rg "^//\s*@no-integration-test:" lib/ ``` -- Visual-regression Goldens under `test/goldens/screens/` and `test/goldens/widgets/` are also the source of the 301 screenshots served at `handbook.realunit.app`. When you add a handbook page, you MUST add a matching Golden test AND a row in the mapping table at `scripts/assemble-handbook-screenshots.sh` — the handbook will not pick up a Maestro-captured PNG anymore. The `Handbook Build Check` workflow on every PR runs the assembly script and fails loudly if a mapped Golden is missing. +- Visual-regression Goldens under `test/goldens/screens/` and `test/goldens/widgets/` are also the source of the 304 screenshots served at `handbook.realunit.app`. When you add a handbook page, you MUST add a matching Golden test AND a row in the mapping table at `scripts/assemble-handbook-screenshots.sh` — the handbook will not pick up a Maestro-captured PNG anymore. The `Handbook Build Check` workflow on every PR runs the assembly script and fails loudly if a mapped Golden is missing. - Why: single source of truth — a UI regression that breaks a Golden also breaks the handbook image before either ships; eliminates the previous "two pipelines, two truths" problem. - See: [`docs/visual-regression-tests.md`](docs/visual-regression-tests.md) section "Handbook screenshots are sourced from Goldens". diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index 36da5d176..827b7130a 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -182,11 +182,10 @@ "payFailureBitboxRequired": "Bitte verbinden Sie Ihre BitBox, um fortzufahren.", "payFailureGeneric": "Bei der Zahlung ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.", "payFailureInsufficientEth": "Es konnten nicht genügend ETH für die Netzwerkgebühren bereitgestellt werden. Bitte versuchen Sie es später erneut.", - "payFailureInsufficientZchf": "Ihr REALU-Bestand reicht für diesen Betrag nicht aus.", "payFailureQuoteExpired": "Das Zahlungsangebot ist abgelaufen. Bitte scannen Sie den Code erneut.", "payFailureSignatureUnsupported": "Diese Wallet kann keine Transaktionen signieren. Wechseln Sie zu einer Software- oder BitBox-Wallet.", "payFailureTitle": "Zahlung fehlgeschlagen", - "payInfoBody": "Die Bezahlfunktion kann an allen OpenCryptoPay-Standorten genutzt werden.\n\nDer Tausch erfolgt in zwei Schritten. Zuerst wird REALU in ZCHF getauscht. ZCHF ist ein CHF-Stablecoin, der anschliessend an den Zahlungsempfänger übertragen wird.\n\nREALU ist ein Wertpapier und wird nicht für die Bezahlung selbst verwendet.", + "payInfoBody": "Die Bezahlfunktion kann an allen OpenCryptoPay-Standorten genutzt werden.\n\nDer Tausch erfolgt in zwei Schritten. Zuerst wird REALU in ZCHF getauscht. ZCHF ist ein CHF-Stablecoin, der anschliessend an den Zahlungsempfänger übertragen wird.\n\nREALU ist ein Wertpapier und wird nicht für die Bezahlung selbst verwendet.\n\nBei jeder Zahlung wird auf ganze REALU-Anteile aufgerundet, weil es keine halben REALU gibt. Restliches ZCHF behält DFX.", "payInfoTitle": "Bevor Sie bezahlen", "paymentInformationFailed": "Beim Abrufen der Zahlungsinformationen ist ein Fehler aufgetreten.", "paymentInformationFailedDescription": "Bitte versuchen Sie es später erneut. Wenn der Fehler weiterhin besteht, wenden Sie sich an unseren Support.", @@ -199,6 +198,7 @@ "payQuoteRealuEstimated": "Erwarteter Erlös", "payQuoteRealuFees": "Gebühren", "payQuoteRequested": "Geforderter Betrag", + "payQuoteRoundingNotice": "Bei jeder Zahlung wird auf ganze REALU-Anteile aufgerundet, weil es keine halben REALU gibt. Restliches ZCHF behält DFX.", "payQuoteSummary": "Sie bezahlen ${amount} ${asset}", "payQuoteTitle": "Zahlung bestätigen", "payQuoteUnavailable": "Für diesen Zahlungscode ist keine ZCHF-Zahlung verfügbar.", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 4d5578e31..fe5738880 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -182,11 +182,10 @@ "payFailureBitboxRequired": "Please connect your BitBox to continue.", "payFailureGeneric": "Something went wrong with the payment. Please try again.", "payFailureInsufficientEth": "Could not provision enough ETH for network fees. Please try again later.", - "payFailureInsufficientZchf": "Your REALU holdings are not enough for this amount.", "payFailureQuoteExpired": "The payment quote expired. Please scan the code again.", "payFailureSignatureUnsupported": "This wallet cannot sign transactions. Switch to a software or BitBox wallet.", "payFailureTitle": "Payment failed", - "payInfoBody": "You can use the payment function at all OpenCryptoPay locations.\n\nThe exchange happens in two steps. First, REALU is swapped for ZCHF. ZCHF is a Swiss franc stablecoin, which is then transferred to the payment recipient.\n\nREALU is a security and is not used for the payment itself.", + "payInfoBody": "You can use the payment function at all OpenCryptoPay locations.\n\nThe exchange happens in two steps. First, REALU is swapped for ZCHF. ZCHF is a Swiss franc stablecoin, which is then transferred to the payment recipient.\n\nREALU is a security and is not used for the payment itself.\n\nEvery payment is rounded up to whole REALU shares, because there are no fractional REALU. Leftover ZCHF is kept by DFX.", "payInfoTitle": "Before you pay", "paymentInformationFailed": "An error occurred while getting the payment information.", "paymentInformationFailedDescription": "Please try again later. If the error persists, contact our support team.", @@ -199,6 +198,7 @@ "payQuoteRealuEstimated": "Estimated proceeds", "payQuoteRealuFees": "Fees", "payQuoteRequested": "Requested amount", + "payQuoteRoundingNotice": "Every payment is rounded up to whole REALU shares, because there are no fractional REALU. Leftover ZCHF is kept by DFX.", "payQuoteSummary": "You pay ${amount} ${asset}", "payQuoteTitle": "Confirm payment", "payQuoteUnavailable": "No ZCHF payment is available for this payment code.", diff --git a/docs/handbook/README.md b/docs/handbook/README.md index 2bc7b928b..82d593d62 100644 --- a/docs/handbook/README.md +++ b/docs/handbook/README.md @@ -21,7 +21,7 @@ deployten Image (`handbook.realunit.app`). ## Screenshots regenerieren -Es gibt keinen separaten Regeneration-Schritt: Die 301 Handbook-Screenshots +Es gibt keinen separaten Regeneration-Schritt: Die 304 Handbook-Screenshots sind direkt die Golden-Baselines unter `test/goldens/` (gemappt in `scripts/assemble-handbook-screenshots.sh`). Eine UI-Änderung an einer der gemappten Pages produziert beim `flutter test test/goldens` einen Diff — diff --git a/docs/handbook/de/index.html b/docs/handbook/de/index.html index e65845eb1..e07a21f31 100644 --- a/docs/handbook/de/index.html +++ b/docs/handbook/de/index.html @@ -1137,6 +1137,9 @@
  • 80Testkunden — Bestandsentwicklung
  • +
  • + 81Bezahlen — ganze REALU +
  • GAktientoken — Geo-Filter
  • @@ -1186,14 +1189,14 @@

    RealUnit Wallet — Handbook

    📱 App: RealUnit Wallet (Flutter) 📂 Code: RealUnitCH/app - 🧪 Tests: 355 Visual-Regression Goldens (301 davon im Handbook) · 26 Tier-3 Smoke-Flows + 🧪 Tests: 355 Visual-Regression Goldens (304 davon im Handbook) · 26 Tier-3 Smoke-Flows

    Single Source of Truth

    - Die 301 App-Screenshots dieser Seite sind Goldens aus + Die 304 App-Screenshots dieser Seite sind Goldens aus test/goldens/{screens,widgets}/<feature>/goldens/macos/<file>.png. Welcher Golden welcher Handbook-Slot ist, definiert die Mapping-Tabelle in scripts/assemble-handbook-screenshots.sh. Der Docker-Build @@ -1233,7 +1236,7 @@

    Single Source of Truth

    Pipeline: Page-Edit → flutter test --update-goldens test/goldens/ auf dem self-hosted Runner (Hardware-Determinismus) → neue Baseline committen → handbook-deploy.yaml baut das Docker-Image aus dem gepushten Branch - und assembliert die 301 Slots aus test/goldens/ → Push auf + und assembliert die 304 Slots aus test/goldens/ → Push auf staging deployt das Handbook-Image nach Produktion, via Cloudflare Tunnel.

    @@ -8419,6 +8422,95 @@

    80Testkunden — Bestandsentwicklung


    +
    + +
    +
    +

    81Bezahlen — ganze REALU

    +
    test/goldens/screens/pay/
    +
    +
    + 3 Screens + +
    +
    +
    +

    + OpenCryptoPay-Zahlungen verkaufen immer ganze REALU-Anteile — + halbe Anteile gibt es nicht. Die Bestätigungsseite («Zahlung bestätigen») + sagt das ausdrücklich. Ein allfälliger ZCHF-Rest geht an DFX. + Die Zahlungs-API rundet den Tausch auf ganze Anteile auf; die App rechnet + die Stückzahl nicht selbst. +

    + +
    +
    +
    + + + screens/pay/goldens/macos/pay_info_page.png +
    +
    + Bevor Sie bezahlen — Hinweis auf ganze REALU-Anteile +
    +
    + Intro-Seite Bevor Sie bezahlen mit dem zusätzlichen Absatz: + bei jeder Zahlung wird auf ganze REALU-Anteile aufgerundet, weil es + keine halben REALU gibt; ein allfälliger ZCHF-Rest geht an DFX. + Golden-Quelle: pay_info_page.png. +
    +
    +
    +
    + + + screens/pay/goldens/macos/pay_quote_page_ready.png +
    +
    + Zahlung bestätigen — Aufrundungshinweis +
    +
    + Bestätigungsseite mit Betrag, benötigten ZCHF und dem sichtbaren + Hinweis, dass jede Zahlung auf ganze REALU-Anteile aufgerundet wird. + Restliches ZCHF behält DFX. Button Bezahlen bleibt + die sticky CTA. + Golden-Quelle: pay_quote_page_ready.png. +
    +
    +
    +
    + + + screens/pay/goldens/macos/pay_quote_page_ready_with_merchant.png +
    +
    + Zahlung bestätigen — Empfänger, REALU-Zeilen und Aufrundungshinweis +
    +
    + Dieselbe Bestätigungsseite mit Empfänger, REALU-Anteilzeilen und + dem Aufrundungshinweis unter den Beträgen. + Golden-Quelle: pay_quote_page_ready_with_merchant.png. +
    +
    +
    +
    + +
    +
    diff --git a/docs/screens.md b/docs/screens.md index 2f5adea2d..2b438c5a8 100644 --- a/docs/screens.md +++ b/docs/screens.md @@ -13,7 +13,8 @@ Column meaning: **not a route**: it is shown inside a parent route (KYC steps, status sub-pages, disclaimer steps). - **Handbook** — the handbook screenshot slot number(s) that document the - screen, or `—` if the screen has no Golden baseline. Each slot is a + screen, or `—` if the screen has no handbook slot (it may still have a + Visual-Regression Golden). Each slot is a Visual-Regression Golden under `test/goldens/`, mapped to its handbook position by `scripts/assemble-handbook-screenshots.sh`. The handbook now covers a **curated subset of Golden baselines** — screens **plus selected @@ -30,9 +31,10 @@ Column meaning: default plus its state variants), so most cells now carry a range rather than a single anchor. One Golden is a shared form widget rather than a screen: slot `268` is `PhoneNumberField` under `test/goldens/widgets/form/`. - Only `WebViewPage` (no active Golden) and `KycPageManager` (the orchestrator - has no Golden of its own — its states are the individual KYC pages) still - carry `—`. Slot ↔ Golden mapping in + `WebViewPage` (no active Golden) and `KycPageManager` (the orchestrator + has no Golden of its own — its states are the individual KYC pages) carry + `—`, as do `PayScanPage` and `PayProcessPage` (Visual-Regression goldens + exist, no handbook slot). Slot ↔ Golden mapping in `scripts/assemble-handbook-screenshots.sh`, slot ↔ HTML block in `docs/handbook/de/index.html`. See `docs/handbook/README.md`. @@ -56,6 +58,10 @@ Column meaning: | Dashboard & trading | `SellBitboxPage` | `sellBitbox` | `/sellBitbox` | `126`, `127`, `128`, `129`, `130`, `131`, `132`, `133`, `134`, `135`, `136` | | Dashboard & trading | `SellBankAccountSelectionPage` | — | — | `121`, `122` | | Dashboard & trading | `ReceivePage` | `receive` | `/receive` | `101`, `102` | +| Dashboard & trading | `PayInfoPage` | `pay` | `/pay` | `293` | +| Dashboard & trading | `PayScanPage` | — | — | — | +| Dashboard & trading | `PayQuotePage` | — | — | `294`, `295` | +| Dashboard & trading | `PayProcessPage` | — | — | — | | Dashboard & trading | `ConnectBitboxPage` | — | — | `137`, `138`, `139`, `140`, `141`, `142`, `143`, `144`, `145`, `146` | | Dashboard & trading | `BitboxAddressRecoveryPage` | `bitboxAddressRecovery` | `/bitboxAddressRecovery` | `147` | | Dashboard & trading | `WebViewPage` | `webView` | `/webView` | — | @@ -122,13 +128,15 @@ Column meaning: | Shared widgets | `PhoneNumberField` | — | — | `268` | | Shared widgets | `ReferralPayoutTransactionRow` | — | — | `282` | -81 screens — 47 routed (`GoRoute`) + 34 non-routed. The table also carries +85 screens — 48 routed (`GoRoute`) + 37 non-routed. The table also carries two shared-widget baselines (`PhoneNumberField`, `ReferralPayoutTransactionRow`), which are not screens. Referral surfaces have Golden baselines (`276`–`292`); the handbook documents the programme in `#spec-referral`. Overlay slots `291` and `292` are listed on `DashboardPage` (the host screen), not as a second shared-widget -row. +row. OpenCryptoPay pay rounding cards `293`–`295` are documented in +`#spec-81`. `PayScanPage` and `PayProcessPage` have Visual-Regression goldens +under `test/goldens/screens/pay/` and no handbook slot. ## Notes @@ -147,7 +155,7 @@ row. `243`–`245`. `SetupPinPage` also backs the `settingsChangePin` route (`/settings/security/changePin`) via a second constructor; that reuse has no separate Golden and is not given its own row. -- **Handbook numbering.** Each of the 301 handbook slots is a Visual-Regression +- **Handbook numbering.** Each of the 304 handbook slots is a Visual-Regression Golden under `test/goldens/`, mapped to its handbook position by `scripts/assemble-handbook-screenshots.sh`. A parallel Tier-3 Maestro flow (`.maestro/handbook/NN-*.yaml`) covers navigation/tap-routing smoke for the diff --git a/docs/visual-regression-tests.md b/docs/visual-regression-tests.md index a43d1d01f..c173b59b3 100644 --- a/docs/visual-regression-tests.md +++ b/docs/visual-regression-tests.md @@ -191,7 +191,7 @@ public repos are free even for macOS minutes. ## Handbook screenshots are sourced from Goldens -The 301 PNGs the handbook serves at `handbook.realunit.app/screenshots/` +The 304 PNGs the handbook serves at `handbook.realunit.app/screenshots/` are assembled from the Golden baselines at docker-build time. One Golden → one handbook page, via the explicit mapping in `scripts/assemble-handbook-screenshots.sh`. The handbook does **not** @@ -231,7 +231,7 @@ that directory into `/usr/share/nginx/html/screenshots/`. Golden file. 4. Open the PR. The `Handbook Build Check` workflow runs `docker build` and a container smoke (`/healthz` + auth gate + - spot-checks selected mapped screenshots via `docker exec test -f`; the full set is gated by the assemble step (`expected 301`)). A missing Golden surfaces here + spot-checks selected mapped screenshots via `docker exec test -f`; the full set is gated by the assemble step (`expected 304`)). A missing Golden surfaces here as a missing-source error from the assembly script before docker even spins up. diff --git a/lib/screens/pay/cubits/pay_process/pay_process_cubit.dart b/lib/screens/pay/cubits/pay_process/pay_process_cubit.dart index 763711c8f..7b1f5a27e 100644 --- a/lib/screens/pay/cubits/pay_process/pay_process_cubit.dart +++ b/lib/screens/pay/cubits/pay_process/pay_process_cubit.dart @@ -50,11 +50,13 @@ class PayProcessCubit extends Cubit { SwapPaymentInfo? _swap; - /// Set once the REALU→ZCHF swap has been broadcast successfully. From this - /// point the user holds ZCHF and recovery must NEVER re-swap — the pay leg is - /// retried on its own via [retryPay]. + /// Set after the swap is signed, before broadcast. A timeout can still have + /// sent the tx; recovery must NEVER re-swap — the pay leg is retried via + /// [retryPay]. bool _swapCompleted = false; + bool get swapCompleted => _swapCompleted; + /// Guards overlapping ETH-poll ticks from each calling [_executeSwap]. Set /// synchronously before the first await in a tick; released in `finally` on /// every path out of a tick (success, max-attempts, isClosed, transient @@ -108,7 +110,8 @@ class PayProcessCubit extends Cubit { /// the common case (a few minutes of drift + the OCP/swap fees), so any /// adverse move stranded the user in ZCHF that could not cover settlement. /// 3% is a pragmatic headroom that absorbs ordinary drift while keeping the - /// over-swap small (leftover ZCHF simply stays in the wallet); a larger move + /// over-swap small (leftover from the 3% swap buffer is swept to DFX on the + /// pay transfer, not left on the user address); a larger move /// is caught explicitly and surfaced as a retryable /// [PayRetryReason.insufficientZchf] rather than a server-side failure. static const _slippageBuffer = 1.03; @@ -173,7 +176,13 @@ class PayProcessCubit extends Cubit { // The API is the authority on whether the swap is fundable; render its // signal rather than recomputing limits locally. if (!swap.isValid) { - emit(const PayProcessFailure(PayProcessFailureReason.insufficientZchf)); + final error = swap.error; + emit( + PayProcessFailure( + PayProcessFailureReason.generic, + message: (error != null && error.isNotEmpty) ? error : null, + ), + ); return; } @@ -259,12 +268,12 @@ class PayProcessCubit extends Cubit { if (isClosed) return; final signed = await _signTransaction(unsigned.swap); if (isClosed) return; - await _payService.broadcastSwapTransaction(swap.id, signed); - if (isClosed) return; - // The swap is now irreversible — the user holds ZCHF. From here every - // recovery path retries the PAY leg only; the swap is never redone. + // Mark completed before the HTTP round-trip. A timeout or dropped 2xx + // can still have broadcast the tx; the quote must not re-enable Pay. _swapCompleted = true; _acquiredZchf = swap.estimatedAmount; + await _payService.broadcastSwapTransaction(swap.id, signed); + if (isClosed) return; await _refreshQuoteAndPay(); } on PaySignatureUnsupportedException { if (isClosed) return; @@ -274,6 +283,17 @@ class PayProcessCubit extends Cubit { emit(const PayProcessFailure(PayProcessFailureReason.bitboxRequired)); } catch (e) { if (isClosed) return; + if (_swapCompleted) { + // API errors 1:1; transport/timeout has no user copy — the retry sheet + // falls back to payRetryTransient (ZCHF stays until pay succeeds). + emit( + PayProcessPayRetry( + PayRetryReason.transient, + message: e is ApiException ? e.message : null, + ), + ); + return; + } emit(PayProcessFailure(PayProcessFailureReason.generic, message: ApiException.userFacingMessage(e))); } } diff --git a/lib/screens/pay/cubits/pay_process/pay_process_state.dart b/lib/screens/pay/cubits/pay_process/pay_process_state.dart index 1a512e003..24be3412f 100644 --- a/lib/screens/pay/cubits/pay_process/pay_process_state.dart +++ b/lib/screens/pay/cubits/pay_process/pay_process_state.dart @@ -3,10 +3,6 @@ part of 'pay_process_cubit.dart'; /// Why the pay flow failed. Each reason maps to a localized, user-facing /// message in the view — the cubit carries the reason, not the copy. enum PayProcessFailureReason { - /// The swap quote came back invalid (e.g. not fundable for the requested - /// ZCHF amount after the slippage buffer). - insufficientZchf, - /// Not enough ETH to cover gas and the faucet top-up did not arrive. insufficientEth, diff --git a/lib/screens/pay/cubits/pay_quote/pay_quote_cubit.dart b/lib/screens/pay/cubits/pay_quote/pay_quote_cubit.dart index ceeff9eb6..d056b89c0 100644 --- a/lib/screens/pay/cubits/pay_quote/pay_quote_cubit.dart +++ b/lib/screens/pay/cubits/pay_quote/pay_quote_cubit.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/swap_payment_info.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart'; part 'pay_quote_state.dart'; @@ -11,7 +12,9 @@ part 'pay_quote_state.dart'; /// the requested fiat amount + the exact ZCHF amount the Ethereum method /// requires. The amount comes from the API `transferAmounts` (ZCHF on the /// Ethereum entry) — the app never computes it. An expired quote surfaces as a -/// typed state so the view can prompt a re-scan. +/// typed state so the view can prompt a re-scan. An invalid swap preview +/// (`isValid: false`) is [PayQuoteError], never Ready — the app does not +/// locally ceil share counts; `swap.amount` is displayed only when valid. class PayQuoteCubit extends Cubit { final RealUnitPayService _payService; final String _paymentLinkId; @@ -47,6 +50,11 @@ class PayQuoteCubit extends Cubit { ); if (isClosed) return; + if (!swap.isValid) { + emit(_invalidSwapState(swap)); + return; + } + emit( PayQuoteReady( paymentLinkId: _paymentLinkId, @@ -67,6 +75,17 @@ class PayQuoteCubit extends Cubit { } } + /// Invalid preview swaps are not payable. Non-empty API errors are shown 1:1; + /// a missing/empty error uses the view's generic copy. The app does not map + /// `AmountTooLow` onto holdings — that string is the API's volume signal. + static PayQuoteError _invalidSwapState(SwapPaymentInfo swap) { + final error = swap.error; + if (error != null && error.isNotEmpty) { + return PayQuoteError(error); + } + return const PayQuoteError(''); + } + /// The ZCHF amount listed for the Ethereum transfer method, or null if the /// payment link does not offer an Ethereum/ZCHF method. static double? _zchfTransferAmount(LnurlpPaymentDto details) { diff --git a/lib/screens/pay/pay_process_page.dart b/lib/screens/pay/pay_process_page.dart index fcd0338c5..71baef6dc 100644 --- a/lib/screens/pay/pay_process_page.dart +++ b/lib/screens/pay/pay_process_page.dart @@ -59,6 +59,7 @@ class PayProcessView extends StatelessWidget { icon: Icons.check_circle_rounded, title: S.of(context).paySuccess, description: S.of(context).paySuccessDescription, + swapCompleted: true, ); } else if (state is PayProcessPayRetry) { // The swap already succeeded — offer to retry the PAY leg only. The @@ -70,6 +71,7 @@ class PayProcessView extends StatelessWidget { icon: Icons.error_rounded, title: S.of(context).payFailureTitle, description: _failureMessage(context, state), + swapCompleted: context.read().swapCompleted, ); } }, @@ -115,7 +117,6 @@ class PayProcessView extends StatelessWidget { return apiText; } return switch (state.reason) { - PayProcessFailureReason.insufficientZchf => S.of(context).payFailureInsufficientZchf, PayProcessFailureReason.insufficientEth => S.of(context).payFailureInsufficientEth, PayProcessFailureReason.signatureUnsupported => S.of(context).payFailureSignatureUnsupported, PayProcessFailureReason.bitboxRequired => S.of(context).payFailureBitboxRequired, @@ -141,6 +142,7 @@ class PayProcessView extends StatelessWidget { required IconData icon, required String title, required String description, + required bool swapCompleted, }) async { await waitForIncomingRouteAnimation(context); if (!context.mounted) { @@ -175,7 +177,7 @@ class PayProcessView extends StatelessWidget { ), ), ); - if (context.mounted) Navigator.of(context).pop(); + if (context.mounted) Navigator.of(context).pop(swapCompleted); } /// Recovery sheet shown after a successful swap when the pay leg failed. The @@ -233,7 +235,8 @@ class PayProcessView extends StatelessWidget { await cubit.retryPay(); } else if (context.mounted) { // Closed: leave the flow. The swapped ZCHF stays safely in the wallet. - Navigator.of(context).pop(); + // true tells the quote page not to offer Pay again on the same quote. + Navigator.of(context).pop(true); } } } diff --git a/lib/screens/pay/pay_quote_page.dart b/lib/screens/pay/pay_quote_page.dart index 71ec878f6..665dc4bc6 100644 --- a/lib/screens/pay/pay_quote_page.dart +++ b/lib/screens/pay/pay_quote_page.dart @@ -121,22 +121,43 @@ class _PayQuoteReadyViewState extends State<_PayQuoteReadyView> { value: '${state.realuFeesTotal!.toStringAsFixed(2)} ${realUnitAsset.symbol}', ), ], + Text( + S.of(context).payQuoteRoundingNotice, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: RealUnitColors.neutral500, + ), + ), ], ), actions: [ FilledButton( onPressed: _navigating ? null - : () { + : () async { + if (_navigating) return; setState(() => _navigating = true); - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => PayProcessPage( - paymentLinkId: state.paymentLinkId, - zchfNeeded: state.zchfAmount, + bool? swapCompleted = false; + try { + swapCompleted = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PayProcessPage( + paymentLinkId: state.paymentLinkId, + zchfNeeded: state.zchfAmount, + ), ), - ), - ); + ); + } finally { + // Only a typed pre-swap failure pops `false` and re-enables + // Pay. `true` (swap ran) and `null` (AppBar / system back) + // both leave this quote so REALU cannot be sold twice. + if (mounted && swapCompleted == false) { + setState(() => _navigating = false); + } + } + if (mounted && swapCompleted != false) { + Navigator.of(context).pop(); + } }, child: Text(S.of(context).payConfirmButton), ), diff --git a/scripts/assemble-handbook-screenshots.sh b/scripts/assemble-handbook-screenshots.sh index 73aee07fd..e6eab98af 100755 --- a/scripts/assemble-handbook-screenshots.sh +++ b/scripts/assemble-handbook-screenshots.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# Assemble the 301 handbook screenshots from the visual-regression Golden +# Assemble the 304 handbook screenshots from the visual-regression Golden # baselines. The flat `NN-name.png` output layout matches what # docs/handbook/de/index.html links to (`` # — the relative path resolves to `docs/handbook/screenshots/NN-name.png`). @@ -341,6 +341,11 @@ MAPPING=( "290-kyc-registration-referral-promo=screens/kyc/goldens/macos/kyc_registration_referral_step_promo.png" "291-referral-bind-error-invalid=screens/referral/goldens/macos/referral_bind_error_invalid.png" "292-referral-bind-error-already-registered=screens/referral/goldens/macos/referral_bind_error_already_registered.png" + # 293–295: OpenCryptoPay pay whole-share rounding disclosure. No matching + # .maestro/handbook flow — pay is behind the insider unlock in the 01–26 chain. + "293-pay-info=screens/pay/goldens/macos/pay_info_page.png" + "294-pay-quote-ready=screens/pay/goldens/macos/pay_quote_page_ready.png" + "295-pay-quote-ready-merchant=screens/pay/goldens/macos/pay_quote_page_ready_with_merchant.png" ) missing=() diff --git a/test/goldens/screens/pay/goldens/macos/pay_info_page.png b/test/goldens/screens/pay/goldens/macos/pay_info_page.png index c21ea3bd9..9fe11497c 100644 Binary files a/test/goldens/screens/pay/goldens/macos/pay_info_page.png and b/test/goldens/screens/pay/goldens/macos/pay_info_page.png differ diff --git a/test/goldens/screens/pay/goldens/macos/pay_quote_page_ready.png b/test/goldens/screens/pay/goldens/macos/pay_quote_page_ready.png index 9441f7a8a..ad005122a 100644 Binary files a/test/goldens/screens/pay/goldens/macos/pay_quote_page_ready.png and b/test/goldens/screens/pay/goldens/macos/pay_quote_page_ready.png differ diff --git a/test/goldens/screens/pay/goldens/macos/pay_quote_page_ready_with_merchant.png b/test/goldens/screens/pay/goldens/macos/pay_quote_page_ready_with_merchant.png index 5df1e5c2b..f6a4c25b4 100644 Binary files a/test/goldens/screens/pay/goldens/macos/pay_quote_page_ready_with_merchant.png and b/test/goldens/screens/pay/goldens/macos/pay_quote_page_ready_with_merchant.png differ diff --git a/test/goldens/screens/pay/pay_process_golden_test.dart b/test/goldens/screens/pay/pay_process_golden_test.dart index 80f8d4f30..02bb41a2f 100644 --- a/test/goldens/screens/pay/pay_process_golden_test.dart +++ b/test/goldens/screens/pay/pay_process_golden_test.dart @@ -15,6 +15,7 @@ void main() { setUp(() { processCubit = _MockPayProcessCubit(); when(() => processCubit.state).thenReturn(const PayProcessInitial()); + when(() => processCubit.swapCompleted).thenReturn(false); }); // PayProcessPage resolves its cubit from getIt and calls start(); the golden diff --git a/test/integration/pay_ocp_flow_test.dart b/test/integration/pay_ocp_flow_test.dart new file mode 100644 index 000000000..fe7337f5f --- /dev/null +++ b/test/integration/pay_ocp_flow_test.dart @@ -0,0 +1,331 @@ +// Cross-layer integration tests for the OpenCryptoPay pay flow. +// +// These tests stitch together the layers that a 1 EUR / 0.95 ZCHF merchant +// bill touches end-to-end. That bill is the original screenshot bug (quote +// showed 0 REALU sold vs ~0.95 ZCHF needed, Pay disabled) and the leftover +// sweep (the pay unsigned-tx `amountWei` is the full ZCHF balance, not the +// 0.95 quote): +// +// PayQuoteCubit / PayProcessCubit +// → FakeBitboxCredentials.signToSignature (BitBox boundary, no real +// BLE/USB stack) +// → RealUnitPayService (real production class) +// → MockClient (last-mile HTTP boundary stand-in) +// +// We deliberately wire up the real RealUnitPayService instead of stubbing +// it — the goal is to pin the contract between the cubits and the service +// AND the wire-format the service produces. The MockClient plays the fixed +// API: PUT /v1/realunit/swap with targetAmount 0.95 returns amount: 1, +// isValid: true. The app does not ceil shares locally; Ready displays the +// JSON `amount`. +// +// They run headless (no device, no simulator, no live merchant QR), so they +// live under `test/integration/` and run as part of `flutter test`. + +import 'dart:convert'; + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/config/network_mode.dart'; +import 'package:realunit_wallet/packages/repository/cache_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_blockchain_api_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_faucet_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart'; +import 'package:realunit_wallet/packages/service/session_cache.dart'; +import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/packages/wallet/wallet_account.dart'; +import 'package:realunit_wallet/screens/pay/cubits/pay_process/pay_process_cubit.dart'; +import 'package:realunit_wallet/screens/pay/cubits/pay_quote/pay_quote_cubit.dart'; + +import '../helper/fake_bitbox_credentials.dart'; + +class _MockFaucet extends Mock implements DfxFaucetService {} + +class _MockBlockchain extends Mock implements DfxBlockchainApiService {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockWallet extends Mock implements AWallet {} + +class _MockWalletAccount extends Mock implements AWalletAccount {} + +class _MockWalletService extends Mock implements WalletService {} + +class _MockCacheRepository extends Mock implements CacheRepository {} + +const _paymentLinkId = 'pl_realunit_ocp_1eur'; +const _lnurlpPath = '/v1/lnurlp/pl_realunit_ocp_1eur'; +const _swapPath = '/v1/realunit/swap'; +const _swapUnsignedPath = '/v1/realunit/swap/99/unsigned-transaction'; +const _swapBroadcastPath = '/v1/realunit/swap/99/broadcast'; +const _payUnsignedPath = '/v1/realunit/pay/unsigned-transaction'; +const _paySubmitPath = '/v1/realunit/pay/submit'; +const _payStatusPath = '/v1/realunit/pay/pl_realunit_ocp_1eur/status'; + +// Verified EIP-1559 unsigned pay tx from pay_process_cubit_test.dart +// (`amountWei: 5 ZCHF` vs a 0.95 quote — the leftover overpay). The app must +// forward this hex unchanged; it must not shrink amountWei to the quote. +const _unsignedPayHex = + '0x02f87183aa36a7018459682f008504a817c800830186a094111111111111111111111111111111111111ac0180b844a9059cbb000000000000000000000000222222222222222222222222222222222222bc020000000000000000000000000000000000000000000000004563918244f40000c0'; + +Map _lnurlpJson() => { + 'requestedAmount': {'asset': 'EUR', 'amount': 1}, + 'quote': { + 'id': 'plq_1eur', + 'expiration': DateTime.now().add(const Duration(minutes: 5)).toIso8601String(), + }, + 'transferAmounts': [ + { + 'method': 'Ethereum', + 'assets': [ + {'asset': 'ZCHF', 'amount': 0.95}, + ], + }, + ], +}; + +Map _swapInfoJson({ + required num amount, + required bool isValid, + String? error, +}) => { + 'id': 99, + 'uid': 'MOCK-UID', + 'routeId': 7, + 'timestamp': '2026-06-03T00:00:00.000Z', + 'amount': amount, + 'estimatedAmount': 1.05, + 'targetAsset': 'ZCHF', + 'minVolume': 1, + 'maxVolume': 1000, + 'minVolumeTarget': 95, + 'maxVolumeTarget': 95000, + 'ethBalance': 1.0, + 'requiredGasEth': 0.001, + 'isValid': isValid, + if (error != null) 'error': error, +}; + +Map _unsignedPayJson() => { + 'unsignedTx': _unsignedPayHex, + 'tokenAddress': '0x111111111111111111111111111111111111ac01', + 'recipient': '0x222222222222222222222222222222222222bc02', + 'amountWei': '5000000000000000000', + 'chainId': 11155111, +}; + +void main() { + late _MockFaucet faucet; + late _MockBlockchain blockchain; + late _MockAppStore appStore; + late _MockWallet wallet; + late _MockWalletAccount account; + late _MockWalletService walletService; + late SessionCache session; + late FakeBitboxCredentials creds; + + setUp(() { + faucet = _MockFaucet(); + blockchain = _MockBlockchain(); + appStore = _MockAppStore(); + wallet = _MockWallet(); + account = _MockWalletAccount(); + walletService = _MockWalletService(); + session = SessionCache(_MockCacheRepository()); + // Pre-seed an auth token so the service skips its sign-message round-trip. + session.setAuthToken('jwt-test'); + + creds = FakeBitboxCredentials(signDelay: Duration.zero); + + when(() => appStore.wallet).thenReturn(wallet); + when( + () => appStore.apiConfig, + ).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); + when(() => appStore.sessionCache).thenReturn(session); + when(() => appStore.primaryAddress).thenReturn(creds.address.hexEip55); + when(() => wallet.walletType).thenReturn(WalletType.bitbox); + when(() => wallet.currentAccount).thenReturn(account); + when(() => account.primaryAddress).thenReturn(creds); + when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {}); + when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {}); + }); + + // Build a real [RealUnitPayService] backed by [client]. The cubits talk to + // this service object — there is no service-level stub between the cubit + // and the HTTP boundary. + RealUnitPayService buildPayService(http.Client client) { + when(() => appStore.httpClient).thenReturn(client); + return RealUnitPayService(appStore, walletService); + } + + group('pay OCP flow cross-layer: cubit → BitBox boundary → service → MockClient', () { + test( + '1 EUR / 0.95 ZCHF quote with a 1-share swap preview becomes PayQuoteReady', + () async { + // Original screenshot bug: 0 REALU sold / grey Pay. The MockClient + // plays the fixed API (targetAmount 0.95 → amount: 1, isValid: true). + // Ready with 1 share is the fix — the harness does not ceil locally. + Map? swapBody; + final client = MockClient((request) async { + if (request.method == 'GET' && request.url.path == _lnurlpPath) { + return http.Response(jsonEncode(_lnurlpJson()), 200); + } + if (request.method == 'PUT' && request.url.path == _swapPath) { + swapBody = jsonDecode(request.body) as Map; + return http.Response( + jsonEncode(_swapInfoJson(amount: 1, isValid: true)), + 200, + ); + } + fail('unexpected ${request.method} ${request.url.path}'); + }); + + final cubit = PayQuoteCubit(buildPayService(client), _paymentLinkId); + await cubit.load(); + + expect(cubit.state, isA()); + final state = cubit.state as PayQuoteReady; + expect(state.fiatAsset, 'EUR'); + expect(state.fiatAmount, 1); + expect(state.zchfAmount, 0.95); + expect(state.realuAmount, 1); + expect(state.realuEstimatedZchf, 1.05); + + expect(swapBody, isNotNull); + expect(swapBody!['targetAmount'], 0.95); + expect(swapBody!.containsKey('amount'), isFalse); + + await cubit.close(); + }, + ); + + test('invalid 0-share swap preview is PayQuoteError, never Ready', () async { + // Same lnurlp bill; the API returns amount: 0 / isValid: false. The app + // surfaces AmountTooLow 1:1 and does not invent a 1-share Ready state. + final client = MockClient((request) async { + if (request.method == 'GET' && request.url.path == _lnurlpPath) { + return http.Response(jsonEncode(_lnurlpJson()), 200); + } + if (request.method == 'PUT' && request.url.path == _swapPath) { + return http.Response( + jsonEncode( + _swapInfoJson(amount: 0, isValid: false, error: 'AmountTooLow'), + ), + 200, + ); + } + fail('unexpected ${request.method} ${request.url.path}'); + }); + + final cubit = PayQuoteCubit(buildPayService(client), _paymentLinkId); + await cubit.load(); + + expect(cubit.state, isA()); + expect(cubit.state, isNot(isA())); + expect((cubit.state as PayQuoteError).message, 'AmountTooLow'); + + await cubit.close(); + }); + + test('process pays the unsigned amountWei (full ZCHF), not the 0.95 quote', () { + fakeAsync((async) { + // The sign step uses `Future.delayed(Duration.zero)` (FakeBitboxCredentials), + // which is a zero-duration *timer* under fakeAsync — `flushMicrotasks` alone + // does not fire it. Elapsing zero repeatedly drains the whole await chain + // (each MockClient future + every zero-delay sign timer) until the cubit + // settles. Then elapse 3s for the Completed status poll. + void drain() { + for (var i = 0; i < 40; i++) { + async.flushMicrotasks(); + async.elapse(Duration.zero); + } + } + + Map? swapBody; + Map? submitBody; + + final client = MockClient((request) async { + final path = request.url.path; + if (request.method == 'GET' && path == _lnurlpPath) { + return http.Response(jsonEncode(_lnurlpJson()), 200); + } + if (request.method == 'PUT' && path == _swapPath) { + swapBody = jsonDecode(request.body) as Map; + return http.Response( + jsonEncode(_swapInfoJson(amount: 1, isValid: true)), + 200, + ); + } + if (request.method == 'PUT' && path == _swapUnsignedPath) { + return http.Response(jsonEncode({'swap': '0x02f8aa'}), 200); + } + if (request.method == 'PUT' && path == _swapBroadcastPath) { + return http.Response(jsonEncode({'txHash': '0xswaptx'}), 200); + } + if (request.method == 'PUT' && path == _payUnsignedPath) { + return http.Response(jsonEncode(_unsignedPayJson()), 200); + } + if (request.method == 'PUT' && path == _paySubmitPath) { + submitBody = jsonDecode(request.body) as Map; + return http.Response(jsonEncode({'txId': '0xpaytx'}), 200); + } + if (request.method == 'GET' && path == _payStatusPath) { + return http.Response(jsonEncode({'status': 'Completed'}), 200); + } + fail('unexpected ${request.method} ${request.url.path}'); + }); + + // _unsignedPay fixture is chainId 11155111 (Sepolia). PayProcessCubit + // binds the RLP chainId to apiConfig.asset.chainId; mainnet would fail + // as unsignedTxMismatch. + when( + () => appStore.apiConfig, + ).thenReturn(const ApiConfig(networkMode: NetworkMode.testnet)); + final payService = buildPayService(client); + + final cubit = PayProcessCubit( + payService: payService, + faucetService: faucet, + blockchainService: blockchain, + walletService: walletService, + appStore: appStore, + paymentLinkId: _paymentLinkId, + zchfNeeded: 0.95, + ); + cubit.start(); + drain(); + + expect( + cubit.state, + anyOf(isA(), isA()), + ); + + expect(swapBody, isNotNull); + expect(swapBody!['targetAmount'], closeTo(0.95 * 1.03, 0.0001)); + expect(swapBody!.containsKey('amount'), isFalse); + + expect(submitBody, isNotNull); + expect(submitBody!['unsignedTx'], _unsignedPayHex); + expect(submitBody!['r'], isNotNull); + expect(submitBody!['s'], isNotNull); + expect(submitBody!.containsKey('v'), isTrue); + + async.elapse(const Duration(seconds: 3)); + drain(); + expect( + cubit.state, + anyOf(isA(), isA()), + ); + + cubit.close(); + async.flushTimers(); + }); + }); + }); +} diff --git a/test/screens/pay/pay_info_page_test.dart b/test/screens/pay/pay_info_page_test.dart index edbea6c40..1c917c128 100644 --- a/test/screens/pay/pay_info_page_test.dart +++ b/test/screens/pay/pay_info_page_test.dart @@ -15,6 +15,10 @@ void main() { expect(find.text(S.current.payInfoTitle), findsOneWidget); expect(find.text(S.current.payInfoBody), findsOneWidget); expect(find.text(S.current.next), findsOneWidget); + expect( + S.current.payInfoBody, + anyOf(contains('ganze REALU'), contains('whole REALU shares')), + ); }); testWidgets('continues to the scanner with the initial payload', (tester) async { diff --git a/test/screens/pay/pay_process_cubit_test.dart b/test/screens/pay/pay_process_cubit_test.dart index cfcb40dbd..3e9b1ab3e 100644 --- a/test/screens/pay/pay_process_cubit_test.dart +++ b/test/screens/pay/pay_process_cubit_test.dart @@ -57,6 +57,7 @@ SwapPaymentInfo _swap({ double ethBalance = 1.0, double requiredGasEth = 0.001, bool isValid = true, + String? error, }) { return SwapPaymentInfo.fromDto( RealUnitSwapPaymentInfoDto( @@ -74,6 +75,7 @@ SwapPaymentInfo _swap({ ethBalance: ethBalance, requiredGasEth: requiredGasEth, isValid: isValid, + error: error, ), ); } @@ -313,14 +315,17 @@ void main() { await cubit.close(); }); - test('invalid swap quote → insufficientZchf', () async { - when(() => payService.getSwapPaymentInfo(any())).thenAnswer((_) async => _swap(isValid: false)); + test('invalid swap quote surfaces the API error 1:1', () async { + when(() => payService.getSwapPaymentInfo(any())).thenAnswer( + (_) async => _swap(isValid: false, error: 'AmountTooLow'), + ); final cubit = build(); await cubit.start(); final state = cubit.state as PayProcessFailure; - expect(state.reason, PayProcessFailureReason.insufficientZchf); + expect(state.reason, PayProcessFailureReason.generic); + expect(state.message, 'AmountTooLow'); await cubit.close(); }); @@ -984,6 +989,23 @@ void main() { await cubit.close(); }); + test('broadcast throw after sign is a pay-only retry, not a re-scan', () async { + wireHappyPath(); + when( + () => payService.broadcastSwapTransaction(any(), any()), + ).thenThrow(Exception('timeout')); + + final cubit = build(); + final retry = cubit.stream.firstWhere((s) => s is PayProcessPayRetry); + await cubit.start(); + final state = await retry as PayProcessPayRetry; + + expect(state.reason, PayRetryReason.transient); + expect(state.message, isNull); + expect(cubit.swapCompleted, isTrue); + await cubit.close(); + }); + test('transient quote re-fetch failure after swap → retry (not re-scan)', () async { wireHappyPath(); when(() => payService.getPaymentDetails('pl_abc')).thenThrow(Exception('lnurlp 500')); diff --git a/test/screens/pay/pay_process_page_test.dart b/test/screens/pay/pay_process_page_test.dart index 2882fcfe0..531232b94 100644 --- a/test/screens/pay/pay_process_page_test.dart +++ b/test/screens/pay/pay_process_page_test.dart @@ -66,6 +66,7 @@ void main() { processCubit = _MockPayProcessCubit(); when(() => processCubit.state).thenReturn(const PayProcessInitial()); when(() => processCubit.retryPay()).thenAnswer((_) async {}); + when(() => processCubit.swapCompleted).thenReturn(false); }); Widget buildSubject() => BlocProvider.value( @@ -220,6 +221,63 @@ void main() { await tester.pump(const Duration(milliseconds: 400)); } + /// Pushes [PayProcessView] onto a navigator so the `push` result can + /// be captured. Phone-sized surface matches [pumpWithState] so result sheets + /// fit the hit box. + Future pumpPushedView( + WidgetTester tester, { + required void Function(bool? result) onPopped, + }) async { + tester.view.physicalSize = const Size(1200, 2400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpApp( + Builder( + builder: (context) => Scaffold( + body: Center( + child: TextButton( + onPressed: () async { + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => BlocProvider.value( + value: processCubit, + child: const PayProcessView(), + ), + ), + ); + onPopped(result); + }, + child: const Text('go'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('go')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + } + + Future pumpPushedWithState( + WidgetTester tester, + PayProcessState terminal, { + required void Function(bool? result) onPopped, + }) async { + whenListen( + processCubit, + Stream.fromIterable([terminal]), + initialState: const PayProcessSwapping(), + ); + await pumpPushedView(tester, onPopped: onPopped); + // Incoming route animation + sheet open (same extra pumps as the + // TimedMaterialPageRoute page test). + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + } + group('$PayProcessView result sheet', () { testWidgets('success emits a success sheet with title + description', (tester) async { await pumpWithState(tester, const PayProcessSuccess()); @@ -236,19 +294,6 @@ void main() { expect(find.byIcon(Icons.check_circle_rounded), findsNothing); }); - testWidgets('insufficient-zchf failure emits a failure sheet', (tester) async { - await pumpWithState( - tester, - const PayProcessFailure(PayProcessFailureReason.insufficientZchf), - ); - - // payFailureTitle also renders as the progress-label behind the sheet, - // so it appears twice; the reason message is the sheet-unique assertion. - expect(find.text(S.current.payFailureTitle), findsWidgets); - expect(find.text(S.current.payFailureInsufficientZchf), findsOne); - expect(find.byIcon(Icons.error_rounded), findsOne); - }); - testWidgets('insufficient-eth failure message', (tester) async { await pumpWithState( tester, @@ -352,4 +397,52 @@ void main() { expect(find.text(S.current.payRetryTransient), findsNothing); }); }); + + group('$PayProcessView swapCompleted pop', () { + testWidgets('failure sheet after a completed swap pops the process route with true', ( + tester, + ) async { + when(() => processCubit.swapCompleted).thenReturn(true); + + bool? popped; + await pumpPushedWithState( + tester, + const PayProcessFailure(PayProcessFailureReason.generic), + onPopped: (result) => popped = result, + ); + + expect(find.text(S.current.close), findsOne); + // pumpPushedWithState nests an extra navigator; Close lands off-screen. + final navigator = tester.state(find.byType(Navigator).first); + navigator.pop(); // dismiss the sheet + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.byType(PayProcessView), findsNothing); + expect(popped, isTrue); + }); + + testWidgets('failure sheet before a swap pops the process route with false', (tester) async { + bool? popped; + await pumpPushedWithState( + tester, + const PayProcessFailure(PayProcessFailureReason.generic), + onPopped: (result) => popped = result, + ); + + expect(find.text(S.current.close), findsOne); + // pumpPushedWithState nests an extra navigator; Close lands off-screen. + final navigator = tester.state(find.byType(Navigator).first); + navigator.pop(); // dismiss the sheet + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.byType(PayProcessView), findsNothing); + expect(popped, isFalse); + }); + }); } diff --git a/test/screens/pay/pay_quote_cubit_test.dart b/test/screens/pay/pay_quote_cubit_test.dart index d92901741..2df72fbd7 100644 --- a/test/screens/pay/pay_quote_cubit_test.dart +++ b/test/screens/pay/pay_quote_cubit_test.dart @@ -42,6 +42,8 @@ SwapPaymentInfo _swap({ double amount = 5, double estimatedAmount = 1.98, double? feesTotal = 0.02, + bool isValid = true, + String? error, }) { return SwapPaymentInfo( id: 99, @@ -50,7 +52,8 @@ SwapPaymentInfo _swap({ targetAsset: 'ZCHF', ethBalance: 1.0, requiredGasEth: 0.001, - isValid: true, + isValid: isValid, + error: error, feesTotal: feesTotal, ); } @@ -141,6 +144,50 @@ void main() { expect: () => [isA(), isA()], ); + blocTest( + 'an invalid swap quote with AmountTooLow emits PayQuoteError and not Ready', + build: build, + setUp: () { + when(() => payService.getPaymentDetails('pl_realunit_ocp_sepolia')).thenAnswer( + (_) async => _details(expiration: DateTime.now().add(const Duration(minutes: 5))), + ); + when(() => payService.getSwapPaymentInfo(any())).thenAnswer( + (_) async => _swap(isValid: false, error: 'AmountTooLow', amount: 0), + ); + }, + act: (cubit) => cubit.load(), + expect: () => [ + isA(), + const PayQuoteError('AmountTooLow'), + ], + verify: (cubit) { + expect(cubit.state, isA()); + expect(cubit.state, isNot(isA())); + expect((cubit.state as PayQuoteError).message, 'AmountTooLow'); + }, + ); + + blocTest( + 'an invalid swap quote with KycRequired emits that API error 1:1', + build: build, + setUp: () { + when(() => payService.getPaymentDetails('pl_realunit_ocp_sepolia')).thenAnswer( + (_) async => _details(expiration: DateTime.now().add(const Duration(minutes: 5))), + ); + when(() => payService.getSwapPaymentInfo(any())).thenAnswer( + (_) async => _swap(isValid: false, error: 'KycRequired'), + ); + }, + act: (cubit) => cubit.load(), + expect: () => [ + isA(), + const PayQuoteError('KycRequired'), + ], + verify: (cubit) { + expect(cubit.state, isNot(isA())); + }, + ); + blocTest( 'a service error emits PayQuoteError', build: build, diff --git a/test/screens/pay/pay_quote_page_test.dart b/test/screens/pay/pay_quote_page_test.dart index f4bf037e7..26e422295 100644 --- a/test/screens/pay/pay_quote_page_test.dart +++ b/test/screens/pay/pay_quote_page_test.dart @@ -1,5 +1,6 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; @@ -115,6 +116,7 @@ void main() { expect(find.text(S.current.payQuoteSummary('2.00', 'CHF')), findsOne); expect(find.text('2.00 CHF'), findsOne); expect(find.text('2.00 ZCHF'), findsOne); + expect(find.text(S.current.payQuoteRoundingNotice), findsOne); expect(find.text(S.current.payConfirmButton), findsOne); }); @@ -139,6 +141,7 @@ void main() { expect(find.text('5 REALU'), findsOne); expect(find.text('1.98 ZCHF'), findsOne); expect(find.text('0.02 REALU'), findsOne); + expect(find.text(S.current.payQuoteRoundingNotice), findsOne); }); testWidgets('confirm button navigates to the process step', (tester) async { @@ -155,6 +158,50 @@ void main() { expect(find.byType(PayProcessView), findsOne); }); + testWidgets('double-tap on Pay does not push two process routes', (tester) async { + when(() => quoteCubit.state).thenReturn(ready); + await tester.pumpApp(buildSubject()); + + await tester.tap(find.text(S.current.payConfirmButton)); + await tester.tap(find.text(S.current.payConfirmButton), warnIfMissed: false); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + // Offstage: a second pushed route would hide the first; count both. + expect(find.byType(PayProcessView, skipOffstage: false), findsOne); + }); + + testWidgets('Pay re-enables after the process route pops', (tester) async { + when(() => quoteCubit.state).thenReturn(ready); + await tester.pumpApp(buildSubject()); + + await tester.tap(find.text(S.current.payConfirmButton)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.byType(PayProcessView), findsOne); + + // Debug wallet fails before the swap and shows a result sheet. Pop the + // sheet; the process route then pops with swapCompleted=false so Pay + // re-enables. Avoid tap(): the sheet is taller than the default test + // surface, so Close is outside the hit box. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + final navigator = tester.state(find.byType(Navigator).first); + navigator.pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + navigator.pop(false); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.byType(PayProcessView), findsNothing); + final button = tester.widget( + find.widgetWithText(FilledButton, S.current.payConfirmButton), + ); + expect(button.onPressed, isNotNull); + }); + testWidgets('expired state shows the re-scan message', (tester) async { when(() => quoteCubit.state).thenReturn(const PayQuoteExpired()); await tester.pumpApp(buildSubject()); @@ -175,6 +222,7 @@ void main() { expect(find.text('boom'), findsOne); expect(find.text(S.current.payFailureGeneric), findsNothing); + expect(find.text(S.current.payConfirmButton), findsNothing); }); testWidgets('error state without API text falls back to the generic copy', (tester) async { @@ -182,6 +230,7 @@ void main() { await tester.pumpApp(buildSubject()); expect(find.text(S.current.payFailureGeneric), findsOne); + expect(find.text(S.current.payConfirmButton), findsNothing); }); testWidgets('error state retry button re-invokes load()', (tester) async {