diff --git a/.github/workflows/tier3-handbook.yaml b/.github/workflows/tier3-handbook.yaml index eb83f5f2..cf020115 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. 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 / # 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 00000000..1c5146c6 --- /dev/null +++ b/.maestro/handbook/11a-kyc-link-wallet.yaml @@ -0,0 +1,47 @@ +# 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. +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 "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' + timeout: 15000 + +- repeat: + times: 5 + commands: + - tapOn: + text: 'Zustimmen' + - 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 00000000..7873f765 --- /dev/null +++ b/.maestro/handbook/11b-kyc-link-wallet-bitbox-sheet.yaml @@ -0,0 +1,84 @@ +# 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). +# 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: 5 + 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/service/app_store.dart b/lib/packages/service/app_store.dart index 294c7438..6d698a4e 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 00000000..2eba8ecb --- /dev/null +++ b/lib/packages/service/dfx/maestro_mock_client.dart @@ -0,0 +1,175 @@ +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 +/// the DFX API endpoints the KYC / link-wallet flow needs. +/// +/// 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 { + 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); + + @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; + + // 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': 'maestro-mock-token'}); + } + 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() => { + '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() => [ + { + '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'}, + ); + } + + @override + void close() => _inner.close(); +} 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 00000000..3493b31e --- /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 a6d6b9e9..ecf52838 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 fa7b20f1..ca9f31f5 100644 --- a/lib/setup/di.dart +++ b/lib/setup/di.dart @@ -27,6 +27,8 @@ 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'; @@ -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 c99374e3..3457ef03 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 @@ -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 00000000..dd8003a1 --- /dev/null +++ b/test/packages/service/dfx/maestro_mock_client_test.dart @@ -0,0 +1,164 @@ +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() { + group('MaestroMockClient', () { + late MaestroMockClient client; + + setUp(() { + client = MaestroMockClient(); + }); + + 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 { + 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; + } + + 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'); + 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('https://api.dfx.swiss/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('https://api.dfx.swiss/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 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'); + }); + }); + + group('inMaestroMockMode', () { + test('is false by default (no compile-time flag in test environment)', () { + expect(MaestroMockClient.inMaestroMockMode, isFalse); + }); + }); + + group('close', () { + test('delegates to inner client', () { + expect(() => client.close(), returnsNormally); + }); + }); + }); +} 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 00000000..1501294f --- /dev/null +++ b/test/packages/service/dfx/maestro_registration_service_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.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/user/dto/real_unit_user_data_dto.dart'; +import 'package:realunit_wallet/packages/service/wallet_service.dart'; + +class MockAppStore extends Mock implements AppStore {} +class MockWalletService extends Mock implements WalletService {} + +void main() { + group('MaestroRegistrationService', () { + final userData = const 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())), + ); + }); + }); +}