From a3a736e668875b0991995d7c75e6000a1673ed55 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 22 May 2026 10:27:13 +0200 Subject: [PATCH 1/2] fix(kyc): filter country pickers by allow-flag, harden load states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared CountryField (used by 4 KYC/onboarding screens) showed the same unfiltered country list for both nationality and residence pickers, auto-selected Switzerland, and collapsed to an empty or non-FormField widget while loading or on error — letting Form.validate() pass with no selection and crashing the KYC submit on a null country. - Country now carries nationalityAllowed/locationAllowed; CountryField takes a required `purpose` and filters the list accordingly - loading and error states render a present, invalid FormField so the surrounding form can never validate without a selection; the error state shows a localized message and a retry instead of the raw exception - drop the silent Switzerland auto-select; country selection is now explicit and validated - localize the nationality-failure SnackBar --- assets/languages/strings_de.arb | 2 + assets/languages/strings_en.arb | 2 + .../service/dfx/models/country/country.dart | 8 + .../nationality/kyc_nationality_page.dart | 7 +- .../steps/kyc_registration_address_step.dart | 5 +- .../steps/kyc_registration_personal_step.dart | 5 +- .../settings_edit_address_page.dart | 5 +- lib/widgets/form/country_field.dart | 114 +++++++-- .../dfx/models/aggregate_dtos_test.dart | 76 +++--- .../models/country/dfx_country_dto_test.dart | 79 ++++-- ..._unit_registration_service_happy_test.dart | 102 ++++---- .../real_unit_registration_service_test.dart | 109 +++++---- test/packages/wallet/eip712_signer_test.dart | 16 +- .../home_settings_user_data_states_test.dart | 16 +- .../kyc_nationality_2fa_cubits_test.dart | 48 ++-- .../kyc/steps/kyc_nationality_page_test.dart | 14 +- .../kyc_registration_submit_cubit_test.dart | 8 +- .../settings_user_data_cubit_test.dart | 91 ++++--- .../settings_user_data_page_test.dart | 16 +- .../settings_edit_address_page_test.dart | 19 +- test/widgets/form/country_field_test.dart | 227 ++++++++++++++++++ 21 files changed, 732 insertions(+), 237 deletions(-) create mode 100644 test/widgets/form/country_field_test.dart diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index 3ac699c2..8abf0a23 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -67,6 +67,7 @@ "contactSupportDescription": "FAQ, Tickets & Chat", "continueAnyway": "Trotzdem fortfahren", "copyClipboard": "In die Zwischenablage kopiert", + "countriesLoadFailed": "Die Länderliste konnte nicht geladen werden. Bitte versuchen Sie es erneut.", "country": "Land", "createWallet": "Neue Wallet erstellen", "createWalletConfirm": "Ich habe es gesichert", @@ -254,6 +255,7 @@ "sellSuccess": "Verkauf erfolgreich", "sellSuccessDescription": "Der Betrag wird Ihnen auf das angegebene Bankkonto ausgezahlt.", "sending": "Wird gesendet", + "setNationalityFailed": "Ihre Staatsangehörigkeit konnte nicht gesetzt werden:\n${message}", "settings": "Einstellungen", "settingsAppVersion": "Version ${tag}", "settingsCurrency": "Währung", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 64f465a2..5e029e25 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -67,6 +67,7 @@ "contactSupportDescription": "FAQ, tickets & chat", "continueAnyway": "Continue anyway", "copyClipboard": "Copied to clipboard", + "countriesLoadFailed": "Could not load the country list. Please try again.", "country": "Country", "createWallet": "Create new Wallet", "createWalletConfirm": "I’ve written it down", @@ -254,6 +255,7 @@ "sellSuccess": "Sell successful", "sellSuccessDescription": "The amount will be paid into the bank account you have specified.", "sending": "Sending", + "setNationalityFailed": "Could not set your nationality:\n${message}", "settings": "Settings", "settingsAppVersion": "Version ${tag}", "settingsCurrency": "Currency", diff --git a/lib/packages/service/dfx/models/country/country.dart b/lib/packages/service/dfx/models/country/country.dart index c69274a3..1ba7e11a 100644 --- a/lib/packages/service/dfx/models/country/country.dart +++ b/lib/packages/service/dfx/models/country/country.dart @@ -6,11 +6,17 @@ class Country extends Equatable { final String symbol; final String name; final String? foreignName; + // Whether the backend accepts this country as a person's nationality. + final bool nationalityAllowed; + // Whether the backend accepts this country as a residence/address country. + final bool locationAllowed; const Country({ required this.id, required this.symbol, required this.name, + required this.nationalityAllowed, + required this.locationAllowed, this.foreignName, }); @@ -25,6 +31,8 @@ extension DfxCountryDtoMapper on DfxCountryDto { symbol: symbol, name: name, foreignName: foreignName, + nationalityAllowed: nationalityAllowed, + locationAllowed: locationAllowed, ); } } diff --git a/lib/screens/kyc/steps/nationality/kyc_nationality_page.dart b/lib/screens/kyc/steps/nationality/kyc_nationality_page.dart index 4e8dc12b..bdb7c632 100644 --- a/lib/screens/kyc/steps/nationality/kyc_nationality_page.dart +++ b/lib/screens/kyc/steps/nationality/kyc_nationality_page.dart @@ -51,7 +51,7 @@ class _KycNationalityViewState extends State { if (state is KycNationalityFailure) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Set Nationality failed:\n${state.message}'), + content: Text(S.of(context).setNationalityFailed(state.message)), backgroundColor: RealUnitColors.status.red600, ), ); @@ -70,11 +70,8 @@ class _KycNationalityViewState extends State { children: [ CountryField( label: S.of(context).registerCitizenship, + purpose: CountryFieldPurpose.nationality, onChanged: (country) => nationalityCtrl.value = country, - validator: (value) { - if (value == null) return ''; - return null; - }, ), Padding( padding: const EdgeInsets.symmetric(vertical: 16.0), diff --git a/lib/screens/kyc/steps/registration/steps/kyc_registration_address_step.dart b/lib/screens/kyc/steps/registration/steps/kyc_registration_address_step.dart index 25142330..e815d640 100644 --- a/lib/screens/kyc/steps/registration/steps/kyc_registration_address_step.dart +++ b/lib/screens/kyc/steps/registration/steps/kyc_registration_address_step.dart @@ -104,11 +104,8 @@ class KycRegistrationAddressStep extends StatelessWidget { ), CountryField( label: S.of(context).country, + purpose: CountryFieldPurpose.residence, onChanged: (country) => countryCtrl.value = country, - validator: (value) { - if (value == null) return ''; - return null; - }, ), Padding( padding: const .symmetric(vertical: 16.0), diff --git a/lib/screens/kyc/steps/registration/steps/kyc_registration_personal_step.dart b/lib/screens/kyc/steps/registration/steps/kyc_registration_personal_step.dart index 9d7b919e..58733de5 100644 --- a/lib/screens/kyc/steps/registration/steps/kyc_registration_personal_step.dart +++ b/lib/screens/kyc/steps/registration/steps/kyc_registration_personal_step.dart @@ -96,11 +96,8 @@ class KycRegistrationPersonalStep extends StatelessWidget { ), CountryField( label: S.of(context).registerCitizenship, + purpose: CountryFieldPurpose.nationality, onChanged: (country) => nationalityCtrl.value = country, - validator: (value) { - if (value == null) return ''; - return null; - }, ), Padding( padding: const .symmetric(vertical: 16.0), diff --git a/lib/screens/settings_user_data/subpages/edit_address/settings_edit_address_page.dart b/lib/screens/settings_user_data/subpages/edit_address/settings_edit_address_page.dart index 3ec9ff11..6102ab9f 100644 --- a/lib/screens/settings_user_data/subpages/edit_address/settings_edit_address_page.dart +++ b/lib/screens/settings_user_data/subpages/edit_address/settings_edit_address_page.dart @@ -160,11 +160,8 @@ class _SettingsEditAddressViewState extends State { ), CountryField( label: S.of(context).country, + purpose: CountryFieldPurpose.residence, onChanged: (country) => _countryCtrl.value = country, - validator: (value) { - if (value == null) return ''; - return null; - }, ), FilePickerField( label: S.of(context).proofDocument, diff --git a/lib/widgets/form/country_field.dart b/lib/widgets/form/country_field.dart index 702e6a51..06da9d40 100644 --- a/lib/widgets/form/country_field.dart +++ b/lib/widgets/form/country_field.dart @@ -1,19 +1,34 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart'; import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; import 'package:realunit_wallet/setup/di.dart'; +import 'package:realunit_wallet/styles/colors.dart'; import 'package:realunit_wallet/widgets/form/dropdown_field.dart'; +/// Selects which backend allow-flag gates the country list for a given field. +enum CountryFieldPurpose { + nationality, + residence + ; + + bool allows(Country country) => switch (this) { + CountryFieldPurpose.nationality => country.nationalityAllowed, + CountryFieldPurpose.residence => country.locationAllowed, + }; +} + class CountryField extends StatefulWidget { final String label; + final CountryFieldPurpose purpose; final void Function(Country?)? onChanged; - final String? Function(Country?)? validator; const CountryField({ super.key, required this.label, + required this.purpose, this.onChanged, - this.validator, }); @override @@ -23,7 +38,6 @@ class CountryField extends StatefulWidget { class _CountryFieldState extends State { final DfxCountryService countryService = getIt(); late Future> _countriesFuture; - bool _hasPreloaded = false; @override void initState() { @@ -37,28 +51,56 @@ class _CountryFieldState extends State { future: _countriesFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { - return const SizedBox.shrink(); + return _StatusField( + label: widget.label, + child: const Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 4), + child: CupertinoActivityIndicator(), + ), + ), + ); } if (snapshot.hasError) { - return Text('Failed to load countries: ${snapshot.error}'); + return _StatusField( + label: widget.label, + child: Row( + children: [ + Expanded( + child: Text( + S.of(context).countriesLoadFailed, + style: TextStyle(color: RealUnitColors.status.red600), + ), + ), + TextButton( + onPressed: _retry, + child: Text(S.of(context).retry), + ), + ], + ), + ); } - final countries = snapshot.data ?? []; - final initialCountry = countries.isNotEmpty ? countries.first : null; - _preloadCountry(initialCountry); + final countries = snapshot.data!.where(widget.purpose.allows).toList(); return DropdownField( hintText: 'Schweiz', label: widget.label, items: countries.map((c) => DropdownMenuItem(value: c, child: Text(c.name))).toList(), - initialValue: initialCountry, + initialValue: null, onChanged: widget.onChanged, - validator: widget.validator, + validator: (value) => value == null ? '' : null, ); }, ); } + void _retry() { + setState(() { + _countriesFuture = _loadCountries(); + }); + } + Future> _loadCountries() async { final countries = await countryService.getAllCountries(); @@ -80,13 +122,51 @@ class _CountryFieldState extends State { return countries; } +} - void _preloadCountry(Country? initialCountry) { - if (!_hasPreloaded && initialCountry != null) { - _hasPreloaded = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - widget.onChanged?.call(initialCountry); - }); - } +/// A labeled container that always registers an *invalid* [FormField] while no +/// country can be selected (loading or error). This guarantees that +/// `Form.validate()` cannot return `true` before the user picks a country. +class _StatusField extends StatelessWidget { + final String label; + final Widget child; + + const _StatusField({required this.label, required this.child}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), + child: Text( + label, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + height: 18 / 13, + ), + ), + ), + FormField( + // No country is available yet, so the field is always invalid and + // blocks the surrounding Form from validating. + validator: (_) => '', + builder: (state) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8.0), + border: Border.all( + color: state.hasError ? RealUnitColors.status.red600 : RealUnitColors.neutral300, + ), + ), + child: child, + ); + }, + ), + ], + ); } } diff --git a/test/packages/service/dfx/models/aggregate_dtos_test.dart b/test/packages/service/dfx/models/aggregate_dtos_test.dart index 0e0463df..0ec5425a 100644 --- a/test/packages/service/dfx/models/aggregate_dtos_test.dart +++ b/test/packages/service/dfx/models/aggregate_dtos_test.dart @@ -102,9 +102,27 @@ void main() { group('$Country', () { test('equality is by id only', () { - const a = Country(id: 41, symbol: 'CH', name: 'Switzerland'); - const b = Country(id: 41, symbol: 'XX', name: 'Different'); - const c = Country(id: 49, symbol: 'DE', name: 'Germany'); + const a = Country( + id: 41, + symbol: 'CH', + name: 'Switzerland', + nationalityAllowed: true, + locationAllowed: true, + ); + const b = Country( + id: 41, + symbol: 'XX', + name: 'Different', + nationalityAllowed: true, + locationAllowed: true, + ); + const c = Country( + id: 49, + symbol: 'DE', + name: 'Germany', + nationalityAllowed: true, + locationAllowed: true, + ); expect(a, b); expect(a, isNot(c)); @@ -208,29 +226,29 @@ void main() { } Map _userDataJson() => { - 'email': 'a@b.com', - 'name': 'Ada Lovelace', - 'type': 'HUMAN', - 'phoneNumber': '+41 79 000 00 00', - 'birthday': '1815-12-10', - 'nationality': 'CH', - 'addressStreet': 'Bahnhofstrasse 1', - 'addressPostalCode': '8000', - 'addressCity': 'Zurich', - 'addressCountry': 'CH', - 'swissTaxResidence': true, - 'lang': 'de', - 'kycData': { - 'accountType': 'Personal', - 'firstName': 'Ada', - 'lastName': 'Lovelace', - 'phone': '+41 79 000 00 00', - 'address': { - 'street': 'Bahnhofstrasse', - 'houseNumber': '1', - 'zip': '8000', - 'city': 'Zurich', - 'country': {'id': 41}, - }, - }, - }; + 'email': 'a@b.com', + 'name': 'Ada Lovelace', + 'type': 'HUMAN', + 'phoneNumber': '+41 79 000 00 00', + 'birthday': '1815-12-10', + 'nationality': 'CH', + 'addressStreet': 'Bahnhofstrasse 1', + 'addressPostalCode': '8000', + 'addressCity': 'Zurich', + 'addressCountry': 'CH', + 'swissTaxResidence': true, + 'lang': 'de', + 'kycData': { + 'accountType': 'Personal', + 'firstName': 'Ada', + 'lastName': 'Lovelace', + 'phone': '+41 79 000 00 00', + 'address': { + 'street': 'Bahnhofstrasse', + 'houseNumber': '1', + 'zip': '8000', + 'city': 'Zurich', + 'country': {'id': 41}, + }, + }, +}; diff --git a/test/packages/service/dfx/models/country/dfx_country_dto_test.dart b/test/packages/service/dfx/models/country/dfx_country_dto_test.dart index 596a434d..fe435699 100644 --- a/test/packages/service/dfx/models/country/dfx_country_dto_test.dart +++ b/test/packages/service/dfx/models/country/dfx_country_dto_test.dart @@ -2,20 +2,25 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; import 'package:realunit_wallet/packages/service/dfx/models/country/dto/dfx_country_dto.dart'; -Map _wire({String? foreignName, bool ibanAllowed = true}) => { - 'id': 41, - 'symbol': 'CH', - 'name': 'Switzerland', - 'foreignName': foreignName, - 'locationAllowed': true, - 'ibanAllowed': ibanAllowed, - 'kycAllowed': true, - 'kycOrganizationAllowed': true, - 'nationalityAllowed': true, - 'bankAllowed': true, - 'cardAllowed': true, - 'cryptoAllowed': true, - }; +Map _wire({ + String? foreignName, + bool ibanAllowed = true, + bool locationAllowed = true, + bool nationalityAllowed = true, +}) => { + 'id': 41, + 'symbol': 'CH', + 'name': 'Switzerland', + 'foreignName': foreignName, + 'locationAllowed': locationAllowed, + 'ibanAllowed': ibanAllowed, + 'kycAllowed': true, + 'kycOrganizationAllowed': true, + 'nationalityAllowed': nationalityAllowed, + 'bankAllowed': true, + 'cardAllowed': true, + 'cryptoAllowed': true, +}; void main() { group('$DfxCountryDto.fromJson', () { @@ -50,7 +55,7 @@ void main() { }); group('DfxCountryDtoMapper.toCountry', () { - test('keeps id / symbol / name / foreignName, drops the *_allowed flags', () { + test('keeps id / symbol / name / foreignName and the purpose allow-flags', () { final country = DfxCountryDto.fromJson(_wire(foreignName: 'Schweiz')).toCountry(); expect(country, isA()); @@ -58,21 +63,57 @@ void main() { expect(country.symbol, 'CH'); expect(country.name, 'Switzerland'); expect(country.foreignName, 'Schweiz'); + expect(country.nationalityAllowed, isTrue); + expect(country.locationAllowed, isTrue); + }); + + test('passes false allow-flags through unchanged', () { + final country = DfxCountryDto.fromJson( + _wire(nationalityAllowed: false, locationAllowed: false), + ).toCountry(); + + expect(country.nationalityAllowed, isFalse); + expect(country.locationAllowed, isFalse); }); }); group('$Country equality', () { test('two Country instances with the same id are ==', () { - const a = Country(id: 41, symbol: 'CH', name: 'Switzerland'); - const b = Country(id: 41, symbol: 'XX', name: 'Different name', foreignName: 'F'); + const a = Country( + id: 41, + symbol: 'CH', + name: 'Switzerland', + nationalityAllowed: true, + locationAllowed: true, + ); + const b = Country( + id: 41, + symbol: 'XX', + name: 'Different name', + foreignName: 'F', + nationalityAllowed: false, + locationAllowed: false, + ); // Equality only on id (props returns [id]). expect(a, equals(b)); }); test('different ids → different equality', () { - const a = Country(id: 41, symbol: 'CH', name: 'Switzerland'); - const b = Country(id: 49, symbol: 'DE', name: 'Germany'); + const a = Country( + id: 41, + symbol: 'CH', + name: 'Switzerland', + nationalityAllowed: true, + locationAllowed: true, + ); + const b = Country( + id: 49, + symbol: 'DE', + name: 'Germany', + nationalityAllowed: true, + locationAllowed: true, + ); expect(a, isNot(equals(b))); }); diff --git a/test/packages/service/dfx/real_unit_registration_service_happy_test.dart b/test/packages/service/dfx/real_unit_registration_service_happy_test.dart index a3725d5c..93c3fd2a 100644 --- a/test/packages/service/dfx/real_unit_registration_service_happy_test.dart +++ b/test/packages/service/dfx/real_unit_registration_service_happy_test.dart @@ -31,8 +31,7 @@ class _MockCacheRepository extends Mock implements CacheRepository {} class _MockWalletService extends Mock implements WalletService {} -const _testPrivateKeyHex = - 'fb1ace12f9801e85f3db1b3935dd47d9f064f98152466f47c701b5e12680e612'; +const _testPrivateKeyHex = 'fb1ace12f9801e85f3db1b3935dd47d9f064f98152466f47c701b5e12680e612'; final _privKey = EthPrivateKey.fromHex(_testPrivateKeyHex); @@ -51,8 +50,7 @@ void main() { session = SessionCache(_MockCacheRepository()); session.setAuthToken('jwt-1'); - when(() => appStore.apiConfig) - .thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); + when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); when(() => appStore.sessionCache).thenReturn(session); when(() => appStore.wallet).thenReturn(wallet); when(() => wallet.primaryAccount).thenReturn(account); @@ -67,50 +65,62 @@ void main() { } Registration buildRegistration() => const Registration( - type: RegistrationUserType.human, - email: 'AdA@ExAmPlE.COM', - // Diacritics → must be ASCII-transliterated (ä→ae, ü→ue) for the - // BitBox-safe wire envelope; the test asserts the transliteration - // round-trip below. - firstName: 'Adä', - lastName: 'Loveläce', - phoneNumber: '+41 79 000 00 00', - birthday: '1815-12-10', - nationality: Country(id: 41, symbol: 'CH', name: 'Switzerland'), - addressStreet: 'Bahnhofstraße', - addressStreetNumber: '1', - addressPostalCode: '8000', - addressCity: 'Zürich', - addressCountry: Country(id: 41, symbol: 'CH', name: 'Switzerland'), - swissTaxResidence: true, - ); + type: RegistrationUserType.human, + email: 'AdA@ExAmPlE.COM', + // Diacritics → must be ASCII-transliterated (ä→ae, ü→ue) for the + // BitBox-safe wire envelope; the test asserts the transliteration + // round-trip below. + firstName: 'Adä', + lastName: 'Loveläce', + phoneNumber: '+41 79 000 00 00', + birthday: '1815-12-10', + nationality: Country( + id: 41, + symbol: 'CH', + name: 'Switzerland', + nationalityAllowed: true, + locationAllowed: true, + ), + addressStreet: 'Bahnhofstraße', + addressStreetNumber: '1', + addressPostalCode: '8000', + addressCity: 'Zürich', + addressCountry: Country( + id: 41, + symbol: 'CH', + name: 'Switzerland', + nationalityAllowed: true, + locationAllowed: true, + ), + swissTaxResidence: true, + ); RealUnitUserDataDto buildUserData() => const RealUnitUserDataDto( - email: 'a@b.com', - name: 'Ada Lovelace', - type: 'HUMAN', - phoneNumber: '+41 79 000 00 00', - birthday: '1815-12-10', - nationality: 'CH', - addressStreet: 'Bahnhofstrasse 1', - addressPostalCode: '8000', - addressCity: 'Zurich', - addressCountry: 'CH', - swissTaxResidence: true, - lang: 'de', - kycData: KycPersonalData( - accountType: KycAccountType.personal, - firstName: 'Ada', - lastName: 'Lovelace', - phone: '+41 79 000 00 00', - address: KycAddress( - street: 'Bahnhofstrasse', - zip: '8000', - city: 'Zurich', - country: 41, - ), - ), - ); + email: 'a@b.com', + name: 'Ada Lovelace', + type: 'HUMAN', + phoneNumber: '+41 79 000 00 00', + birthday: '1815-12-10', + nationality: 'CH', + addressStreet: 'Bahnhofstrasse 1', + addressPostalCode: '8000', + addressCity: 'Zurich', + addressCountry: 'CH', + swissTaxResidence: true, + lang: 'de', + kycData: KycPersonalData( + accountType: KycAccountType.personal, + firstName: 'Ada', + lastName: 'Lovelace', + phone: '+41 79 000 00 00', + address: KycAddress( + street: 'Bahnhofstrasse', + zip: '8000', + city: 'Zurich', + country: 41, + ), + ), + ); group('completeRegistration happy path', () { test( diff --git a/test/packages/service/dfx/real_unit_registration_service_test.dart b/test/packages/service/dfx/real_unit_registration_service_test.dart index efb6bc14..af9e0bc6 100644 --- a/test/packages/service/dfx/real_unit_registration_service_test.dart +++ b/test/packages/service/dfx/real_unit_registration_service_test.dart @@ -49,8 +49,7 @@ void main() { session = SessionCache(_MockCacheRepository()); session.setAuthToken('jwt-1'); - when(() => appStore.apiConfig) - .thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); + when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); when(() => appStore.sessionCache).thenReturn(session); when(() => appStore.wallet).thenReturn(wallet); when(() => wallet.primaryAccount).thenReturn(account); @@ -84,10 +83,12 @@ void main() { }); test('accepts a 202 Accepted response as success', () async { - final client = MockClient((_) async => http.Response( - jsonEncode({'status': 'merge_requested'}), - 202, - )); + final client = MockClient( + (_) async => http.Response( + jsonEncode({'status': 'merge_requested'}), + 202, + ), + ); final status = await build(client).registerEmail('a@b.com'); @@ -111,26 +112,37 @@ void main() { group('$RealUnitRegistrationService.completeRegistration', () { Registration buildRegistration() => const Registration( - type: RegistrationUserType.human, - email: 'a@b.com', - firstName: 'Ada', - lastName: 'Lovelace', - phoneNumber: '+41 79 000 00 00', - birthday: '1815-12-10', - nationality: Country(id: 41, symbol: 'CH', name: 'Switzerland'), - addressStreet: 'Bahnhofstrasse', - addressStreetNumber: '1', - addressPostalCode: '8000', - addressCity: 'Zurich', - addressCountry: Country(id: 41, symbol: 'CH', name: 'Switzerland'), - swissTaxResidence: true, - ); + type: RegistrationUserType.human, + email: 'a@b.com', + firstName: 'Ada', + lastName: 'Lovelace', + phoneNumber: '+41 79 000 00 00', + birthday: '1815-12-10', + nationality: Country( + id: 41, + symbol: 'CH', + name: 'Switzerland', + nationalityAllowed: true, + locationAllowed: true, + ), + addressStreet: 'Bahnhofstrasse', + addressStreetNumber: '1', + addressPostalCode: '8000', + addressCity: 'Zurich', + addressCountry: Country( + id: 41, + symbol: 'CH', + name: 'Switzerland', + nationalityAllowed: true, + locationAllowed: true, + ), + swissTaxResidence: true, + ); test('throws BitboxNotConnectedException when the BitBox is disconnected', () async { // Disconnected fake → isConnected = false. when(() => account.primaryAddress).thenReturn( - FakeBitboxCredentials(behavior: FakeBitboxBehavior.disconnect) - ..bitboxManager = null, + FakeBitboxCredentials(behavior: FakeBitboxBehavior.disconnect)..bitboxManager = null, ); final client = MockClient((_) async => http.Response('{}', 201)); @@ -143,36 +155,35 @@ void main() { group('$RealUnitRegistrationService.registerWallet', () { RealUnitUserDataDto buildUserData() => const RealUnitUserDataDto( - email: 'a@b.com', - name: 'Ada Lovelace', - type: 'HUMAN', - phoneNumber: '+41 79 000 00 00', - birthday: '1815-12-10', - nationality: 'CH', - addressStreet: 'Bahnhofstrasse 1', - addressPostalCode: '8000', - addressCity: 'Zurich', - addressCountry: 'CH', - swissTaxResidence: true, - lang: 'de', - kycData: KycPersonalData( - accountType: KycAccountType.personal, - firstName: 'Ada', - lastName: 'Lovelace', - phone: '+41 79 000 00 00', - address: KycAddress( - street: 'Bahnhofstrasse', - zip: '8000', - city: 'Zurich', - country: 41, - ), - ), - ); + email: 'a@b.com', + name: 'Ada Lovelace', + type: 'HUMAN', + phoneNumber: '+41 79 000 00 00', + birthday: '1815-12-10', + nationality: 'CH', + addressStreet: 'Bahnhofstrasse 1', + addressPostalCode: '8000', + addressCity: 'Zurich', + addressCountry: 'CH', + swissTaxResidence: true, + lang: 'de', + kycData: KycPersonalData( + accountType: KycAccountType.personal, + firstName: 'Ada', + lastName: 'Lovelace', + phone: '+41 79 000 00 00', + address: KycAddress( + street: 'Bahnhofstrasse', + zip: '8000', + city: 'Zurich', + country: 41, + ), + ), + ); test('throws BitboxNotConnectedException when the BitBox is disconnected', () async { when(() => account.primaryAddress).thenReturn( - FakeBitboxCredentials(behavior: FakeBitboxBehavior.disconnect) - ..bitboxManager = null, + FakeBitboxCredentials(behavior: FakeBitboxBehavior.disconnect)..bitboxManager = null, ); final client = MockClient((_) async => http.Response('{}', 201)); diff --git a/test/packages/wallet/eip712_signer_test.dart b/test/packages/wallet/eip712_signer_test.dart index 45e8f9da..e749be75 100644 --- a/test/packages/wallet/eip712_signer_test.dart +++ b/test/packages/wallet/eip712_signer_test.dart @@ -54,12 +54,24 @@ void main() { lastName = 'Direct'; phoneNumber = '+41791234567'; birthday = '1990-01-15'; - nationality = const Country(id: 41, symbol: 'CH', name: 'Switzerland'); + nationality = const Country( + id: 41, + symbol: 'CH', + name: 'Switzerland', + nationalityAllowed: true, + locationAllowed: true, + ); addressStreet = 'Teststrasse'; addressStreetNumber = '1'; addressPostalCode = '8000'; addressCity = 'Zurich'; - addressCountry = const Country(id: 41, symbol: 'CH', name: 'Switzerland'); + addressCountry = const Country( + id: 41, + symbol: 'CH', + name: 'Switzerland', + nationalityAllowed: true, + locationAllowed: true, + ); swissTaxResidence = true; registrationDate = '2025-12-17'; diff --git a/test/screens/home_settings_user_data_states_test.dart b/test/screens/home_settings_user_data_states_test.dart index 0c23108d..f6bc9a48 100644 --- a/test/screens/home_settings_user_data_states_test.dart +++ b/test/screens/home_settings_user_data_states_test.dart @@ -55,11 +55,23 @@ void main() { type: RegistrationUserType.human, phoneNumber: '+41', birthday: DateTime.utc(1815, 12, 10), - nationality: const Country(id: 41, symbol: 'CH', name: 'Switzerland'), + nationality: const Country( + id: 41, + symbol: 'CH', + name: 'Switzerland', + nationalityAllowed: true, + locationAllowed: true, + ), addressStreet: 'S', addressPostalCode: '8000', addressCity: 'Zurich', - addressCountry: const Country(id: 41, symbol: 'CH', name: 'Switzerland'), + addressCountry: const Country( + id: 41, + symbol: 'CH', + name: 'Switzerland', + nationalityAllowed: true, + locationAllowed: true, + ), swissTaxResidence: true, lang: 'de', ); diff --git a/test/screens/kyc/steps/kyc_nationality_2fa_cubits_test.dart b/test/screens/kyc/steps/kyc_nationality_2fa_cubits_test.dart index 5a6de7fa..6900bef4 100644 --- a/test/screens/kyc/steps/kyc_nationality_2fa_cubits_test.dart +++ b/test/screens/kyc/steps/kyc_nationality_2fa_cubits_test.dart @@ -31,7 +31,13 @@ void main() { build: () => KycNationalityCubit(service), act: (c) => c.registerNationality( url: 'https://kyc/nat', - nationality: const Country(id: 41, symbol: 'CH', name: 'Switzerland'), + nationality: const Country( + id: 41, + symbol: 'CH', + name: 'Switzerland', + nationalityAllowed: true, + locationAllowed: true, + ), ), expect: () => [ isA(), @@ -46,17 +52,23 @@ void main() { blocTest( 'failure: setData throws → emits Loading then Failure carrying e.toString()', - setUp: () => when(() => service.setData(any(), any())) - .thenAnswer((_) async => throw Exception('boom')), + setUp: () => when( + () => service.setData(any(), any()), + ).thenAnswer((_) async => throw Exception('boom')), build: () => KycNationalityCubit(service), act: (c) => c.registerNationality( url: 'https://kyc/nat', - nationality: const Country(id: 41, symbol: 'CH', name: 'Switzerland'), + nationality: const Country( + id: 41, + symbol: 'CH', + name: 'Switzerland', + nationalityAllowed: true, + locationAllowed: true, + ), ), expect: () => [ isA(), - isA() - .having((s) => s.message, 'message', contains('boom')), + isA().having((s) => s.message, 'message', contains('boom')), ], ); }); @@ -79,14 +91,18 @@ void main() { blocTest( 'failure: request2FaCode throws → Loading → Failure(errorMessage)', - setUp: () => when(() => service.request2FaCode()) - .thenAnswer((_) async => throw Exception('rate limited')), + setUp: () => when( + () => service.request2FaCode(), + ).thenAnswer((_) async => throw Exception('rate limited')), build: () => Kyc2FaCubit(service), act: (c) => c.requestCode(), expect: () => [ isA(), - isA() - .having((s) => s.errorMessage, 'errorMessage', contains('rate limited')), + isA().having( + (s) => s.errorMessage, + 'errorMessage', + contains('rate limited'), + ), ], ); }); @@ -110,14 +126,18 @@ void main() { blocTest( 'failure: verify2FaCode throws → Loading → Failure(errorMessage)', - setUp: () => when(() => service.verify2FaCode(any())) - .thenAnswer((_) async => throw Exception('wrong code')), + setUp: () => when( + () => service.verify2FaCode(any()), + ).thenAnswer((_) async => throw Exception('wrong code')), build: () => Kyc2FaVerifyCubit(service), act: (c) => c.verifyCode('000000'), expect: () => [ isA(), - isA() - .having((s) => s.errorMessage, 'errorMessage', contains('wrong code')), + isA().having( + (s) => s.errorMessage, + 'errorMessage', + contains('wrong code'), + ), ], ); }); diff --git a/test/screens/kyc/steps/kyc_nationality_page_test.dart b/test/screens/kyc/steps/kyc_nationality_page_test.dart index b550c702..c00f1c5c 100644 --- a/test/screens/kyc/steps/kyc_nationality_page_test.dart +++ b/test/screens/kyc/steps/kyc_nationality_page_test.dart @@ -6,6 +6,7 @@ import 'package:get_it/get_it.dart'; import 'package:mocktail/mocktail.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; import 'package:realunit_wallet/screens/kyc/steps/nationality/cubit/kyc_nationality/kyc_nationality_cubit.dart'; import 'package:realunit_wallet/screens/kyc/steps/nationality/kyc_nationality_page.dart'; @@ -25,8 +26,17 @@ class MockDfxCountryService extends Mock implements DfxCountryService {} void main() { late KycNationalityCubit kycNationalityCubit; late KycCubit kycCubit; + late MockDfxCountryService countryService; final String url = 'https://example.com'; + const country = Country( + id: 41, + symbol: 'CH', + name: 'Switzerland', + nationalityAllowed: true, + locationAllowed: true, + ); + setUp(() { kycNationalityCubit = MockKycNationalityCubit(); kycCubit = MockKycCubit(); @@ -34,12 +44,14 @@ void main() { when(() => kycNationalityCubit.state).thenReturn(const KycNationalityInitial()); when(() => kycCubit.state).thenReturn(const KycInitial()); when(() => kycCubit.checkKyc()).thenAnswer((_) => Future.value()); + when(() => countryService.getAllCountries()).thenAnswer((_) async => [country]); }); void setupDependencyInjection() { final getIt = GetIt.instance; + countryService = MockDfxCountryService(); getIt.registerSingleton(MockDfxKycService()); - getIt.registerSingleton(MockDfxCountryService()); + getIt.registerSingleton(countryService); } setUpAll(() { diff --git a/test/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit_test.dart b/test/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit_test.dart index b8af0807..413b9ca2 100644 --- a/test/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit_test.dart +++ b/test/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit_test.dart @@ -17,7 +17,13 @@ class _MockDfxKycService extends Mock implements DfxKycService {} class _MockRealUnitRegistrationService extends Mock implements RealUnitRegistrationService {} -const _country = Country(id: 41, symbol: 'CH', name: 'Switzerland'); +const _country = Country( + id: 41, + symbol: 'CH', + name: 'Switzerland', + nationalityAllowed: true, + locationAllowed: true, +); Registration _registration() => const Registration( type: RegistrationUserType.human, diff --git a/test/screens/settings_user_data/settings_user_data_cubit_test.dart b/test/screens/settings_user_data/settings_user_data_cubit_test.dart index 791896ba..0cc5d1a9 100644 --- a/test/screens/settings_user_data/settings_user_data_cubit_test.dart +++ b/test/screens/settings_user_data/settings_user_data_cubit_test.dart @@ -20,8 +20,20 @@ class _MockCountryService extends Mock implements DfxCountryService {} class _MockKycService extends Mock implements DfxKycService {} -const _ch = Country(id: 41, symbol: 'CH', name: 'Switzerland'); -const _de = Country(id: 49, symbol: 'DE', name: 'Germany'); +const _ch = Country( + id: 41, + symbol: 'CH', + name: 'Switzerland', + nationalityAllowed: true, + locationAllowed: true, +); +const _de = Country( + id: 49, + symbol: 'DE', + name: 'Germany', + nationalityAllowed: true, + locationAllowed: true, +); const _address = KycAddress( street: 'Teststrasse', @@ -42,30 +54,28 @@ const _kycData = KycPersonalData( RealUnitUserDataDto _userData({ String nationality = 'CH', String addressCountry = 'CH', -}) => - RealUnitUserDataDto( - email: 'a@b.com', - name: 'Test User', - type: 'HUMAN', - phoneNumber: '+41790000000', - birthday: '1990-01-15', - nationality: nationality, - addressStreet: 'Teststrasse 1', - addressPostalCode: '8000', - addressCity: 'Zurich', - addressCountry: addressCountry, - swissTaxResidence: true, - lang: 'de', - kycData: _kycData, - ); - -KycStepDto _step(KycStepName name, KycStepStatus status, {int seq = 0}) => - KycStepDto( - name: name, - status: status, - sequenceNumber: seq, - isCurrent: false, - ); +}) => RealUnitUserDataDto( + email: 'a@b.com', + name: 'Test User', + type: 'HUMAN', + phoneNumber: '+41790000000', + birthday: '1990-01-15', + nationality: nationality, + addressStreet: 'Teststrasse 1', + addressPostalCode: '8000', + addressCity: 'Zurich', + addressCountry: addressCountry, + swissTaxResidence: true, + lang: 'de', + kycData: _kycData, +); + +KycStepDto _step(KycStepName name, KycStepStatus status, {int seq = 0}) => KycStepDto( + name: name, + status: status, + sequenceNumber: seq, + isCurrent: false, +); void main() { late _MockWalletService walletService; @@ -79,10 +89,10 @@ void main() { }); SettingsUserDataCubit build() => SettingsUserDataCubit( - walletService: walletService, - countryService: countryService, - kycService: kycService, - ); + walletService: walletService, + countryService: countryService, + kycService: kycService, + ); // Cubit fires getUserData() in its constructor; we assert the final // state via stream.firstWhere rather than the full sequence. @@ -101,7 +111,11 @@ void main() { (_) async => const UserDto( mail: 'a@b.com', kyc: UserKycDto(hash: 'h', level: KycLevel.level20, dataComplete: true), - capabilities: UserCapabilitiesDto(canEditName: true, canEditAddress: true, canEditPhone: true), + capabilities: UserCapabilitiesDto( + canEditName: true, + canEditAddress: true, + canEditPhone: true, + ), ), ); when(() => countryService.getCountryBySymbol('CH')).thenAnswer((_) async => _ch); @@ -182,8 +196,9 @@ void main() { }); test('Failure when walletService.getWalletStatus throws', () async { - when(() => walletService.getWalletStatus()) - .thenAnswer((_) async => throw Exception('network')); + when( + () => walletService.getWalletStatus(), + ).thenAnswer((_) async => throw Exception('network')); when(() => kycService.getKycStatus()).thenAnswer( (_) async => const KycLevelDto(kycLevel: KycLevel.level0, kycSteps: []), ); @@ -216,8 +231,9 @@ void main() { kyc: UserKycDto(hash: 'h', level: KycLevel.level20, dataComplete: true), ), ); - when(() => countryService.getCountryBySymbol(any())) - .thenAnswer((_) async => throw Exception('unknown country')); + when( + () => countryService.getCountryBySymbol(any()), + ).thenAnswer((_) async => throw Exception('unknown country')); final cubit = build(); await cubit.stream.firstWhere((s) => s is SettingsUserDataFailure); @@ -226,8 +242,9 @@ void main() { }); test('BitboxDisconnected when BitboxNotConnectedException thrown', () async { - when(() => walletService.getWalletStatus()) - .thenAnswer((_) async => throw const BitboxNotConnectedException()); + when( + () => walletService.getWalletStatus(), + ).thenAnswer((_) async => throw const BitboxNotConnectedException()); when(() => kycService.getKycStatus()).thenAnswer( (_) async => const KycLevelDto(kycLevel: KycLevel.level0, kycSteps: []), ); diff --git a/test/screens/settings_user_data/settings_user_data_page_test.dart b/test/screens/settings_user_data/settings_user_data_page_test.dart index 855ab862..9e7f20c8 100644 --- a/test/screens/settings_user_data/settings_user_data_page_test.dart +++ b/test/screens/settings_user_data/settings_user_data_page_test.dart @@ -89,11 +89,23 @@ void main() { type: RegistrationUserType.human, phoneNumber: '+41791234567', birthday: DateTime.now(), - nationality: const Country(id: 41, symbol: 'CH', name: 'Switzerland'), + nationality: const Country( + id: 41, + symbol: 'CH', + name: 'Switzerland', + nationalityAllowed: true, + locationAllowed: true, + ), addressStreet: 'Teststrasse', addressPostalCode: '8000', addressCity: 'Zurich', - addressCountry: const Country(id: 41, symbol: 'CH', name: 'Switzerland'), + addressCountry: const Country( + id: 41, + symbol: 'CH', + name: 'Switzerland', + nationalityAllowed: true, + locationAllowed: true, + ), swissTaxResidence: true, lang: 'DE', ); diff --git a/test/screens/settings_user_data/subpages/settings_edit_address_page_test.dart b/test/screens/settings_user_data/subpages/settings_edit_address_page_test.dart index 73624513..eec76f84 100644 --- a/test/screens/settings_user_data/subpages/settings_edit_address_page_test.dart +++ b/test/screens/settings_user_data/subpages/settings_edit_address_page_test.dart @@ -7,6 +7,7 @@ import 'package:get_it/get_it.dart'; import 'package:mocktail/mocktail.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; import 'package:realunit_wallet/screens/settings_user_data/subpages/edit_address/cubit/settings_edit_address_cubit.dart'; import 'package:realunit_wallet/screens/settings_user_data/subpages/edit_address/settings_edit_address_page.dart'; import 'package:realunit_wallet/screens/settings_user_data/subpages/others/settings_edit_failure_page.dart'; @@ -28,16 +29,27 @@ class MockDfxCountryService extends Mock implements DfxCountryService {} void main() { late SettingsEditAddressCubit settingsEditAddressCubit; + late MockDfxCountryService countryService; + + const country = Country( + id: 41, + symbol: 'CH', + name: 'Switzerland', + nationalityAllowed: true, + locationAllowed: true, + ); setUp(() { settingsEditAddressCubit = MockSettingsEditAddressCubit(); when(() => settingsEditAddressCubit.state).thenReturn(const SettingsEditAddressInitial()); + when(() => countryService.getAllCountries()).thenAnswer((_) async => [country]); }); void setupDependencyInjection() { final getIt = GetIt.instance; + countryService = MockDfxCountryService(); getIt.registerSingleton(MockDfxKycService()); - getIt.registerSingleton(MockDfxCountryService()); + getIt.registerSingleton(countryService); } setUpAll(() { @@ -96,6 +108,8 @@ void main() { when(() => settingsEditAddressCubit.state).thenReturn(const SettingsEditAddressReady('url')); await tester.pumpApp(buildSubject(const SettingsEditAddressView())); + // Let the CountryField's country-list future resolve. + await tester.pumpAndSettle(); expect(find.byType(LabeledTextField), findsNWidgets(4)); expect(find.byType(CountryField), findsOne); @@ -110,6 +124,9 @@ void main() { ).thenReturn(const SettingsEditAddressSubmitting('url')); await tester.pumpApp(buildSubject(const SettingsEditAddressView())); + // Flush the CountryField's country-list future. pumpAndSettle is unusable + // here: the submitting state shows a perpetually-animating spinner. + await tester.pump(); expect(find.byType(LabeledTextField), findsNWidgets(4)); expect(find.byType(CountryField), findsOne); diff --git a/test/widgets/form/country_field_test.dart b/test/widgets/form/country_field_test.dart new file mode 100644 index 00000000..6c2ce8dc --- /dev/null +++ b/test/widgets/form/country_field_test.dart @@ -0,0 +1,227 @@ +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; +import 'package:realunit_wallet/widgets/form/country_field.dart'; + +import '../../helper/pump_app.dart'; + +class _MockDfxCountryService extends Mock implements DfxCountryService {} + +Country _country({ + required int id, + required String symbol, + required String name, + required bool nationalityAllowed, + required bool locationAllowed, +}) => Country( + id: id, + symbol: symbol, + name: name, + nationalityAllowed: nationalityAllowed, + locationAllowed: locationAllowed, +); + +void main() { + late _MockDfxCountryService countryService; + + // Switzerland: valid as both nationality and residence. + final ch = _country( + id: 41, + symbol: 'CH', + name: 'Switzerland', + nationalityAllowed: true, + locationAllowed: true, + ); + // Nationality-only: must appear for nationality, hidden for residence. + final natOnly = _country( + id: 1, + symbol: 'NA', + name: 'Nationland', + nationalityAllowed: true, + locationAllowed: false, + ); + // Residence-only: must appear for residence, hidden for nationality. + final resOnly = _country( + id: 2, + symbol: 'RE', + name: 'Resland', + nationalityAllowed: false, + locationAllowed: true, + ); + + setUp(() { + countryService = _MockDfxCountryService(); + GetIt.instance.registerSingleton(countryService); + }); + + tearDown(() async => GetIt.instance.reset()); + + Widget host(Widget child, {GlobalKey? formKey}) { + return Scaffold( + body: Form( + key: formKey, + child: Padding(padding: const EdgeInsets.all(16), child: child), + ), + ); + } + + group('$CountryFieldPurpose', () { + test('nationality reads nationalityAllowed', () { + expect(CountryFieldPurpose.nationality.allows(natOnly), isTrue); + expect(CountryFieldPurpose.nationality.allows(resOnly), isFalse); + }); + + test('residence reads locationAllowed', () { + expect(CountryFieldPurpose.residence.allows(resOnly), isTrue); + expect(CountryFieldPurpose.residence.allows(natOnly), isFalse); + }); + }); + + group('$CountryField filtering', () { + testWidgets('nationality purpose hides countries with nationalityAllowed false', ( + tester, + ) async { + when(() => countryService.getAllCountries()).thenAnswer((_) async => [ch, natOnly, resOnly]); + + await tester.pumpApp( + host( + CountryField( + label: 'Citizenship', + purpose: CountryFieldPurpose.nationality, + onChanged: (_) {}, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + + expect(find.text('Switzerland'), findsWidgets); + expect(find.text('Nationland'), findsWidgets); + expect(find.text('Resland'), findsNothing); + }); + + testWidgets('residence purpose hides countries with locationAllowed false', (tester) async { + when(() => countryService.getAllCountries()).thenAnswer((_) async => [ch, natOnly, resOnly]); + + await tester.pumpApp( + host( + CountryField( + label: 'Country', + purpose: CountryFieldPurpose.residence, + onChanged: (_) {}, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + + expect(find.text('Switzerland'), findsWidgets); + expect(find.text('Resland'), findsWidgets); + expect(find.text('Nationland'), findsNothing); + }); + }); + + group('$CountryField no auto-selection', () { + testWidgets('does not preselect a country and does not fire onChanged', (tester) async { + when(() => countryService.getAllCountries()).thenAnswer((_) async => [ch, natOnly]); + Country? picked; + + await tester.pumpApp( + host( + CountryField( + label: 'Citizenship', + purpose: CountryFieldPurpose.nationality, + onChanged: (c) => picked = c, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(picked, isNull); + // The hint is visible because no value is selected. + expect(find.text('Schweiz'), findsOneWidget); + }); + + testWidgets('an untouched field makes the surrounding Form invalid', (tester) async { + when(() => countryService.getAllCountries()).thenAnswer((_) async => [ch, natOnly]); + final formKey = GlobalKey(); + + await tester.pumpApp( + host( + const CountryField(label: 'Citizenship', purpose: CountryFieldPurpose.nationality), + formKey: formKey, + ), + ); + await tester.pumpAndSettle(); + + expect(formKey.currentState!.validate(), isFalse); + }); + }); + + group('$CountryField loading state', () { + testWidgets('keeps the field present and the Form invalid while loading', (tester) async { + final completer = Completer>(); + when(() => countryService.getAllCountries()).thenAnswer((_) => completer.future); + final formKey = GlobalKey(); + + await tester.pumpApp( + host( + const CountryField(label: 'Citizenship', purpose: CountryFieldPurpose.nationality), + formKey: formKey, + ), + ); + // Still loading — no pumpAndSettle. + await tester.pump(); + + expect(find.text('Citizenship'), findsOneWidget); + expect(find.byType(CupertinoActivityIndicator), findsOneWidget); + expect(formKey.currentState!.validate(), isFalse); + + completer.complete([ch]); + await tester.pumpAndSettle(); + }); + }); + + group('$CountryField error state', () { + testWidgets('keeps the field present, the Form invalid, and offers a retry', (tester) async { + var calls = 0; + when(() => countryService.getAllCountries()).thenAnswer((_) async { + calls++; + if (calls == 1) throw Exception('network down'); + return [ch]; + }); + final formKey = GlobalKey(); + + await tester.pumpApp( + host( + const CountryField(label: 'Citizenship', purpose: CountryFieldPurpose.nationality), + formKey: formKey, + ), + ); + await tester.pumpAndSettle(); + + // The field is present, the raw exception is not leaked, the Form is invalid. + expect(find.text('Citizenship'), findsOneWidget); + expect(find.textContaining('network down'), findsNothing); + expect(formKey.currentState!.validate(), isFalse); + expect(find.byType(DropdownButtonFormField), findsNothing); + + // Retry re-runs the load and recovers into the dropdown. + await tester.tap(find.byType(TextButton)); + await tester.pumpAndSettle(); + + expect(calls, 2); + expect(find.byType(DropdownButtonFormField), findsOneWidget); + }); + }); +} From 82154ae99aaa7a669debb31504aaeda33002cfde Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 22 May 2026 15:27:30 +0200 Subject: [PATCH 2/2] fix(kyc): correct country picker allow-flag to kycAllowed A backend audit of DFXswiss/api showed the allow-flags this branch originally filtered by are wrong: the CountryDto `locationAllowed` field is `ipEnable` (it gates the request IP's country, not a user-entered country), and `nationalityAllowed` maps to the entity column `nationalityStepEnable`, which the backend reads nowhere. - the residence/address picker now filters by `kycAllowed` (= `dfxEnable`), the flag the backend actually enforces on the address country (HTTP 400 on submit otherwise) - the nationality picker no longer filters: nationality is a fact, and the backend routes a disallowed nationality to manual review rather than rejecting it, so hiding countries would be wrong - Country drops nationalityAllowed/locationAllowed, carries kycAllowed --- .../service/dfx/models/country/country.dart | 14 ++--- lib/widgets/form/country_field.dart | 9 ++- .../dfx/models/aggregate_dtos_test.dart | 9 +-- .../models/country/dfx_country_dto_test.dart | 35 ++++------- ..._unit_registration_service_happy_test.dart | 6 +- .../real_unit_registration_service_test.dart | 6 +- test/packages/wallet/eip712_signer_test.dart | 6 +- .../home_settings_user_data_states_test.dart | 6 +- .../kyc_nationality_2fa_cubits_test.dart | 6 +- .../kyc/steps/kyc_nationality_page_test.dart | 3 +- .../kyc_registration_submit_cubit_test.dart | 3 +- .../settings_user_data_cubit_test.dart | 6 +- .../settings_user_data_page_test.dart | 6 +- .../settings_edit_address_page_test.dart | 3 +- test/widgets/form/country_field_test.dart | 59 +++++++++---------- 15 files changed, 74 insertions(+), 103 deletions(-) diff --git a/lib/packages/service/dfx/models/country/country.dart b/lib/packages/service/dfx/models/country/country.dart index 1ba7e11a..b273ad69 100644 --- a/lib/packages/service/dfx/models/country/country.dart +++ b/lib/packages/service/dfx/models/country/country.dart @@ -6,17 +6,16 @@ class Country extends Equatable { final String symbol; final String name; final String? foreignName; - // Whether the backend accepts this country as a person's nationality. - final bool nationalityAllowed; - // Whether the backend accepts this country as a residence/address country. - final bool locationAllowed; + // Whether the backend accepts this country as a KYC address/residence + // country (DTO alias `kycAllowed`, backed by `dfxEnable`). A non-allowed + // address country is rejected with HTTP 400 on registration submit. + final bool kycAllowed; const Country({ required this.id, required this.symbol, required this.name, - required this.nationalityAllowed, - required this.locationAllowed, + required this.kycAllowed, this.foreignName, }); @@ -31,8 +30,7 @@ extension DfxCountryDtoMapper on DfxCountryDto { symbol: symbol, name: name, foreignName: foreignName, - nationalityAllowed: nationalityAllowed, - locationAllowed: locationAllowed, + kycAllowed: kycAllowed, ); } } diff --git a/lib/widgets/form/country_field.dart b/lib/widgets/form/country_field.dart index 06da9d40..ea78b8ed 100644 --- a/lib/widgets/form/country_field.dart +++ b/lib/widgets/form/country_field.dart @@ -7,15 +7,18 @@ import 'package:realunit_wallet/setup/di.dart'; import 'package:realunit_wallet/styles/colors.dart'; import 'package:realunit_wallet/widgets/form/dropdown_field.dart'; -/// Selects which backend allow-flag gates the country list for a given field. +/// Selects how a [CountryField] gates its country list. enum CountryFieldPurpose { nationality, residence ; bool allows(Country country) => switch (this) { - CountryFieldPurpose.nationality => country.nationalityAllowed, - CountryFieldPurpose.residence => country.locationAllowed, + // Nationality is a fact, not a permission: the backend routes a + // disallowed nationality to manual review instead of rejecting it, + // so the picker must offer every country. + CountryFieldPurpose.nationality => true, + CountryFieldPurpose.residence => country.kycAllowed, }; } diff --git a/test/packages/service/dfx/models/aggregate_dtos_test.dart b/test/packages/service/dfx/models/aggregate_dtos_test.dart index 0ec5425a..b9f1174c 100644 --- a/test/packages/service/dfx/models/aggregate_dtos_test.dart +++ b/test/packages/service/dfx/models/aggregate_dtos_test.dart @@ -106,22 +106,19 @@ void main() { id: 41, symbol: 'CH', name: 'Switzerland', - nationalityAllowed: true, - locationAllowed: true, + kycAllowed: true, ); const b = Country( id: 41, symbol: 'XX', name: 'Different', - nationalityAllowed: true, - locationAllowed: true, + kycAllowed: true, ); const c = Country( id: 49, symbol: 'DE', name: 'Germany', - nationalityAllowed: true, - locationAllowed: true, + kycAllowed: true, ); expect(a, b); diff --git a/test/packages/service/dfx/models/country/dfx_country_dto_test.dart b/test/packages/service/dfx/models/country/dfx_country_dto_test.dart index fe435699..b71fa5c7 100644 --- a/test/packages/service/dfx/models/country/dfx_country_dto_test.dart +++ b/test/packages/service/dfx/models/country/dfx_country_dto_test.dart @@ -5,18 +5,17 @@ import 'package:realunit_wallet/packages/service/dfx/models/country/dto/dfx_coun Map _wire({ String? foreignName, bool ibanAllowed = true, - bool locationAllowed = true, - bool nationalityAllowed = true, + bool kycAllowed = true, }) => { 'id': 41, 'symbol': 'CH', 'name': 'Switzerland', 'foreignName': foreignName, - 'locationAllowed': locationAllowed, + 'locationAllowed': true, 'ibanAllowed': ibanAllowed, - 'kycAllowed': true, + 'kycAllowed': kycAllowed, 'kycOrganizationAllowed': true, - 'nationalityAllowed': nationalityAllowed, + 'nationalityAllowed': true, 'bankAllowed': true, 'cardAllowed': true, 'cryptoAllowed': true, @@ -55,7 +54,7 @@ void main() { }); group('DfxCountryDtoMapper.toCountry', () { - test('keeps id / symbol / name / foreignName and the purpose allow-flags', () { + test('keeps id / symbol / name / foreignName and kycAllowed', () { final country = DfxCountryDto.fromJson(_wire(foreignName: 'Schweiz')).toCountry(); expect(country, isA()); @@ -63,17 +62,13 @@ void main() { expect(country.symbol, 'CH'); expect(country.name, 'Switzerland'); expect(country.foreignName, 'Schweiz'); - expect(country.nationalityAllowed, isTrue); - expect(country.locationAllowed, isTrue); + expect(country.kycAllowed, isTrue); }); - test('passes false allow-flags through unchanged', () { - final country = DfxCountryDto.fromJson( - _wire(nationalityAllowed: false, locationAllowed: false), - ).toCountry(); + test('passes kycAllowed false through unchanged', () { + final country = DfxCountryDto.fromJson(_wire(kycAllowed: false)).toCountry(); - expect(country.nationalityAllowed, isFalse); - expect(country.locationAllowed, isFalse); + expect(country.kycAllowed, isFalse); }); }); @@ -83,16 +78,14 @@ void main() { id: 41, symbol: 'CH', name: 'Switzerland', - nationalityAllowed: true, - locationAllowed: true, + kycAllowed: true, ); const b = Country( id: 41, symbol: 'XX', name: 'Different name', foreignName: 'F', - nationalityAllowed: false, - locationAllowed: false, + kycAllowed: false, ); // Equality only on id (props returns [id]). @@ -104,15 +97,13 @@ void main() { id: 41, symbol: 'CH', name: 'Switzerland', - nationalityAllowed: true, - locationAllowed: true, + kycAllowed: true, ); const b = Country( id: 49, symbol: 'DE', name: 'Germany', - nationalityAllowed: true, - locationAllowed: true, + kycAllowed: true, ); expect(a, isNot(equals(b))); diff --git a/test/packages/service/dfx/real_unit_registration_service_happy_test.dart b/test/packages/service/dfx/real_unit_registration_service_happy_test.dart index 93c3fd2a..55f7c5c2 100644 --- a/test/packages/service/dfx/real_unit_registration_service_happy_test.dart +++ b/test/packages/service/dfx/real_unit_registration_service_happy_test.dart @@ -78,8 +78,7 @@ void main() { id: 41, symbol: 'CH', name: 'Switzerland', - nationalityAllowed: true, - locationAllowed: true, + kycAllowed: true, ), addressStreet: 'Bahnhofstraße', addressStreetNumber: '1', @@ -89,8 +88,7 @@ void main() { id: 41, symbol: 'CH', name: 'Switzerland', - nationalityAllowed: true, - locationAllowed: true, + kycAllowed: true, ), swissTaxResidence: true, ); diff --git a/test/packages/service/dfx/real_unit_registration_service_test.dart b/test/packages/service/dfx/real_unit_registration_service_test.dart index af9e0bc6..239b494d 100644 --- a/test/packages/service/dfx/real_unit_registration_service_test.dart +++ b/test/packages/service/dfx/real_unit_registration_service_test.dart @@ -122,8 +122,7 @@ void main() { id: 41, symbol: 'CH', name: 'Switzerland', - nationalityAllowed: true, - locationAllowed: true, + kycAllowed: true, ), addressStreet: 'Bahnhofstrasse', addressStreetNumber: '1', @@ -133,8 +132,7 @@ void main() { id: 41, symbol: 'CH', name: 'Switzerland', - nationalityAllowed: true, - locationAllowed: true, + kycAllowed: true, ), swissTaxResidence: true, ); diff --git a/test/packages/wallet/eip712_signer_test.dart b/test/packages/wallet/eip712_signer_test.dart index e749be75..4b87d889 100644 --- a/test/packages/wallet/eip712_signer_test.dart +++ b/test/packages/wallet/eip712_signer_test.dart @@ -58,8 +58,7 @@ void main() { id: 41, symbol: 'CH', name: 'Switzerland', - nationalityAllowed: true, - locationAllowed: true, + kycAllowed: true, ); addressStreet = 'Teststrasse'; addressStreetNumber = '1'; @@ -69,8 +68,7 @@ void main() { id: 41, symbol: 'CH', name: 'Switzerland', - nationalityAllowed: true, - locationAllowed: true, + kycAllowed: true, ); swissTaxResidence = true; registrationDate = '2025-12-17'; diff --git a/test/screens/home_settings_user_data_states_test.dart b/test/screens/home_settings_user_data_states_test.dart index f6bc9a48..8bbc2052 100644 --- a/test/screens/home_settings_user_data_states_test.dart +++ b/test/screens/home_settings_user_data_states_test.dart @@ -59,8 +59,7 @@ void main() { id: 41, symbol: 'CH', name: 'Switzerland', - nationalityAllowed: true, - locationAllowed: true, + kycAllowed: true, ), addressStreet: 'S', addressPostalCode: '8000', @@ -69,8 +68,7 @@ void main() { id: 41, symbol: 'CH', name: 'Switzerland', - nationalityAllowed: true, - locationAllowed: true, + kycAllowed: true, ), swissTaxResidence: true, lang: 'de', diff --git a/test/screens/kyc/steps/kyc_nationality_2fa_cubits_test.dart b/test/screens/kyc/steps/kyc_nationality_2fa_cubits_test.dart index 6900bef4..9b06b86c 100644 --- a/test/screens/kyc/steps/kyc_nationality_2fa_cubits_test.dart +++ b/test/screens/kyc/steps/kyc_nationality_2fa_cubits_test.dart @@ -35,8 +35,7 @@ void main() { id: 41, symbol: 'CH', name: 'Switzerland', - nationalityAllowed: true, - locationAllowed: true, + kycAllowed: true, ), ), expect: () => [ @@ -62,8 +61,7 @@ void main() { id: 41, symbol: 'CH', name: 'Switzerland', - nationalityAllowed: true, - locationAllowed: true, + kycAllowed: true, ), ), expect: () => [ diff --git a/test/screens/kyc/steps/kyc_nationality_page_test.dart b/test/screens/kyc/steps/kyc_nationality_page_test.dart index c00f1c5c..56815225 100644 --- a/test/screens/kyc/steps/kyc_nationality_page_test.dart +++ b/test/screens/kyc/steps/kyc_nationality_page_test.dart @@ -33,8 +33,7 @@ void main() { id: 41, symbol: 'CH', name: 'Switzerland', - nationalityAllowed: true, - locationAllowed: true, + kycAllowed: true, ); setUp(() { diff --git a/test/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit_test.dart b/test/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit_test.dart index 413b9ca2..ef669cbf 100644 --- a/test/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit_test.dart +++ b/test/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit_test.dart @@ -21,8 +21,7 @@ const _country = Country( id: 41, symbol: 'CH', name: 'Switzerland', - nationalityAllowed: true, - locationAllowed: true, + kycAllowed: true, ); Registration _registration() => const Registration( diff --git a/test/screens/settings_user_data/settings_user_data_cubit_test.dart b/test/screens/settings_user_data/settings_user_data_cubit_test.dart index 0cc5d1a9..3b423b5b 100644 --- a/test/screens/settings_user_data/settings_user_data_cubit_test.dart +++ b/test/screens/settings_user_data/settings_user_data_cubit_test.dart @@ -24,15 +24,13 @@ const _ch = Country( id: 41, symbol: 'CH', name: 'Switzerland', - nationalityAllowed: true, - locationAllowed: true, + kycAllowed: true, ); const _de = Country( id: 49, symbol: 'DE', name: 'Germany', - nationalityAllowed: true, - locationAllowed: true, + kycAllowed: true, ); const _address = KycAddress( diff --git a/test/screens/settings_user_data/settings_user_data_page_test.dart b/test/screens/settings_user_data/settings_user_data_page_test.dart index 9e7f20c8..fd12bf71 100644 --- a/test/screens/settings_user_data/settings_user_data_page_test.dart +++ b/test/screens/settings_user_data/settings_user_data_page_test.dart @@ -93,8 +93,7 @@ void main() { id: 41, symbol: 'CH', name: 'Switzerland', - nationalityAllowed: true, - locationAllowed: true, + kycAllowed: true, ), addressStreet: 'Teststrasse', addressPostalCode: '8000', @@ -103,8 +102,7 @@ void main() { id: 41, symbol: 'CH', name: 'Switzerland', - nationalityAllowed: true, - locationAllowed: true, + kycAllowed: true, ), swissTaxResidence: true, lang: 'DE', diff --git a/test/screens/settings_user_data/subpages/settings_edit_address_page_test.dart b/test/screens/settings_user_data/subpages/settings_edit_address_page_test.dart index eec76f84..d281e7bf 100644 --- a/test/screens/settings_user_data/subpages/settings_edit_address_page_test.dart +++ b/test/screens/settings_user_data/subpages/settings_edit_address_page_test.dart @@ -35,8 +35,7 @@ void main() { id: 41, symbol: 'CH', name: 'Switzerland', - nationalityAllowed: true, - locationAllowed: true, + kycAllowed: true, ); setUp(() { diff --git a/test/widgets/form/country_field_test.dart b/test/widgets/form/country_field_test.dart index 6c2ce8dc..0fed26a5 100644 --- a/test/widgets/form/country_field_test.dart +++ b/test/widgets/form/country_field_test.dart @@ -17,42 +17,37 @@ Country _country({ required int id, required String symbol, required String name, - required bool nationalityAllowed, - required bool locationAllowed, + required bool kycAllowed, }) => Country( id: id, symbol: symbol, name: name, - nationalityAllowed: nationalityAllowed, - locationAllowed: locationAllowed, + kycAllowed: kycAllowed, ); void main() { late _MockDfxCountryService countryService; - // Switzerland: valid as both nationality and residence. + // KYC-allowed: shown for residence and for nationality. final ch = _country( id: 41, symbol: 'CH', name: 'Switzerland', - nationalityAllowed: true, - locationAllowed: true, + kycAllowed: true, ); - // Nationality-only: must appear for nationality, hidden for residence. - final natOnly = _country( + // Also KYC-allowed: a second allowed country. + final allowed = _country( id: 1, symbol: 'NA', name: 'Nationland', - nationalityAllowed: true, - locationAllowed: false, + kycAllowed: true, ); - // Residence-only: must appear for residence, hidden for nationality. - final resOnly = _country( + // Not KYC-allowed: hidden for residence, still shown for nationality. + final notAllowed = _country( id: 2, symbol: 'RE', name: 'Resland', - nationalityAllowed: false, - locationAllowed: true, + kycAllowed: false, ); setUp(() { @@ -72,22 +67,24 @@ void main() { } group('$CountryFieldPurpose', () { - test('nationality reads nationalityAllowed', () { - expect(CountryFieldPurpose.nationality.allows(natOnly), isTrue); - expect(CountryFieldPurpose.nationality.allows(resOnly), isFalse); + test('nationality allows every country regardless of kycAllowed', () { + expect(CountryFieldPurpose.nationality.allows(ch), isTrue); + expect(CountryFieldPurpose.nationality.allows(notAllowed), isTrue); }); - test('residence reads locationAllowed', () { - expect(CountryFieldPurpose.residence.allows(resOnly), isTrue); - expect(CountryFieldPurpose.residence.allows(natOnly), isFalse); + test('residence reads kycAllowed', () { + expect(CountryFieldPurpose.residence.allows(ch), isTrue); + expect(CountryFieldPurpose.residence.allows(notAllowed), isFalse); }); }); group('$CountryField filtering', () { - testWidgets('nationality purpose hides countries with nationalityAllowed false', ( + testWidgets('nationality purpose shows every country, including non-KYC ones', ( tester, ) async { - when(() => countryService.getAllCountries()).thenAnswer((_) async => [ch, natOnly, resOnly]); + when( + () => countryService.getAllCountries(), + ).thenAnswer((_) async => [ch, allowed, notAllowed]); await tester.pumpApp( host( @@ -105,11 +102,13 @@ void main() { expect(find.text('Switzerland'), findsWidgets); expect(find.text('Nationland'), findsWidgets); - expect(find.text('Resland'), findsNothing); + expect(find.text('Resland'), findsWidgets); }); - testWidgets('residence purpose hides countries with locationAllowed false', (tester) async { - when(() => countryService.getAllCountries()).thenAnswer((_) async => [ch, natOnly, resOnly]); + testWidgets('residence purpose hides countries with kycAllowed false', (tester) async { + when( + () => countryService.getAllCountries(), + ).thenAnswer((_) async => [ch, allowed, notAllowed]); await tester.pumpApp( host( @@ -126,14 +125,14 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Switzerland'), findsWidgets); - expect(find.text('Resland'), findsWidgets); - expect(find.text('Nationland'), findsNothing); + expect(find.text('Nationland'), findsWidgets); + expect(find.text('Resland'), findsNothing); }); }); group('$CountryField no auto-selection', () { testWidgets('does not preselect a country and does not fire onChanged', (tester) async { - when(() => countryService.getAllCountries()).thenAnswer((_) async => [ch, natOnly]); + when(() => countryService.getAllCountries()).thenAnswer((_) async => [ch, allowed]); Country? picked; await tester.pumpApp( @@ -153,7 +152,7 @@ void main() { }); testWidgets('an untouched field makes the surrounding Form invalid', (tester) async { - when(() => countryService.getAllCountries()).thenAnswer((_) async => [ch, natOnly]); + when(() => countryService.getAllCountries()).thenAnswer((_) async => [ch, allowed]); final formKey = GlobalKey(); await tester.pumpApp(