From e4c6cac640b0d32cc4f8079ec9ee5c6b91f24616 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 17 Jun 2026 19:23:10 +0200 Subject: [PATCH] refactor(kyc): consume API validation pattern instead of hardcoded swissPaymentText mirror The app kept a byte-for-byte copy of the API's swissPaymentText regex in lib/packages/utils/swiss_payment_text.dart to validate name/address fields inline. That mirror silently drifts whenever the backend's Config.formats.swissPaymentText changes, and it encodes an allowed-character rule the API owns. Replace it with a DfxConfigService that fetches the authoritative pattern from GET /v1/config, compiles it at runtime and validates synchronously. The pattern is warmed at startup and cached (30 min TTL); it is dropped on network switch alongside the other reference-data caches. If the pattern hasn't loaded yet the validator returns true and defers to the server's authoritative @IsSwissPaymentText() check, so the app is never more restrictive than the API and needs no hardcoded fallback. This also fixes the foreign-postal-code class of bugs: NL "1011 AB", UK "EC1A 1BB", CA "K1A 0B1", IE "D02 AF30" now validate correctly because the rule comes from the API rather than a client guess. Deletes the local mirror and its test in the same PR. Server-side change: DFXswiss/api#3911. --- .../service/dfx/dfx_config_service.dart | 83 +++++++++ .../dfx/models/config/dto/dfx_config_dto.dart | 61 +++++++ lib/packages/utils/swiss_payment_text.dart | 30 ---- .../steps/kyc_registration_address_step.dart | 19 +- .../steps/kyc_registration_personal_step.dart | 11 +- .../settings_edit_address_page.dart | 26 ++- .../edit_name/settings_edit_name_page.dart | 10 +- lib/setup/di.dart | 18 ++ .../service/dfx/dfx_config_service_test.dart | 161 +++++++++++++++++ .../utils/swiss_payment_text_test.dart | 164 ------------------ 10 files changed, 373 insertions(+), 210 deletions(-) create mode 100644 lib/packages/service/dfx/dfx_config_service.dart create mode 100644 lib/packages/service/dfx/models/config/dto/dfx_config_dto.dart delete mode 100644 lib/packages/utils/swiss_payment_text.dart create mode 100644 test/packages/service/dfx/dfx_config_service_test.dart delete mode 100644 test/packages/utils/swiss_payment_text_test.dart diff --git a/lib/packages/service/dfx/dfx_config_service.dart b/lib/packages/service/dfx/dfx_config_service.dart new file mode 100644 index 000000000..a69274608 --- /dev/null +++ b/lib/packages/service/dfx/dfx_config_service.dart @@ -0,0 +1,83 @@ +import 'dart:convert'; + +import 'package:clock/clock.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/config/dto/dfx_config_dto.dart'; + +/// Fetches the API's authoritative input validation patterns from +/// `GET /v1/config`. +/// +/// The app validates name/address fields against the pattern the API serves +/// instead of hardcoding a copy of the `swissPaymentText` regex. The server +/// keeps re-validating authoritatively (`@IsSwissPaymentText()`), so this is +/// purely for instant inline UX — never a gate the backend doesn't also +/// enforce. +/// +/// The compiled pattern is cached in memory so form validators can run +/// synchronously ([isSwissPaymentText]); a TTL lets mid-session backend +/// changes surface without an app restart. +class DfxConfigService { + static const _configPath = '/v1/config'; + + /// How long a fetched config stays cached before the next call refetches. + /// The pattern changes only on a backend deploy, so a generous TTL is fine. + static const _cacheTtl = Duration(minutes: 30); + + DfxConfigDto? _cached; + DateTime? _cachedAt; + RegExp? _swissPaymentText; + + final AppStore _appStore; + + String get _host => _appStore.apiConfig.apiHost; + + DfxConfigService(AppStore appStore) : _appStore = appStore; + + Future getConfig() async { + final cached = _cached; + final cachedAt = _cachedAt; + if (cached != null && cachedAt != null && clock.now().difference(cachedAt) < _cacheTtl) { + return cached; + } + + final uri = buildUri(_host, _configPath); + final response = await _appStore.httpClient.get(uri); + + if (response.statusCode != 200) { + throw Exception('Failed to fetch config: ${response.statusCode}'); + } + + final config = DfxConfigDto.fromJson(jsonDecode(response.body) as Map); + _cached = config; + _cachedAt = clock.now(); + _swissPaymentText = config.formats.swissPaymentText.toRegExp(); + return config; + } + + /// Warms the in-memory cache so [isSwissPaymentText] can validate + /// synchronously. Safe to call eagerly at startup; failures are the + /// caller's concern (the validator degrades gracefully if unloaded). + Future load() => getConfig(); + + /// Returns `true` if [value] is accepted by the API's `swissPaymentText` + /// pattern. Empty/null is valid (callers chain a non-empty check). + /// + /// If the pattern has not loaded yet, returns `true` (does not block) — the + /// server re-validates authoritatively, so the app is never *more* + /// restrictive than the API and never needs a hardcoded fallback copy. + bool isSwissPaymentText(String? value) { + if (value == null || value.isEmpty) return true; + final pattern = _swissPaymentText; + if (pattern == null) return true; + return pattern.hasMatch(value); + } + + /// Drops the in-memory cache so the next [getConfig]/[load] refetches. + /// Intended for explicit invalidation points (e.g. network switch). + void invalidateCache() { + _cached = null; + _cachedAt = null; + _swissPaymentText = null; + } +} diff --git a/lib/packages/service/dfx/models/config/dto/dfx_config_dto.dart b/lib/packages/service/dfx/models/config/dto/dfx_config_dto.dart new file mode 100644 index 000000000..563db88eb --- /dev/null +++ b/lib/packages/service/dfx/models/config/dto/dfx_config_dto.dart @@ -0,0 +1,61 @@ +/// DTOs for `GET /v1/config` — the API's authoritative input validation +/// patterns. The app compiles these at runtime instead of hardcoding a copy +/// of the rules (see `DfxConfigService`). +library; + +/// A single validation pattern as exposed by the API: a JavaScript `RegExp` +/// source plus its flags. +class DfxValidationFormatDto { + final String pattern; + final String flags; + + const DfxValidationFormatDto({required this.pattern, required this.flags}); + + factory DfxValidationFormatDto.fromJson(Map json) { + return DfxValidationFormatDto( + pattern: json['pattern'] as String, + flags: json['flags'] as String, + ); + } + + /// Compiles the API-provided source/flags into a Dart [RegExp]. Maps the + /// JavaScript flag characters this app cares about onto their Dart + /// `RegExp` equivalents. + RegExp toRegExp() { + return RegExp( + pattern, + unicode: flags.contains('u'), + multiLine: flags.contains('m'), + caseSensitive: !flags.contains('i'), + dotAll: flags.contains('s'), + ); + } +} + +/// The `formats` map of the config response. Mirrors the API DTO for type +/// safety only — the values themselves are the source of truth. +class DfxFormatsDto { + final DfxValidationFormatDto swissPaymentText; + + const DfxFormatsDto({required this.swissPaymentText}); + + factory DfxFormatsDto.fromJson(Map json) { + return DfxFormatsDto( + swissPaymentText: DfxValidationFormatDto.fromJson( + json['swissPaymentText'] as Map, + ), + ); + } +} + +class DfxConfigDto { + final DfxFormatsDto formats; + + const DfxConfigDto({required this.formats}); + + factory DfxConfigDto.fromJson(Map json) { + return DfxConfigDto( + formats: DfxFormatsDto.fromJson(json['formats'] as Map), + ); + } +} diff --git a/lib/packages/utils/swiss_payment_text.dart b/lib/packages/utils/swiss_payment_text.dart deleted file mode 100644 index ce445c8e6..000000000 --- a/lib/packages/utils/swiss_payment_text.dart +++ /dev/null @@ -1,30 +0,0 @@ -/// Validator for user-supplied name and address fields that mirror the -/// SIX SIG IG QR-Bill v2.3 permitted character set — printable ASCII plus -/// the Latin diacritics required for the four Swiss national languages. -/// -/// Kept byte-for-byte aligned with the API-side -/// `Config.formats.swissPaymentText` regex -/// (`api/src/config/config.ts`) so client-side validation matches what the -/// backend's `@IsSwissPaymentText()` decorator accepts. Without this, -/// non-Latin input would only fail server-side with a generic 400. -library; - -final RegExp _swissPaymentText = RegExp( - r'^[\x20-\x7E' - 'ÀÁÂÄÇÈÉÊË' - 'ÌÍÎÏÑÒÓÔÖ' - 'ÙÚÛÜÝß' - 'àáâäçèéêë' - 'ìíîïñòóôö' - 'ùúûüý' - r'\n]*$', - unicode: true, -); - -/// Returns `true` if [value] contains only characters permitted in Swiss -/// payment systems. Empty/null values are considered valid (callers should -/// chain a non-empty check separately). -bool isSwissPaymentText(String? value) { - if (value == null || value.isEmpty) return true; - return _swissPaymentText.hasMatch(value); -} 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 f863961ce..654555267 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 @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_config_service.dart'; import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; -import 'package:realunit_wallet/packages/utils/swiss_payment_text.dart'; +import 'package:realunit_wallet/setup/di.dart'; import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; import 'package:realunit_wallet/widgets/form/country_field.dart'; import 'package:realunit_wallet/widgets/form/labeled_text_field.dart'; @@ -54,7 +55,9 @@ class KycRegistrationAddressStep extends StatelessWidget { textCapitalization: TextCapitalization.words, validator: (value) { if (value == null || value.isEmpty) return ''; - if (!isSwissPaymentText(value)) return S.of(context).swissPaymentTextInvalid; + if (!getIt().isSwissPaymentText(value)) { + return S.of(context).swissPaymentTextInvalid; + } return null; }, ), @@ -67,7 +70,9 @@ class KycRegistrationAddressStep extends StatelessWidget { keyboardType: TextInputType.streetAddress, validator: (value) { if (value == null || value.isEmpty) return ''; - if (!isSwissPaymentText(value)) return S.of(context).swissPaymentTextInvalid; + if (!getIt().isSwissPaymentText(value)) { + return S.of(context).swissPaymentTextInvalid; + } return null; }, ), @@ -87,7 +92,9 @@ class KycRegistrationAddressStep extends StatelessWidget { keyboardType: TextInputType.number, validator: (value) { if (value == null || value.isEmpty) return ''; - if (!isSwissPaymentText(value)) return S.of(context).swissPaymentTextInvalid; + if (!getIt().isSwissPaymentText(value)) { + return S.of(context).swissPaymentTextInvalid; + } return null; }, ), @@ -102,7 +109,9 @@ class KycRegistrationAddressStep extends StatelessWidget { textCapitalization: TextCapitalization.words, validator: (value) { if (value == null || value.isEmpty) return ''; - if (!isSwissPaymentText(value)) return S.of(context).swissPaymentTextInvalid; + if (!getIt().isSwissPaymentText(value)) { + return S.of(context).swissPaymentTextInvalid; + } return null; }, ), 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 748b7da65..67870b417 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 @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_config_service.dart'; import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_user_type.dart'; -import 'package:realunit_wallet/packages/utils/swiss_payment_text.dart'; import 'package:realunit_wallet/screens/kyc/steps/registration/cubits/registration_step/kyc_registration_step_cubit.dart'; +import 'package:realunit_wallet/setup/di.dart'; import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; import 'package:realunit_wallet/widgets/form/birthday_field.dart'; import 'package:realunit_wallet/widgets/form/country_field.dart'; @@ -72,7 +73,9 @@ class KycRegistrationPersonalStep extends StatelessWidget { textCapitalization: TextCapitalization.words, validator: (value) { if (value == null || value.isEmpty) return ''; - if (!isSwissPaymentText(value)) return S.of(context).swissPaymentTextInvalid; + if (!getIt().isSwissPaymentText(value)) { + return S.of(context).swissPaymentTextInvalid; + } return null; }, ), @@ -86,7 +89,9 @@ class KycRegistrationPersonalStep extends StatelessWidget { textCapitalization: TextCapitalization.words, validator: (value) { if (value == null || value.isEmpty) return ''; - if (!isSwissPaymentText(value)) return S.of(context).swissPaymentTextInvalid; + if (!getIt().isSwissPaymentText(value)) { + return S.of(context).swissPaymentTextInvalid; + } return null; }, ), 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 a72cd9cf6..ac95dc239 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 @@ -3,9 +3,9 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_config_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/packages/utils/swiss_payment_text.dart'; import 'package:realunit_wallet/packages/utils/xfile_extension.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/others/settings_edit_failure_page.dart'; @@ -112,7 +112,11 @@ class _SettingsEditAddressViewState extends State { textCapitalization: .words, validator: (value) { if (value == null || value.isEmpty) return ''; - if (!isSwissPaymentText(value)) return S.of(context).swissPaymentTextInvalid; + if (!getIt().isSwissPaymentText( + value, + )) { + return S.of(context).swissPaymentTextInvalid; + } return null; }, ), @@ -125,7 +129,11 @@ class _SettingsEditAddressViewState extends State { keyboardType: .streetAddress, validator: (value) { if (value == null || value.isEmpty) return null; - if (!isSwissPaymentText(value)) return S.of(context).swissPaymentTextInvalid; + if (!getIt().isSwissPaymentText( + value, + )) { + return S.of(context).swissPaymentTextInvalid; + } return null; }, ), @@ -145,7 +153,11 @@ class _SettingsEditAddressViewState extends State { keyboardType: .number, validator: (value) { if (value == null || value.isEmpty) return ''; - if (!isSwissPaymentText(value)) return S.of(context).swissPaymentTextInvalid; + if (!getIt().isSwissPaymentText( + value, + )) { + return S.of(context).swissPaymentTextInvalid; + } return null; }, ), @@ -160,7 +172,11 @@ class _SettingsEditAddressViewState extends State { textCapitalization: .words, validator: (value) { if (value == null || value.isEmpty) return ''; - if (!isSwissPaymentText(value)) return S.of(context).swissPaymentTextInvalid; + if (!getIt().isSwissPaymentText( + value, + )) { + return S.of(context).swissPaymentTextInvalid; + } return null; }, ), diff --git a/lib/screens/settings_user_data/subpages/edit_name/settings_edit_name_page.dart b/lib/screens/settings_user_data/subpages/edit_name/settings_edit_name_page.dart index 1eb291181..ddc484b2b 100644 --- a/lib/screens/settings_user_data/subpages/edit_name/settings_edit_name_page.dart +++ b/lib/screens/settings_user_data/subpages/edit_name/settings_edit_name_page.dart @@ -3,8 +3,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_config_service.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; -import 'package:realunit_wallet/packages/utils/swiss_payment_text.dart'; import 'package:realunit_wallet/packages/utils/xfile_extension.dart'; import 'package:realunit_wallet/screens/settings_user_data/subpages/edit_name/cubit/settings_edit_name_cubit.dart'; import 'package:realunit_wallet/screens/settings_user_data/subpages/others/settings_edit_failure_page.dart'; @@ -99,7 +99,9 @@ class _SettingsEditNameViewState extends State { textCapitalization: .words, validator: (value) { if (value == null || value.isEmpty) return ''; - if (!isSwissPaymentText(value)) return S.of(context).swissPaymentTextInvalid; + if (!getIt().isSwissPaymentText(value)) { + return S.of(context).swissPaymentTextInvalid; + } return null; }, ), @@ -110,7 +112,9 @@ class _SettingsEditNameViewState extends State { textCapitalization: .words, validator: (value) { if (value == null || value.isEmpty) return ''; - if (!isSwissPaymentText(value)) return S.of(context).swissPaymentTextInvalid; + if (!getIt().isSwissPaymentText(value)) { + return S.of(context).swissPaymentTextInvalid; + } return null; }, ), diff --git a/lib/setup/di.dart b/lib/setup/di.dart index fa7b20f12..e300bdfb0 100644 --- a/lib/setup/di.dart +++ b/lib/setup/di.dart @@ -1,3 +1,5 @@ +import 'dart:async'; +import 'dart:developer' as developer; import 'dart:io'; import 'package:flutter/material.dart'; @@ -19,6 +21,7 @@ import 'package:realunit_wallet/packages/service/debug_auth_service.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_bank_account_service.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_blockchain_api_service.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_brokerbot_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_config_service.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_faucet_service.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_fiat_service.dart'; @@ -89,6 +92,19 @@ Future finishSetup(String encryptionKey) async { await setupBlocs(); await setupDefaultAssets(); + + // Warm the API-provided validation patterns so name/address form fields can + // validate synchronously. Fire-and-forget: a failure here just means the + // first KYC form defers to the server's authoritative validation. + unawaited( + getIt().load().catchError( + (Object error) => developer.log( + 'failed to preload validation config: $error', + name: 'di', + error: error, + ), + ), + ); } void setupRepositories() { @@ -138,6 +154,7 @@ void setupServices() { ), ); + getIt.registerLazySingleton(() => DfxConfigService(getIt())); getIt.registerCachedFactory(() => DfxCountryService(getIt())); getIt.registerLazySingleton(() => DfxFiatService(getIt())); getIt.registerLazySingleton(() => DfxLanguageService(getIt())); @@ -199,6 +216,7 @@ Future setupBlocs() async { // so drop their session caches when the user switches networks. getIt().invalidateCache(); getIt().invalidateCache(); + getIt().invalidateCache(); }, ), ); diff --git a/test/packages/service/dfx/dfx_config_service_test.dart b/test/packages/service/dfx/dfx_config_service_test.dart new file mode 100644 index 000000000..751c9d542 --- /dev/null +++ b/test/packages/service/dfx/dfx_config_service_test.dart @@ -0,0 +1,161 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/config/network_mode.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_config_service.dart'; + +class _MockAppStore extends Mock implements AppStore {} + +/// The authoritative `swissPaymentText` pattern as served by the API +/// (`GET /v1/config`). Kept as a raw string so the backslash escapes reach +/// Dart's `RegExp` verbatim — exactly what the app receives over the wire. +const _swissPaymentTextPattern = r'^[\x20-\x7EÀÁÂÄÇÈÉÊËÌÍÎÏÑÒÓÔÖÙÚÛÜÝàáâäçèéêëìíîïñòóôöùúûüýß\n]*$'; + +String _configBody({String pattern = _swissPaymentTextPattern, String flags = 'u'}) { + return jsonEncode({ + 'formats': { + 'swissPaymentText': {'pattern': pattern, 'flags': flags}, + }, + }); +} + +void main() { + late _MockAppStore appStore; + + setUp(() { + appStore = _MockAppStore(); + when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); + }); + + DfxConfigService build(http.Client client) { + when(() => appStore.httpClient).thenReturn(client); + return DfxConfigService(appStore); + } + + group('$DfxConfigService', () { + test('load fetches /v1/config and caches the result', () async { + var callCount = 0; + final client = MockClient((request) async { + callCount++; + expect(request.url.path, '/v1/config'); + return http.Response(_configBody(), 200); + }); + final service = build(client); + + await service.load(); + await service.load(); + + expect(callCount, 1, reason: 'second load should hit the in-memory cache'); + }); + + test('invalidateCache forces a refetch and clears the compiled pattern', () async { + var callCount = 0; + final client = MockClient((_) async { + callCount++; + return http.Response(_configBody(), 200); + }); + final service = build(client); + + await service.load(); + expect(service.isSwissPaymentText('王小明'), isFalse); + + service.invalidateCache(); + // pattern dropped -> defers to server again until reloaded + expect(service.isSwissPaymentText('王小明'), isTrue); + + await service.load(); + expect(callCount, 2, reason: 'cache was invalidated, so load refetched'); + }); + + test('throws when the backend responds non-200', () async { + final client = MockClient((_) async => http.Response('nope', 500)); + final service = build(client); + + expect(service.load(), throwsException); + }); + + group('isSwissPaymentText before the pattern is loaded', () { + test('returns true so the app never blocks input the server may accept', () { + final service = build(MockClient((_) async => http.Response(_configBody(), 200))); + + // Even input the loaded pattern would reject passes pre-load — the + // server re-validates authoritatively. + expect(service.isSwissPaymentText('王小明'), isTrue); + expect(service.isSwissPaymentText('Müller'), isTrue); + }); + }); + + group('isSwissPaymentText after loading the API pattern', () { + late DfxConfigService service; + + setUp(() async { + service = build(MockClient((_) async => http.Response(_configBody(), 200))); + await service.load(); + }); + + test('empty / null are valid (callers chain a non-empty check)', () { + expect(service.isSwissPaymentText(null), isTrue); + expect(service.isSwissPaymentText(''), isTrue); + }); + + test('plain ASCII, digits and punctuation pass', () { + expect(service.isSwissPaymentText('Bahnhofstrasse 1'), isTrue); + expect(service.isSwissPaymentText("It's @ #1 / 50%."), isTrue); + }); + + test('foreign alphanumeric postal codes pass (the bug this fixes)', () { + expect(service.isSwissPaymentText('1011 AB'), isTrue); // NL + expect(service.isSwissPaymentText('EC1A 1BB'), isTrue); // UK + expect(service.isSwissPaymentText('K1A 0B1'), isTrue); // CA + expect(service.isSwissPaymentText('D02 AF30'), isTrue); // IE + }); + + test('Swiss national-language diacritics pass', () { + expect(service.isSwissPaymentText('Zürich'), isTrue); + expect(service.isSwissPaymentText('Genève'), isTrue); + expect(service.isSwissPaymentText('François'), isTrue); + expect(service.isSwissPaymentText('ÄÖÜß'), isTrue); + }); + + test('newline allowed, tab and carriage return rejected', () { + expect(service.isSwissPaymentText('Line 1\nLine 2'), isTrue); + expect(service.isSwissPaymentText('a\tb'), isFalse); + expect(service.isSwissPaymentText('a\rb'), isFalse); + }); + + test('non-Latin scripts and emoji are rejected', () { + expect(service.isSwissPaymentText('王小明'), isFalse); + expect(service.isSwissPaymentText('Иван'), isFalse); + expect(service.isSwissPaymentText('Hello 👋'), isFalse); + }); + + test('diacritics outside the Swiss set are rejected', () { + expect(service.isSwissPaymentText('Łódź'), isFalse); + expect(service.isSwissPaymentText('São Paulo'), isFalse); + expect(service.isSwissPaymentText('Tromsø'), isFalse); + expect(service.isSwissPaymentText('Müllеr'), isFalse, reason: 'Cyrillic е (U+0435)'); + }); + }); + + test('applies whatever pattern the API serves, not a hardcoded copy', () async { + // A deliberately different server pattern: digits only. The service must + // honour it verbatim — proving the rule lives in the API, not the client. + final service = build( + MockClient((_) async => http.Response(_configBody(pattern: r'^[0-9]*$', flags: ''), 200)), + ); + await service.load(); + + expect(service.isSwissPaymentText('12345'), isTrue); + expect( + service.isSwissPaymentText('Zürich'), + isFalse, + reason: 'letters rejected by this server pattern', + ); + }); + }); +} diff --git a/test/packages/utils/swiss_payment_text_test.dart b/test/packages/utils/swiss_payment_text_test.dart deleted file mode 100644 index 364393455..000000000 --- a/test/packages/utils/swiss_payment_text_test.dart +++ /dev/null @@ -1,164 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:realunit_wallet/packages/utils/swiss_payment_text.dart'; - -void main() { - group('isSwissPaymentText', () { - group('empty / null', () { - test('null is valid (callers chain a non-empty check)', () { - expect(isSwissPaymentText(null), isTrue); - }); - - test('empty string is valid', () { - expect(isSwissPaymentText(''), isTrue); - }); - }); - - group('printable ASCII', () { - test('plain Latin words pass', () { - expect(isSwissPaymentText('Hello World'), isTrue); - }); - - test('digits pass', () { - expect(isSwissPaymentText('12345 67890'), isTrue); - }); - - test('common punctuation passes', () { - expect(isSwissPaymentText("It's @ #1 / 50%."), isTrue); - expect(isSwissPaymentText('Strasse 1, 8001'), isTrue); - expect(isSwissPaymentText('a-b_c.d'), isTrue); - }); - - test('all printable ASCII range pass', () { - // 0x20 (space) through 0x7E (~) — the full printable ASCII band. - final all = String.fromCharCodes(List.generate(0x7F - 0x20, (i) => 0x20 + i)); - expect(isSwissPaymentText(all), isTrue); - }); - }); - - group('Latin diacritics — uppercase', () { - test('German umlauts and ß', () { - expect(isSwissPaymentText('ÄÖÜß'), isTrue); - }); - - test('French accents', () { - expect(isSwissPaymentText('ÀÂÇÈÉÊËÎÏÔÙÛÜŸ'), isFalse, - reason: 'Ÿ is NOT in the Swiss payment set'); - expect(isSwissPaymentText('ÀÂÇÈÉÊËÎÏÔÙÛÜ'), isTrue); - }); - - test('Italian accents', () { - expect(isSwissPaymentText('ÀÉÈÌÒÓÙ'), isTrue); - }); - }); - - group('Latin diacritics — lowercase', () { - test('German lowercase umlauts', () { - expect(isSwissPaymentText('äöü'), isTrue); - }); - - test('French + Italian lowercase accents', () { - expect(isSwissPaymentText('àâçèéêëîïôùûü'), isTrue); - expect(isSwissPaymentText('íóñý'), isTrue); - }); - }); - - group('real Swiss names + addresses', () { - test('Rüttimann', () { - expect(isSwissPaymentText('Rüttimann'), isTrue); - }); - - test('Münchwilen', () { - expect(isSwissPaymentText('Münchwilen'), isTrue); - }); - - test('Genève', () { - expect(isSwissPaymentText('Genève'), isTrue); - }); - - test('Saint-Légier', () { - expect(isSwissPaymentText('Saint-Légier'), isTrue); - }); - - test('François', () { - expect(isSwissPaymentText('François'), isTrue); - }); - - test("D'Hauterive", () { - expect(isSwissPaymentText("D'Hauterive"), isTrue); - }); - }); - - group('non-Latin scripts → invalid', () { - test('Chinese rejected', () { - expect(isSwissPaymentText('王小明'), isFalse); - }); - - test('Cyrillic rejected', () { - expect(isSwissPaymentText('Иван'), isFalse); - }); - - test('Japanese rejected', () { - expect(isSwissPaymentText('日本語'), isFalse); - }); - - test('Arabic rejected', () { - expect(isSwissPaymentText('محمد'), isFalse); - }); - - test('Hebrew rejected', () { - expect(isSwissPaymentText('שלום'), isFalse); - }); - - test('emoji rejected', () { - expect(isSwissPaymentText('Hello 👋'), isFalse); - }); - }); - - group('mixed valid + invalid', () { - test('Latin name with single Cyrillic char fails', () { - expect(isSwissPaymentText('Müllеr'), isFalse, - reason: 'second-to-last is Cyrillic е (U+0435), not Latin e'); - }); - - test('valid prefix + invalid suffix fails', () { - expect(isSwissPaymentText('Hello 王'), isFalse); - }); - }); - - group('whitespace / control', () { - test('newline allowed (multi-line memos)', () { - expect(isSwissPaymentText('Line 1\nLine 2'), isTrue); - }); - - test('tab rejected', () { - expect(isSwissPaymentText('a\tb'), isFalse); - }); - - test('carriage return rejected', () { - expect(isSwissPaymentText('a\rb'), isFalse); - }); - - test('whitespace-only is valid (spaces are printable ASCII 0x20)', () { - expect(isSwissPaymentText(' '), isTrue); - }); - }); - - group('uncommon Latin diacritics — not in Swiss payment set', () { - test('Polish ąć rejected', () { - expect(isSwissPaymentText('Łódź'), isFalse); - }); - - test('Portuguese ã rejected', () { - expect(isSwissPaymentText('São Paulo'), isFalse); - }); - - test('Norwegian øå rejected', () { - expect(isSwissPaymentText('Tromsø'), isFalse); - }); - - test('French ligature œ rejected (not in Swiss payment set)', () { - expect(isSwissPaymentText('cœur'), isFalse); - }); - }); - }); -}