From 10dec80f84359b3861872524b6b557f3819bbf32 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:50:28 +0200 Subject: [PATCH 1/8] feat(maestro): add KYC link-wallet BitBox-connect E2E flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Maestro handbook flows for the KYC "add wallet" path with a regression guard for the BitBox-connect sheet (PR #720). Introduces an in-process HTTP mock layer (`MAESTRO_MOCK`) that enables authenticated KYC flows without a real backend. - MaestroMockClient: intercepts DFX API calls under MAESTRO_MOCK - MaestroRegistrationService: throws BitboxNotConnectedException on first registerWallet() call to exercise the connect-sheet path - Two new handbook flows (11a/11b): navigate KYC → legal disclaimer → link-wallet page → tap submit → BitBox connect sheet → dismiss - CI build tier3-handbook with --dart-define=MAESTRO_MOCK=true - BuyView redirects to /kyc in mock mode for deterministic entry --- .github/workflows/tier3-handbook.yaml | 13 +- .maestro/handbook/11a-kyc-link-wallet.yaml | 50 ++++++ .../11b-kyc-link-wallet-bitbox-sheet.yaml | 93 +++++++++++ lib/packages/config/api_config.dart | 6 +- lib/packages/service/app_store.dart | 5 +- .../service/dfx/maestro_mock_client.dart | 157 ++++++++++++++++++ .../dfx/maestro_registration_service.dart | 27 +++ lib/screens/buy/buy_page.dart | 13 ++ lib/setup/di.dart | 8 +- scripts/run-handbook-flows.sh | 2 +- 10 files changed, 364 insertions(+), 10 deletions(-) create mode 100644 .maestro/handbook/11a-kyc-link-wallet.yaml create mode 100644 .maestro/handbook/11b-kyc-link-wallet-bitbox-sheet.yaml create mode 100644 lib/packages/service/dfx/maestro_mock_client.dart create mode 100644 lib/packages/service/dfx/maestro_registration_service.dart diff --git a/.github/workflows/tier3-handbook.yaml b/.github/workflows/tier3-handbook.yaml index eb83f5f2a..457471bda 100644 --- a/.github/workflows/tier3-handbook.yaml +++ b/.github/workflows/tier3-handbook.yaml @@ -87,7 +87,7 @@ name: Tier 3 — Handbook flows # reflow elements out of the tap-coordinate path or shift safe-area # insets in a way that breaks the assertions. When the macOS runner # image stops shipping iPhone 17 by default, bump this here AND -# re-verify all 26 flows on the new device (run +# re-verify all 28 flows on the new device (run # `scripts/run-handbook-flows.sh` locally; assertion failures point # at flows that need their coordinates / waits updated). # @@ -133,8 +133,8 @@ jobs: || contains(github.event.pull_request.labels.*.name, 'tier3:full') runs-on: macos-latest # ~12 min setup (Flutter + tooling, build, sim erase/boot/install) plus - # ~26-33 min for the 26 handbook flows — each flow restarts the XCUITest - # driver (~40-60 s). A full run lands around 40-46 min; 60 min gives + # ~28-36 min for the 28 handbook flows — each flow restarts the XCUITest + # driver (~40-60 s). A full run lands around 42-50 min; 60 min gives # headroom for runner-speed variance and the per-flow retry budget. timeout-minutes: 60 steps: @@ -182,7 +182,12 @@ jobs: ios-derived-data-${{ runner.os }}-${{ steps.xcode.outputs.version }}- - name: Build iOS simulator app - run: flutter build ios --simulator --debug + # MAESTRO_MOCK enables in-process HTTP mocking for authenticated + # flows (KYC, add-wallet) that need canned API responses. It also + # sets ApiConfig._localTesting=true, routing ALL DFX API traffic + # through MaestroMockClient — unknown paths receive empty 200 + # responses so dashboard/settings flows don't crash. + run: flutter build ios --simulator --debug --dart-define=MAESTRO_MOCK=true # `scripts/run-handbook-flows.sh` does its own `simctl shutdown / erase / # boot / bootstatus / install` once it has a booted UDID. We just need diff --git a/.maestro/handbook/11a-kyc-link-wallet.yaml b/.maestro/handbook/11a-kyc-link-wallet.yaml new file mode 100644 index 000000000..5b709d175 --- /dev/null +++ b/.maestro/handbook/11a-kyc-link-wallet.yaml @@ -0,0 +1,50 @@ +# Tier-3 E2E: KYC "add wallet" (link wallet) flow — part 1 of 2. +# +# Prerequisite: flow 11-dashboard must have run (app is on dashboard with +# a software wallet, PIN verified). This flow relies on the MAESTRO_MOCK +# build flag: tapping "RealUnit kaufen" opens /buy, and the mock mode +# redirects /buy → /kyc immediately. The mock API then returns +# `AddWallet` registration info (after the legal disclaimer is accepted), +# routing the app to the KycLinkWalletPage. +# +# Regression guard for PR #720: this flow verifies that the link-wallet +# page renders the "shareholder confirmed" UI and is ready for the +# BitBox-connect sheet test in flow 11b. +# +# The handbook screenshot for this flow maps to the Golden baseline +# kyc_link_wallet_page_default.png via scripts/assemble-handbook-screenshots.sh. +appId: swiss.realunit.app +--- +# Navigate from dashboard to KYC via the buy-page redirect (mock mode only). +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: 'Rechtlicher Hinweis' + commands: + - tapOn: + text: 'RealUnit kaufen' + optional: true +- waitForAnimationToEnd + +# The legal disclaimer has 5 steps (0-4). Each step shows a "Ja" / "Nein" +# button pair — "Ja" advances, on the last step it completes and triggers +# checkKyc(). With the mock API the next route is KycLinkWalletPage. +- extendedWaitUntil: + visible: 'Rechtlicher Hinweis' + timeout: 15000 + +- repeat: + times: 5 + commands: + - tapOn: + text: 'Ja' + - waitForAnimationToEnd + +# After accepting the legal disclaimer, checkKyc() fetches registration +# info from the mock → state=AddWallet → KycLinkWalletPage renders with +# the shareholder-confirmation description. +- extendedWaitUntil: + visible: 'Sie sind als Aktionär eingetragen' + timeout: 30000 diff --git a/.maestro/handbook/11b-kyc-link-wallet-bitbox-sheet.yaml b/.maestro/handbook/11b-kyc-link-wallet-bitbox-sheet.yaml new file mode 100644 index 000000000..a0dd45978 --- /dev/null +++ b/.maestro/handbook/11b-kyc-link-wallet-bitbox-sheet.yaml @@ -0,0 +1,93 @@ +# Tier-3 E2E: KYC "add wallet" (link wallet) BitBox-connect sheet — part 2 of 2. +# +# Prerequisite: flow 11a-kyc-link-wallet must have run (app is on +# KycLinkWalletPage with the shareholder-confirmation body visible). +# +# Regression guard for PR #720: verifies that tapping "Wallet hinzufügen" +# WITHOUT a connected BitBox opens the ConnectBitboxPage bottom sheet +# instead of surfacing a dead-end red "BitBox is not connected" error. +# +# The MaestroRegistrationService (active under MAESTRO_MOCK) throws +# BitboxNotConnectedException on the first registerWallet() call, which +# the page catches and translates into the connect-sheet UI. +# Retry (after a successful connect) calls through to the real service +# and the mock API returns {"status":"completed"} — covered by widget +# tests, not repeated here. +# +# The sheet is dismissed by tapping the background scrim (top ~20% of +# screen where the sheet does not reach). After dismissal the page must +# still show the link-wallet body so the user can try again. +# +# Finally, the flow navigates back to the dashboard (through the buy +# page that redirected here) so the next handbook flow (12-settings) +# starts from the expected state. +appId: swiss.realunit.app +--- +# Tap the submit button. The title "Wallet hinzufügen" (AppBar) and the +# submit button share the same i18n string; we use a bottom-center +# coordinate to reliably hit the button. +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: 'BitBox verbinden' + commands: + - tapOn: + point: '50%,88%' + optional: true + - waitForAnimationToEnd + +# The connect sheet slides up with title "BitBox verbinden". The initial +# state is BitboxConnecting (scanning for devices). Just seeing the +# sheet confirms the regression guard — the bug (#720) would have shown +# a SnackBar error instead. +- extendedWaitUntil: + visible: 'BitBox verbinden' + timeout: 15000 + +# Dismiss the sheet by tapping outside (top portion of the screen where +# the scrim is visible). The sheet covers ~80% of the screen height, so +# the top ~10% is safe scrim area. +- tapOn: + point: '50%,10%' +- waitForAnimationToEnd + +# After dismissal, the link-wallet body must still be visible — the user +# is not stuck on an error page and can try again. +- extendedWaitUntil: + visible: 'Sie sind als Aktionär eingetragen' + timeout: 10000 + +# Navigate back to the dashboard for the next flow (12-settings). +# First back: link-wallet page → buy page (the page that redirected to +# KYC in mock mode). +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: 'RealUnit kaufen' + commands: + - tapOn: + point: '8%,8%' + optional: true + - waitForAnimationToEnd +# Second back (if needed): buy page → dashboard. The buy page has a +# standard AppBar back button because it was pushed from dashboard. +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: 'RealUnit kaufen' + commands: + - tapOn: + point: '8%,8%' + optional: true + - waitForAnimationToEnd + +# Confirm we landed on the dashboard. +- extendedWaitUntil: + visible: 'RealUnit kaufen' + timeout: 15000 diff --git a/lib/packages/config/api_config.dart b/lib/packages/config/api_config.dart index 76f4ca2cb..e2fd8f0d9 100644 --- a/lib/packages/config/api_config.dart +++ b/lib/packages/config/api_config.dart @@ -2,8 +2,10 @@ import 'package:realunit_wallet/models/asset.dart'; import 'package:realunit_wallet/packages/config/network_mode.dart'; import 'package:realunit_wallet/packages/utils/default_assets.dart'; -/// if true, requires to have a local running backend -bool get _localTesting => false; +/// `true` when compiled with `--dart-define=MAESTRO_MOCK=true` so the app +/// routes all DFX API calls through the in-process [MaestroMockClient]. +/// In production this is always `false`. +bool get _localTesting => const bool.fromEnvironment('MAESTRO_MOCK', defaultValue: false); class ApiConfig { final NetworkMode networkMode; diff --git a/lib/packages/service/app_store.dart b/lib/packages/service/app_store.dart index 294c74389..6d698a4ee 100644 --- a/lib/packages/service/app_store.dart +++ b/lib/packages/service/app_store.dart @@ -7,11 +7,12 @@ import 'package:realunit_wallet/packages/wallet/wallet.dart'; class AppStore { final ApiConfig Function() getApiConfig; final SessionCache sessionCache; - final Client httpClient = RealUnitApiClient(); + final Client httpClient; AWallet? _wallet; - AppStore(this.getApiConfig, this.sessionCache); + AppStore(this.getApiConfig, this.sessionCache, [Client? httpClient]) + : httpClient = httpClient ?? RealUnitApiClient(); set wallet(AWallet wallet_) => _wallet = wallet_; diff --git a/lib/packages/service/dfx/maestro_mock_client.dart b/lib/packages/service/dfx/maestro_mock_client.dart new file mode 100644 index 000000000..597b68cc2 --- /dev/null +++ b/lib/packages/service/dfx/maestro_mock_client.dart @@ -0,0 +1,157 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart'; + +/// Intercepts HTTP calls when the app is compiled with +/// `--dart-define=MAESTRO_MOCK=true` and returns canned JSON responses for +/// all DFX API endpoints the KYC / link-wallet flow needs. +/// +/// Unknown paths receive a default empty 200 to prevent crashes on +/// dashboard / price / balance calls. The mock is only compiled into the +/// binary when [inMaestroMockMode] is true — production builds are +/// unaffected. +class MaestroMockClient extends BaseClient { + static const _mockToken = 'maestro-mock-token'; + static const _mockKycHash = 'maestro-kyc-hash'; + + /// Whether the current build was compiled with `MAESTRO_MOCK=true`. + static bool get inMaestroMockMode => + const bool.fromEnvironment('MAESTRO_MOCK', defaultValue: false); + + @override + Future send(BaseRequest request) async { + final path = request.url.path; + final method = request.method; + + final resp = _response(method, path); + if (resp != null) return resp; + + // Unknown path — return a valid empty JSON response so the app + // doesn't crash on dashboard / price / balance API calls. + return _json(200, {}); + } + + StreamedResponse? _response(String method, String path) { + switch (path) { + case '/v1/auth': + if (method == 'POST') { + return _json(201, {'accessToken': _mockToken}); + } + break; + case '/v2/user': + if (method == 'GET') { + return _json(200, _user()); + } + if (method == 'PUT') { + return _json(200, _user()); + } + break; + case '/v2/kyc': + if (method == 'GET') { + return _json(200, _kycStatus()); + } + if (method == 'PUT') { + return _json(200, _continueKycResponse()); + } + break; + case '/v1/realunit/registration': + if (method == 'GET') { + return _json(200, { + 'state': 'AddWallet', + 'userData': _userData(), + }); + } + break; + case '/v1/realunit/register/wallet': + if (method == 'POST') { + return _json(201, {'status': 'completed'}); + } + break; + case '/v1/realunit/register/email': + if (method == 'POST') { + return _json(201, {'status': 'completed'}); + } + break; + } + return null; + } + + Map _user() => { + 'mail': 'shareholder@example.com', + 'kyc': { + 'hash': _mockKycHash, + 'level': 10, + 'dataComplete': true, + }, + 'capabilities': {}, + }; + + Map _kycStatus() => { + 'kycLevel': 10, + 'kycSteps': _kycSteps(), + 'processStatus': 'InProgress', + }; + + Map _continueKycResponse() => { + 'currentStep': { + 'name': 'ContactData', + 'session': {'url': 'https://localhost:3000/v2/kyc/session/1'}, + }, + }; + + List> _kycSteps() => [ + { + 'name': 'ContactData', + 'status': 'Completed', + 'sequenceNumber': 0, + 'isCurrent': false, + 'isRequired': true, + }, + { + 'name': 'RealUnitRegistration', + 'status': 'Completed', + 'sequenceNumber': 1, + 'isCurrent': false, + 'isRequired': true, + }, + ]; + + Map _userData() => { + 'email': 'shareholder@example.com', + 'name': 'Max Mustermann', + 'type': 'Personal', + 'phoneNumber': '+41791234567', + 'birthday': '1990-01-01', + 'nationality': 'CH', + 'addressStreet': 'Bahnhofstrasse 1', + 'addressPostalCode': '8001', + 'addressCity': 'Zürich', + 'addressCountry': 'CH', + 'swissTaxResidence': true, + 'lang': 'DE', + 'kycData': { + 'accountType': 'Personal', + 'firstName': 'Max', + 'lastName': 'Mustermann', + 'phone': '+41791234567', + 'address': { + 'street': 'Bahnhofstrasse', + 'houseNumber': '1', + 'zip': '8001', + 'city': 'Zürich', + 'country': 1, + }, + }, + }; + + StreamedResponse _json(int code, Object body) { + final bytes = utf8.encode(jsonEncode(body)); + return StreamedResponse( + Stream.value(bytes), + code, + contentLength: bytes.length, + headers: {'content-type': 'application/json'}, + ); + } +} diff --git a/lib/packages/service/dfx/maestro_registration_service.dart b/lib/packages/service/dfx/maestro_registration_service.dart new file mode 100644 index 000000000..3493b31ee --- /dev/null +++ b/lib/packages/service/dfx/maestro_registration_service.dart @@ -0,0 +1,27 @@ +import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_status.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; + +/// Overrides [RealUnitRegistrationService.registerWallet] so the first call +/// throws [BitboxNotConnectedException] — exactly the scenario the KYC +/// link-wallet connect sheet guards against (bug fix PR #720). +/// +/// Subsequent calls delegate to the real service. The state resets when the +/// app process restarts, matching the Maestro handbook flow lifecycle. +class MaestroRegistrationService extends RealUnitRegistrationService { + MaestroRegistrationService(super.appStore, super.walletService); + + bool _firstCall = true; + + @override + Future registerWallet( + RealUnitUserDataDto userData, + ) async { + if (_firstCall) { + _firstCall = false; + throw const BitboxNotConnectedException(); + } + return super.registerWallet(userData); + } +} diff --git a/lib/screens/buy/buy_page.dart b/lib/screens/buy/buy_page.dart index a6d6b9e9d..ecf528385 100644 --- a/lib/screens/buy/buy_page.dart +++ b/lib/screens/buy/buy_page.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_brokerbot_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/maestro_mock_client.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_buy_payment_info_service.dart'; import 'package:realunit_wallet/screens/buy/cubits/buy_converter/buy_converter_cubit.dart'; import 'package:realunit_wallet/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart'; @@ -9,6 +11,7 @@ import 'package:realunit_wallet/screens/buy/widgets/payment_additional_action_ne import 'package:realunit_wallet/screens/buy/widgets/payment_converter.dart'; import 'package:realunit_wallet/screens/buy/widgets/payment_information.dart'; import 'package:realunit_wallet/setup/di.dart'; +import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; class BuyPage extends StatelessWidget { const BuyPage({super.key}); @@ -44,6 +47,16 @@ class _BuyViewState extends State { final TextEditingController _amountController = TextEditingController(); final TextEditingController _resultController = TextEditingController(); + @override + void initState() { + super.initState(); + if (MaestroMockClient.inMaestroMockMode) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) context.pushNamed(AppRoutes.kyc); + }); + } + } + @override Widget build(BuildContext context) { return Scaffold( diff --git a/lib/setup/di.dart b/lib/setup/di.dart index fa7b20f12..7d5edfd08 100644 --- a/lib/setup/di.dart +++ b/lib/setup/di.dart @@ -30,6 +30,8 @@ import 'package:realunit_wallet/packages/service/dfx/dfx_widget_service.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_account_service.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_buy_payment_info_service.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_pdf_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/maestro_mock_client.dart'; +import 'package:realunit_wallet/packages/service/dfx/maestro_registration_service.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_sell_payment_info_service.dart'; import 'package:realunit_wallet/packages/service/session_cache.dart'; @@ -78,10 +80,12 @@ Future finishSetup(String encryptionKey) async { getIt.registerSingleton(AppDatabase(encryptionKey)); setupRepositories(); + final useMock = MaestroMockClient.inMaestroMockMode; getIt.registerSingleton( AppStore( () => ApiConfig(networkMode: getIt().networkMode), SessionCache(getIt()), + useMock ? MaestroMockClient() : null, ), ); @@ -178,7 +182,9 @@ void setupServices() { () => RealUnitPdfService(getIt(), getIt()), ); getIt.registerFactory( - () => RealUnitRegistrationService(getIt(), getIt()), + () => MaestroMockClient.inMaestroMockMode + ? MaestroRegistrationService(getIt(), getIt()) + : RealUnitRegistrationService(getIt(), getIt()), ); getIt.registerFactory( () => RealUnitSellPaymentInfoService(getIt(), getIt()), diff --git a/scripts/run-handbook-flows.sh b/scripts/run-handbook-flows.sh index c99374e32..066159914 100755 --- a/scripts/run-handbook-flows.sh +++ b/scripts/run-handbook-flows.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# Tier-3 navigation smoke for the 26 .maestro/handbook/*.yaml flows. +# Tier-3 navigation smoke for the 28 .maestro/handbook/*.yaml flows. # # For each flow (alphabetical order): # 1. run the Maestro flow — navigates the real built app to the target From 30176badfbee900a07cb9b9bd8a921d6fb6c7ee6 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:15:45 +0200 Subject: [PATCH 2/8] fix(maestro): address review findings for KYC link-wallet flows - Fix flow 11b back-navigation: single retry-gated loop instead of two separate blocks with incorrect gate condition - Fix import ordering in di.dart (maestro_* before real_unit_*) - Fix _continueKycResponse: add missing kycLevel, kycSteps, status, sequenceNumber, isCurrent, and session.type fields - Update all flow-count references from 26 to 28 in run-handbook-flows.sh - Add unit tests for MaestroMockClient (10 tests) and MaestroRegistrationService (1 test) - Merge PR #720 (BitBox connect sheet in link-wallet step) --- .../11b-kyc-link-wallet-bitbox-sheet.yaml | 23 +-- .../service/dfx/maestro_mock_client.dart | 19 ++- lib/setup/di.dart | 4 +- scripts/run-handbook-flows.sh | 8 +- .../service/dfx/maestro_mock_client_test.dart | 144 ++++++++++++++++++ .../maestro_registration_service_test.dart | 81 ++++++++++ 6 files changed, 252 insertions(+), 27 deletions(-) create mode 100644 test/packages/service/dfx/maestro_mock_client_test.dart create mode 100644 test/packages/service/dfx/maestro_registration_service_test.dart diff --git a/.maestro/handbook/11b-kyc-link-wallet-bitbox-sheet.yaml b/.maestro/handbook/11b-kyc-link-wallet-bitbox-sheet.yaml index a0dd45978..7873f7659 100644 --- a/.maestro/handbook/11b-kyc-link-wallet-bitbox-sheet.yaml +++ b/.maestro/handbook/11b-kyc-link-wallet-bitbox-sheet.yaml @@ -60,23 +60,14 @@ appId: swiss.realunit.app timeout: 10000 # Navigate back to the dashboard for the next flow (12-settings). -# First back: link-wallet page → buy page (the page that redirected to -# KYC in mock mode). +# Single retry-gated loop: taps the back button (top-left) until +# "RealUnit kaufen" becomes visible. The first tap pops the KYC route +# back to the buy page; the second tap pops the buy page back to the +# dashboard where the buy-action button is visible and the gate stops. +# Up to 5 attempts covers tap-loss on Apple Silicon + iOS 26 +# (mobile-dev-inc/maestro#3137). - repeat: - times: 3 - commands: - - runFlow: - when: - notVisible: 'RealUnit kaufen' - commands: - - tapOn: - point: '8%,8%' - optional: true - - waitForAnimationToEnd -# Second back (if needed): buy page → dashboard. The buy page has a -# standard AppBar back button because it was pushed from dashboard. -- repeat: - times: 3 + times: 5 commands: - runFlow: when: diff --git a/lib/packages/service/dfx/maestro_mock_client.dart b/lib/packages/service/dfx/maestro_mock_client.dart index 597b68cc2..c7452e3f4 100644 --- a/lib/packages/service/dfx/maestro_mock_client.dart +++ b/lib/packages/service/dfx/maestro_mock_client.dart @@ -94,11 +94,20 @@ class MaestroMockClient extends BaseClient { }; Map _continueKycResponse() => { - 'currentStep': { - 'name': 'ContactData', - 'session': {'url': 'https://localhost:3000/v2/kyc/session/1'}, - }, - }; + 'kycLevel': 10, + 'kycSteps': _kycSteps(), + 'processStatus': 'InProgress', + 'currentStep': { + 'name': 'ContactData', + 'status': 'Completed', + 'sequenceNumber': 0, + 'isCurrent': true, + 'session': { + 'url': 'https://localhost:3000/v2/kyc/session/1', + 'type': 'API', + }, + }, + }; List> _kycSteps() => [ { diff --git a/lib/setup/di.dart b/lib/setup/di.dart index 7d5edfd08..ca9f31f59 100644 --- a/lib/setup/di.dart +++ b/lib/setup/di.dart @@ -27,11 +27,11 @@ import 'package:realunit_wallet/packages/service/dfx/dfx_language_service.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_price_service.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_support_service.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_widget_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/maestro_mock_client.dart'; +import 'package:realunit_wallet/packages/service/dfx/maestro_registration_service.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_account_service.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_buy_payment_info_service.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_pdf_service.dart'; -import 'package:realunit_wallet/packages/service/dfx/maestro_mock_client.dart'; -import 'package:realunit_wallet/packages/service/dfx/maestro_registration_service.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_sell_payment_info_service.dart'; import 'package:realunit_wallet/packages/service/session_cache.dart'; diff --git a/scripts/run-handbook-flows.sh b/scripts/run-handbook-flows.sh index 066159914..3457ef03f 100755 --- a/scripts/run-handbook-flows.sh +++ b/scripts/run-handbook-flows.sh @@ -178,7 +178,7 @@ trap 'rm -rf "$TMP_DIR"' EXIT # Per-attempt retry budget for the upstream driver-hang class. The per-flow # / per-attempt timing logged below is the data to size the workflow's # `timeout-minutes` (see tier3-handbook.yaml) and to target a real speed-up. -# Worst-case the entire suite is 3 × 26 × ~1 min plus 3 × ~6 min +# Worst-case the entire suite is 3 × 28 × ~1 min plus 3 × ~6 min # driver-startup-timeout per failed attempt, which still fits inside # the workflow's 60 min envelope. MAESTRO_MAX_ATTEMPTS="${MAESTRO_MAX_ATTEMPTS:-3}" @@ -199,13 +199,13 @@ mkdir -p "$MAESTRO_DEBUG_ROOT" # close, so the next invocation finds nothing alive and has to # uninstall/install/start it again from scratch — the Maestro log line # `Restarting XCTest Runner (uninstalling, installing and starting)` + a -# fresh `xcodebuild test-without-building`. Across 26 flows that one-time -# ~5 min build is effectively paid 26×. +# fresh `xcodebuild test-without-building`. Across 28 flows that one-time +# ~5 min build is effectively paid 28×. # # `--reinstall-driver=false` (Maestro 2.0.10 TestCommand option, default # `true`) skips the uninstall step both at session start AND at session # close. So invocation 1 installs+starts the runner and then *leaves it -# running*; invocations 2-26 hit Maestro's `isChannelAlive()` early-return +# running*; invocations 2-28 hit Maestro's `isChannelAlive()` early-return # (`UI Test runner already running, returning`) and reuse it — no # reinstall, no rebuild. The first run on a freshly `simctl erase`-d # device has nothing to uninstall, so passing the flag there is harmless. diff --git a/test/packages/service/dfx/maestro_mock_client_test.dart b/test/packages/service/dfx/maestro_mock_client_test.dart new file mode 100644 index 000000000..2b17d7e53 --- /dev/null +++ b/test/packages/service/dfx/maestro_mock_client_test.dart @@ -0,0 +1,144 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart'; +import 'package:realunit_wallet/packages/service/dfx/maestro_mock_client.dart'; + +void main() { + group('MaestroMockClient', () { + late MaestroMockClient client; + + setUp(() { + client = MaestroMockClient(); + }); + + Future> _getJson(String path) async { + final request = Request('GET', Uri.parse('http://localhost:3000$path')); + final response = await client.send(request); + final body = await response.stream.bytesToString(); + return jsonDecode(body) as Map; + } + + Future> _postJson(String path) async { + final request = Request('POST', Uri.parse('http://localhost:3000$path')); + final response = await client.send(request); + final body = await response.stream.bytesToString(); + return jsonDecode(body) as Map; + } + + Future> _putJson(String path) async { + final request = Request('PUT', Uri.parse('http://localhost:3000$path')); + final response = await client.send(request); + final body = await response.stream.bytesToString(); + return jsonDecode(body) as Map; + } + + group('POST /v1/auth', () { + test('returns 201 with accessToken', () async { + final request = Request('POST', Uri.parse('http://localhost:3000/v1/auth')); + final response = await client.send(request); + expect(response.statusCode, 201); + final body = await response.stream.bytesToString(); + final json = jsonDecode(body) as Map; + expect(json['accessToken'], 'maestro-mock-token'); + }); + }); + + group('GET /v2/user', () { + test('returns user with email and kyc hash', () async { + final json = await _getJson('/v2/user'); + expect(json['mail'], 'shareholder@example.com'); + expect((json['kyc'] as Map)['hash'], 'maestro-kyc-hash'); + expect((json['kyc'] as Map)['level'], 10); + }); + }); + + group('PUT /v2/user', () { + test('returns same user shape', () async { + final json = await _putJson('/v2/user'); + expect(json['mail'], isNotNull); + }); + }); + + group('GET /v2/kyc', () { + test('returns kyc status with InProgress processStatus', () async { + final json = await _getJson('/v2/kyc'); + expect(json['kycLevel'], 10); + expect(json['processStatus'], 'InProgress'); + final steps = json['kycSteps'] as List; + expect(steps.length, 2); + }); + }); + + group('PUT /v2/kyc', () { + test('returns session response with required fields', () async { + final json = await _putJson('/v2/kyc'); + expect(json['kycLevel'], 10); + expect(json['processStatus'], 'InProgress'); + expect(json['kycSteps'], isNotEmpty); + final currentStep = json['currentStep'] as Map; + expect(currentStep['name'], 'ContactData'); + expect(currentStep['status'], 'Completed'); + expect(currentStep['sequenceNumber'], 0); + expect(currentStep['isCurrent'], true); + final session = currentStep['session'] as Map; + expect(session['url'], isNotEmpty); + expect(session['type'], 'API'); + }); + }); + + group('GET /v1/realunit/registration', () { + test('returns AddWallet state with userData', () async { + final json = await _getJson('/v1/realunit/registration'); + expect(json['state'], 'AddWallet'); + final userData = json['userData'] as Map; + expect(userData['email'], 'shareholder@example.com'); + expect(userData['name'], 'Max Mustermann'); + expect(userData['swissTaxResidence'], true); + final kycData = userData['kycData'] as Map; + expect(kycData['accountType'], 'Personal'); + }); + }); + + group('POST /v1/realunit/register/wallet', () { + test('returns 201 with completed status', () async { + final request = Request( + 'POST', + Uri.parse('http://localhost:3000/v1/realunit/register/wallet'), + ); + final response = await client.send(request); + expect(response.statusCode, 201); + final body = await response.stream.bytesToString(); + final json = jsonDecode(body) as Map; + expect(json['status'], 'completed'); + }); + }); + + group('POST /v1/realunit/register/email', () { + test('returns 201 with completed status', () async { + final request = Request( + 'POST', + Uri.parse('http://localhost:3000/v1/realunit/register/email'), + ); + final response = await client.send(request); + expect(response.statusCode, 201); + final body = await response.stream.bytesToString(); + final json = jsonDecode(body) as Map; + expect(json['status'], 'completed'); + }); + }); + + group('unknown paths', () { + test('returns 200 with empty JSON object', () async { + final json = await _getJson('/v2/some/unknown/path'); + expect(json, isEmpty); + }); + }); + + group('inMaestroMockMode', () { + test('is false by default (no compile-time flag in test environment)', () { + expect(MaestroMockClient.inMaestroMockMode, isFalse); + }); + }); + }); +} diff --git a/test/packages/service/dfx/maestro_registration_service_test.dart b/test/packages/service/dfx/maestro_registration_service_test.dart new file mode 100644 index 000000000..60fee9cf2 --- /dev/null +++ b/test/packages/service/dfx/maestro_registration_service_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter_test/flutter_test.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/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/maestro_registration_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_status.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.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'; + +class MockAppStore extends Mock implements AppStore {} +class MockWalletService extends Mock implements WalletService {} +class MockSessionCache extends Mock implements SessionCache {} + +void main() { + group('MaestroRegistrationService', () { + final userData = RealUnitUserDataDto( + email: 'test@example.com', + name: 'Max Mustermann', + type: 'Personal', + phoneNumber: '+41791234567', + birthday: '1990-01-01', + nationality: 'CH', + addressStreet: 'Bahnhofstrasse 1', + addressPostalCode: '8001', + addressCity: 'Zürich', + addressCountry: 'CH', + swissTaxResidence: true, + lang: 'DE', + kycData: KycPersonalData( + accountType: KycAccountType.personal, + firstName: 'Max', + lastName: 'Mustermann', + phone: '+41791234567', + address: KycAddress( + street: 'Bahnhofstrasse', + houseNumber: '1', + zip: '8001', + city: 'Zürich', + country: 1, + ), + ), + ); + + test('first registerWallet() throws BitboxNotConnectedException, retry delegates', + () async { + final appStore = MockAppStore(); + final walletService = MockWalletService(); + + // Stub just enough for the super.registerWallet() chain to fail with + // a non-BitBox exception. We don't need a full wallet set up — + // any exception that isn't BitboxNotConnectedException proves the + // retry path delegates to super instead of re-throwing our synthetic. + when(() => walletService.ensureCurrentWalletUnlocked()) + .thenAnswer((_) async {}); + + // appStore.wallet will throw because we haven't set it — that's fine. + // The expectation is isNot. + + final service = MaestroRegistrationService(appStore, walletService); + + // First call — synthetic BitBox disconnect. + await expectLater( + service.registerWallet(userData), + throwsA(isA()), + ); + + // Second call delegates to super. The super chain fails because + // appStore.wallet throws, but crucially it does NOT throw + // BitboxNotConnectedException (our synthetic flag has been cleared). + await expectLater( + service.registerWallet(userData), + throwsA(isNot(isA())), + ); + }); + }); +} From 0dab25a1fd2b9bcced1728f62956f3f563831389 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:58:12 +0200 Subject: [PATCH 3/8] fix(maestro): decouple mock mode from _localTesting, forward unknown paths to real API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous approach set _localTesting=true under MAESTRO_MOCK, which routed ALL API calls to localhost:3000. The mock's fallback empty-{} response for unknown paths crashed dashboard services that expect List responses (TransactionHistoryService, DFXPriceService, etc.), causing red error overlays during flows 01-26. Fix: - Revert _localTesting to always false (apiHost stays api.dfx.swiss) - Remove /v1/auth mock intercept — let the app get a real auth token - Forward unknown paths to a real HTTP Client so dashboard/price/ balance calls hit api.dfx.swiss with valid credentials - Only KYC/registration paths are intercepted by the mock Also remove misleading handbook screenshot mapping comment from flow 11a (no mapping exists in assemble-handbook-screenshots.sh). --- .maestro/handbook/11a-kyc-link-wallet.yaml | 3 - lib/packages/config/api_config.dart | 6 +- .../service/dfx/maestro_mock_client.dart | 138 +++++++++--------- .../service/dfx/maestro_mock_client_test.dart | 48 +++--- 4 files changed, 95 insertions(+), 100 deletions(-) diff --git a/.maestro/handbook/11a-kyc-link-wallet.yaml b/.maestro/handbook/11a-kyc-link-wallet.yaml index 5b709d175..574d33dc9 100644 --- a/.maestro/handbook/11a-kyc-link-wallet.yaml +++ b/.maestro/handbook/11a-kyc-link-wallet.yaml @@ -10,9 +10,6 @@ # Regression guard for PR #720: this flow verifies that the link-wallet # page renders the "shareholder confirmed" UI and is ready for the # BitBox-connect sheet test in flow 11b. -# -# The handbook screenshot for this flow maps to the Golden baseline -# kyc_link_wallet_page_default.png via scripts/assemble-handbook-screenshots.sh. appId: swiss.realunit.app --- # Navigate from dashboard to KYC via the buy-page redirect (mock mode only). diff --git a/lib/packages/config/api_config.dart b/lib/packages/config/api_config.dart index e2fd8f0d9..76f4ca2cb 100644 --- a/lib/packages/config/api_config.dart +++ b/lib/packages/config/api_config.dart @@ -2,10 +2,8 @@ import 'package:realunit_wallet/models/asset.dart'; import 'package:realunit_wallet/packages/config/network_mode.dart'; import 'package:realunit_wallet/packages/utils/default_assets.dart'; -/// `true` when compiled with `--dart-define=MAESTRO_MOCK=true` so the app -/// routes all DFX API calls through the in-process [MaestroMockClient]. -/// In production this is always `false`. -bool get _localTesting => const bool.fromEnvironment('MAESTRO_MOCK', defaultValue: false); +/// if true, requires to have a local running backend +bool get _localTesting => false; class ApiConfig { final NetworkMode networkMode; diff --git a/lib/packages/service/dfx/maestro_mock_client.dart b/lib/packages/service/dfx/maestro_mock_client.dart index c7452e3f4..fd25db696 100644 --- a/lib/packages/service/dfx/maestro_mock_client.dart +++ b/lib/packages/service/dfx/maestro_mock_client.dart @@ -5,16 +5,21 @@ import 'package:http/http.dart'; /// Intercepts HTTP calls when the app is compiled with /// `--dart-define=MAESTRO_MOCK=true` and returns canned JSON responses for -/// all DFX API endpoints the KYC / link-wallet flow needs. +/// the DFX API endpoints the KYC / link-wallet flow needs. /// -/// Unknown paths receive a default empty 200 to prevent crashes on -/// dashboard / price / balance calls. The mock is only compiled into the -/// binary when [inMaestroMockMode] is true — production builds are -/// unaffected. +/// Auth (`POST /v1/auth`) is NOT intercepted — the app gets a real token +/// from the DFX API so dashboard / price / balance calls that pass through +/// to the real API work with a valid Bearer token. Only KYC and +/// registration endpoints that the mock must control are intercepted. +/// +/// Unknown paths are forwarded to the real DFX API via [_inner]. class MaestroMockClient extends BaseClient { - static const _mockToken = 'maestro-mock-token'; static const _mockKycHash = 'maestro-kyc-hash'; + final Client _inner; + + MaestroMockClient([Client? inner]) : _inner = inner ?? Client(); + /// Whether the current build was compiled with `MAESTRO_MOCK=true`. static bool get inMaestroMockMode => const bool.fromEnvironment('MAESTRO_MOCK', defaultValue: false); @@ -27,18 +32,14 @@ class MaestroMockClient extends BaseClient { final resp = _response(method, path); if (resp != null) return resp; - // Unknown path — return a valid empty JSON response so the app - // doesn't crash on dashboard / price / balance API calls. - return _json(200, {}); + // Pass through to the real DFX API. The app has a real auth token + // (we don't intercept /v1/auth), so dashboard / price / balance + // calls hit api.dfx.swiss with valid credentials. + return _inner.send(request); } StreamedResponse? _response(String method, String path) { switch (path) { - case '/v1/auth': - if (method == 'POST') { - return _json(201, {'accessToken': _mockToken}); - } - break; case '/v2/user': if (method == 'GET') { return _json(200, _user()); @@ -78,20 +79,20 @@ class MaestroMockClient extends BaseClient { } Map _user() => { - 'mail': 'shareholder@example.com', - 'kyc': { - 'hash': _mockKycHash, - 'level': 10, - 'dataComplete': true, - }, - 'capabilities': {}, - }; + 'mail': 'shareholder@example.com', + 'kyc': { + 'hash': _mockKycHash, + 'level': 10, + 'dataComplete': true, + }, + 'capabilities': {}, + }; Map _kycStatus() => { - 'kycLevel': 10, - 'kycSteps': _kycSteps(), - 'processStatus': 'InProgress', - }; + 'kycLevel': 10, + 'kycSteps': _kycSteps(), + 'processStatus': 'InProgress', + }; Map _continueKycResponse() => { 'kycLevel': 10, @@ -110,49 +111,49 @@ class MaestroMockClient extends BaseClient { }; List> _kycSteps() => [ - { - 'name': 'ContactData', - 'status': 'Completed', - 'sequenceNumber': 0, - 'isCurrent': false, - 'isRequired': true, - }, - { - 'name': 'RealUnitRegistration', - 'status': 'Completed', - 'sequenceNumber': 1, - 'isCurrent': false, - 'isRequired': true, - }, - ]; + { + 'name': 'ContactData', + 'status': 'Completed', + 'sequenceNumber': 0, + 'isCurrent': false, + 'isRequired': true, + }, + { + 'name': 'RealUnitRegistration', + 'status': 'Completed', + 'sequenceNumber': 1, + 'isCurrent': false, + 'isRequired': true, + }, + ]; Map _userData() => { - 'email': 'shareholder@example.com', - 'name': 'Max Mustermann', - 'type': 'Personal', - 'phoneNumber': '+41791234567', - 'birthday': '1990-01-01', - 'nationality': 'CH', - 'addressStreet': 'Bahnhofstrasse 1', - 'addressPostalCode': '8001', - 'addressCity': 'Zürich', - 'addressCountry': 'CH', - 'swissTaxResidence': true, - 'lang': 'DE', - 'kycData': { - 'accountType': 'Personal', - 'firstName': 'Max', - 'lastName': 'Mustermann', - 'phone': '+41791234567', - 'address': { - 'street': 'Bahnhofstrasse', - 'houseNumber': '1', - 'zip': '8001', - 'city': 'Zürich', - 'country': 1, - }, - }, - }; + 'email': 'shareholder@example.com', + 'name': 'Max Mustermann', + 'type': 'Personal', + 'phoneNumber': '+41791234567', + 'birthday': '1990-01-01', + 'nationality': 'CH', + 'addressStreet': 'Bahnhofstrasse 1', + 'addressPostalCode': '8001', + 'addressCity': 'Zürich', + 'addressCountry': 'CH', + 'swissTaxResidence': true, + 'lang': 'DE', + 'kycData': { + 'accountType': 'Personal', + 'firstName': 'Max', + 'lastName': 'Mustermann', + 'phone': '+41791234567', + 'address': { + 'street': 'Bahnhofstrasse', + 'houseNumber': '1', + 'zip': '8001', + 'city': 'Zürich', + 'country': 1, + }, + }, + }; StreamedResponse _json(int code, Object body) { final bytes = utf8.encode(jsonEncode(body)); @@ -163,4 +164,7 @@ class MaestroMockClient extends BaseClient { headers: {'content-type': 'application/json'}, ); } + + @override + void close() => _inner.close(); } diff --git a/test/packages/service/dfx/maestro_mock_client_test.dart b/test/packages/service/dfx/maestro_mock_client_test.dart index 2b17d7e53..9c5e6b39b 100644 --- a/test/packages/service/dfx/maestro_mock_client_test.dart +++ b/test/packages/service/dfx/maestro_mock_client_test.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart'; +import 'package:http/testing.dart'; import 'package:realunit_wallet/packages/service/dfx/maestro_mock_client.dart'; void main() { @@ -13,37 +14,19 @@ void main() { }); Future> _getJson(String path) async { - final request = Request('GET', Uri.parse('http://localhost:3000$path')); - final response = await client.send(request); - final body = await response.stream.bytesToString(); - return jsonDecode(body) as Map; - } - - Future> _postJson(String path) async { - final request = Request('POST', Uri.parse('http://localhost:3000$path')); + final request = Request('GET', Uri.parse('https://api.dfx.swiss$path')); final response = await client.send(request); final body = await response.stream.bytesToString(); return jsonDecode(body) as Map; } Future> _putJson(String path) async { - final request = Request('PUT', Uri.parse('http://localhost:3000$path')); + final request = Request('PUT', Uri.parse('https://api.dfx.swiss$path')); final response = await client.send(request); final body = await response.stream.bytesToString(); return jsonDecode(body) as Map; } - group('POST /v1/auth', () { - test('returns 201 with accessToken', () async { - final request = Request('POST', Uri.parse('http://localhost:3000/v1/auth')); - final response = await client.send(request); - expect(response.statusCode, 201); - final body = await response.stream.bytesToString(); - final json = jsonDecode(body) as Map; - expect(json['accessToken'], 'maestro-mock-token'); - }); - }); - group('GET /v2/user', () { test('returns user with email and kyc hash', () async { final json = await _getJson('/v2/user'); @@ -104,7 +87,7 @@ void main() { test('returns 201 with completed status', () async { final request = Request( 'POST', - Uri.parse('http://localhost:3000/v1/realunit/register/wallet'), + Uri.parse('https://api.dfx.swiss/v1/realunit/register/wallet'), ); final response = await client.send(request); expect(response.statusCode, 201); @@ -118,7 +101,7 @@ void main() { test('returns 201 with completed status', () async { final request = Request( 'POST', - Uri.parse('http://localhost:3000/v1/realunit/register/email'), + Uri.parse('https://api.dfx.swiss/v1/realunit/register/email'), ); final response = await client.send(request); expect(response.statusCode, 201); @@ -128,10 +111,23 @@ void main() { }); }); - group('unknown paths', () { - test('returns 200 with empty JSON object', () async { - final json = await _getJson('/v2/some/unknown/path'); - expect(json, isEmpty); + group('unknown paths pass through to inner client', () { + test('forwards request to inner client (real API)', () async { + // Use a MockClient as inner to verify pass-through behavior. + final mockInner = MockClient((request) async { + expect(request.url.path, '/v1/some/real/endpoint'); + return Response('{"real": "data"}', 200); + }); + final passThroughClient = MaestroMockClient(mockInner); + + final request = Request( + 'GET', + Uri.parse('https://api.dfx.swiss/v1/some/real/endpoint'), + ); + final response = await passThroughClient.send(request); + final body = await response.stream.bytesToString(); + final json = jsonDecode(body) as Map; + expect(json['real'], 'data'); }); }); From 56535f928d0835c5640280c4163653acf1fcd403 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:05:54 +0200 Subject: [PATCH 4/8] chore(maestro): remove dead code in test, fix stale CI comment - Remove unused MockSessionCache class and dead imports from maestro_registration_service_test.dart - Fix outdated tier3-handbook.yaml build-step comment that described the old _localTesting=true / empty-200 fallback approach --- .github/workflows/tier3-handbook.yaml | 8 ++++---- .../service/dfx/maestro_registration_service_test.dart | 6 ------ 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/.github/workflows/tier3-handbook.yaml b/.github/workflows/tier3-handbook.yaml index 457471bda..cf020115e 100644 --- a/.github/workflows/tier3-handbook.yaml +++ b/.github/workflows/tier3-handbook.yaml @@ -183,10 +183,10 @@ jobs: - name: Build iOS simulator app # MAESTRO_MOCK enables in-process HTTP mocking for authenticated - # flows (KYC, add-wallet) that need canned API responses. It also - # sets ApiConfig._localTesting=true, routing ALL DFX API traffic - # through MaestroMockClient — unknown paths receive empty 200 - # responses so dashboard/settings flows don't crash. + # flows (KYC, add-wallet) that need canned API responses. Only + # KYC/registration paths are intercepted; all other requests + # (auth, dashboard, prices, balances) pass through to the real + # DFX API so existing handbook flows work without modification. run: flutter build ios --simulator --debug --dart-define=MAESTRO_MOCK=true # `scripts/run-handbook-flows.sh` does its own `simctl shutdown / erase / diff --git a/test/packages/service/dfx/maestro_registration_service_test.dart b/test/packages/service/dfx/maestro_registration_service_test.dart index 60fee9cf2..5347d2416 100644 --- a/test/packages/service/dfx/maestro_registration_service_test.dart +++ b/test/packages/service/dfx/maestro_registration_service_test.dart @@ -1,20 +1,14 @@ import 'package:flutter_test/flutter_test.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/service/app_store.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/maestro_registration_service.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; -import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_status.dart'; import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.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'; class MockAppStore extends Mock implements AppStore {} class MockWalletService extends Mock implements WalletService {} -class MockSessionCache extends Mock implements SessionCache {} void main() { group('MaestroRegistrationService', () { From d2d5159f49bf99300203eed53713731cdafe8b7f Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:16:14 +0200 Subject: [PATCH 5/8] chore(maestro): fix lint violations in mock test files - Rename local helper functions to drop leading underscore - Add const to RealUnitUserDataDto constructor --- .../service/dfx/maestro_mock_client_test.dart | 14 +++++++------- .../dfx/maestro_registration_service_test.dart | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/test/packages/service/dfx/maestro_mock_client_test.dart b/test/packages/service/dfx/maestro_mock_client_test.dart index 9c5e6b39b..f65c7239c 100644 --- a/test/packages/service/dfx/maestro_mock_client_test.dart +++ b/test/packages/service/dfx/maestro_mock_client_test.dart @@ -13,14 +13,14 @@ void main() { client = MaestroMockClient(); }); - Future> _getJson(String path) async { + Future> getJson(String path) async { final request = Request('GET', Uri.parse('https://api.dfx.swiss$path')); final response = await client.send(request); final body = await response.stream.bytesToString(); return jsonDecode(body) as Map; } - Future> _putJson(String path) async { + Future> putJson(String path) async { final request = Request('PUT', Uri.parse('https://api.dfx.swiss$path')); final response = await client.send(request); final body = await response.stream.bytesToString(); @@ -29,7 +29,7 @@ void main() { group('GET /v2/user', () { test('returns user with email and kyc hash', () async { - final json = await _getJson('/v2/user'); + final json = await getJson('/v2/user'); expect(json['mail'], 'shareholder@example.com'); expect((json['kyc'] as Map)['hash'], 'maestro-kyc-hash'); expect((json['kyc'] as Map)['level'], 10); @@ -38,14 +38,14 @@ void main() { group('PUT /v2/user', () { test('returns same user shape', () async { - final json = await _putJson('/v2/user'); + final json = await putJson('/v2/user'); expect(json['mail'], isNotNull); }); }); group('GET /v2/kyc', () { test('returns kyc status with InProgress processStatus', () async { - final json = await _getJson('/v2/kyc'); + final json = await getJson('/v2/kyc'); expect(json['kycLevel'], 10); expect(json['processStatus'], 'InProgress'); final steps = json['kycSteps'] as List; @@ -55,7 +55,7 @@ void main() { group('PUT /v2/kyc', () { test('returns session response with required fields', () async { - final json = await _putJson('/v2/kyc'); + final json = await putJson('/v2/kyc'); expect(json['kycLevel'], 10); expect(json['processStatus'], 'InProgress'); expect(json['kycSteps'], isNotEmpty); @@ -72,7 +72,7 @@ void main() { group('GET /v1/realunit/registration', () { test('returns AddWallet state with userData', () async { - final json = await _getJson('/v1/realunit/registration'); + final json = await getJson('/v1/realunit/registration'); expect(json['state'], 'AddWallet'); final userData = json['userData'] as Map; expect(userData['email'], 'shareholder@example.com'); diff --git a/test/packages/service/dfx/maestro_registration_service_test.dart b/test/packages/service/dfx/maestro_registration_service_test.dart index 5347d2416..1501294f0 100644 --- a/test/packages/service/dfx/maestro_registration_service_test.dart +++ b/test/packages/service/dfx/maestro_registration_service_test.dart @@ -12,7 +12,7 @@ class MockWalletService extends Mock implements WalletService {} void main() { group('MaestroRegistrationService', () { - final userData = RealUnitUserDataDto( + final userData = const RealUnitUserDataDto( email: 'test@example.com', name: 'Max Mustermann', type: 'Personal', From 246017036b8b0b3b6012c0ccab41ed5879dab61f Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:37:58 +0200 Subject: [PATCH 6/8] test(maestro): cover close() delegation in MaestroMockClient --- test/packages/service/dfx/maestro_mock_client_test.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/packages/service/dfx/maestro_mock_client_test.dart b/test/packages/service/dfx/maestro_mock_client_test.dart index f65c7239c..44c25bd2a 100644 --- a/test/packages/service/dfx/maestro_mock_client_test.dart +++ b/test/packages/service/dfx/maestro_mock_client_test.dart @@ -136,5 +136,11 @@ void main() { expect(MaestroMockClient.inMaestroMockMode, isFalse); }); }); + + group('close', () { + test('delegates to inner client', () { + expect(() => client.close(), returnsNormally); + }); + }); }); } From c526281909b1da741c44e29fbda06642efc06ae4 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:51:43 +0200 Subject: [PATCH 7/8] fix(maestro): intercept POST /v1/auth in mock client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The KYC flow calls getAuthToken() which hits POST /v1/auth when no cached token exists. Onboarding flows (01-10) never call getAuthToken() — the token is null when flow 11a enters the KYC path, so getAuthResponse() falls through to the real DFX API. On GitHub Actions runners whose IP is geo-blocked, the real API returns 403 ("The country of IP address is not allowed"), which surfaces as a KycFailurePage instead of the expected legal disclaimer. Return a synthetic accessToken from the mock so authenticatedGet succeeds without any real-API round-trip. --- .../service/dfx/maestro_mock_client.dart | 13 +++++++++---- .../service/dfx/maestro_mock_client_test.dart | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/lib/packages/service/dfx/maestro_mock_client.dart b/lib/packages/service/dfx/maestro_mock_client.dart index fd25db696..2eba8ecb9 100644 --- a/lib/packages/service/dfx/maestro_mock_client.dart +++ b/lib/packages/service/dfx/maestro_mock_client.dart @@ -7,10 +7,10 @@ import 'package:http/http.dart'; /// `--dart-define=MAESTRO_MOCK=true` and returns canned JSON responses for /// the DFX API endpoints the KYC / link-wallet flow needs. /// -/// Auth (`POST /v1/auth`) is NOT intercepted — the app gets a real token -/// from the DFX API so dashboard / price / balance calls that pass through -/// to the real API work with a valid Bearer token. Only KYC and -/// registration endpoints that the mock must control are intercepted. +/// Auth (`POST /v1/auth`) is intercepted and returns a synthetic token so +/// authenticated KYC / registration calls succeed without hitting the real +/// DFX API. Only KYC and registration endpoints that the mock must control +/// are intercepted; all other calls pass through to the real API. /// /// Unknown paths are forwarded to the real DFX API via [_inner]. class MaestroMockClient extends BaseClient { @@ -40,6 +40,11 @@ class MaestroMockClient extends BaseClient { StreamedResponse? _response(String method, String path) { switch (path) { + case '/v1/auth': + if (method == 'POST') { + return _json(201, {'accessToken': 'maestro-mock-token'}); + } + break; case '/v2/user': if (method == 'GET') { return _json(200, _user()); diff --git a/test/packages/service/dfx/maestro_mock_client_test.dart b/test/packages/service/dfx/maestro_mock_client_test.dart index 44c25bd2a..dd8003a13 100644 --- a/test/packages/service/dfx/maestro_mock_client_test.dart +++ b/test/packages/service/dfx/maestro_mock_client_test.dart @@ -27,6 +27,24 @@ void main() { return jsonDecode(body) as Map; } + Future> postJson(String path, {Object? body}) async { + final request = Request('POST', Uri.parse('https://api.dfx.swiss$path')); + if (body != null) { + request.body = jsonEncode(body); + request.headers['content-type'] = 'application/json'; + } + final response = await client.send(request); + final responseBody = await response.stream.bytesToString(); + return jsonDecode(responseBody) as Map; + } + + group('POST /v1/auth', () { + test('returns access token', () async { + final json = await postJson('/v1/auth'); + expect(json['accessToken'], 'maestro-mock-token'); + }); + }); + group('GET /v2/user', () { test('returns user with email and kyc hash', () async { final json = await getJson('/v2/user'); From 3bcd1ff71e0162abc78ad54b811745b6e079ed03 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 10 Jun 2026 19:22:58 +0200 Subject: [PATCH 8/8] fix(maestro): use "Zustimmen" instead of "Ja" in flow 11a The legal disclaimer page uses "Zustimmen" / "Ablehnen" (localized via legalDisclaimerYes/No), not "Ja" / "Nein". The flow's repeat block was tapping a non-existent element. --- .maestro/handbook/11a-kyc-link-wallet.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.maestro/handbook/11a-kyc-link-wallet.yaml b/.maestro/handbook/11a-kyc-link-wallet.yaml index 574d33dc9..1c5146c6c 100644 --- a/.maestro/handbook/11a-kyc-link-wallet.yaml +++ b/.maestro/handbook/11a-kyc-link-wallet.yaml @@ -25,8 +25,8 @@ appId: swiss.realunit.app optional: true - waitForAnimationToEnd -# The legal disclaimer has 5 steps (0-4). Each step shows a "Ja" / "Nein" -# button pair — "Ja" advances, on the last step it completes and triggers +# The legal disclaimer has 5 steps (0-4). Each step shows a "Zustimmen" / "Ablehnen" +# button pair — "Zustimmen" advances, on the last step it completes and triggers # checkKyc(). With the mock API the next route is KycLinkWalletPage. - extendedWaitUntil: visible: 'Rechtlicher Hinweis' @@ -36,7 +36,7 @@ appId: swiss.realunit.app times: 5 commands: - tapOn: - text: 'Ja' + text: 'Zustimmen' - waitForAnimationToEnd # After accepting the legal disclaimer, checkKyc() fetches registration