From cd0de48940253c635f600dbade65333999735751 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:53:34 +0200 Subject: [PATCH 1/2] test(kyc): cover registration form-widget interaction + confirm-email concurrency + kyc routing branches Test-only, no lib change. Closes coverage gaps found by a post-#808 audit: the registration personal-step form-widget interaction layer, the confirm-email recheck concurrency guards, the KycPageManager DI wiring / unsupported-step / legal-disclaimer arms, and per-arm _mapStepName routing (contactData/nationalityData/financialData). --- .../kyc/cubits/kyc/kyc_cubit_test.dart | 43 ++++ test/screens/kyc/kyc_page_manager_test.dart | 85 ++++++++ .../kyc_confirm_email_cubit_test.dart | 96 +++++++++ .../kyc_registration_personal_step_test.dart | 184 ++++++++++++++++++ 4 files changed, 408 insertions(+) create mode 100644 test/screens/kyc/steps/registration/steps/kyc_registration_personal_step_test.dart diff --git a/test/screens/kyc/cubits/kyc/kyc_cubit_test.dart b/test/screens/kyc/cubits/kyc/kyc_cubit_test.dart index ff77d630b..40459d07f 100644 --- a/test/screens/kyc/cubits/kyc/kyc_cubit_test.dart +++ b/test/screens/kyc/cubits/kyc/kyc_cubit_test.dart @@ -902,6 +902,49 @@ void main() { ); }); + // Per-arm coverage of `_mapStepName`: line coverage marks every switch- + // expression arm as hit the moment the switch is evaluated, so it cannot + // prove an individual arm maps its input to the right step. Drive + // `_continueKyc` with each step name and assert the resulting step — the + // assertion, not the line hit, pins the arm. (`ident` and `dfxApproval` are + // already exercised by the InProgress / PendingReview tests above.) + group('$KycCubit _mapStepName routing arms', () { + void expectStepMapsTo(KycStepName name, KycStep expected) { + blocTest( + '$name maps to $expected', + setUp: () { + when(() => kycService.getKycStatus()).thenAnswer( + (_) async => _kycStatus( + level: KycLevel.level20, + processStatus: KycProcessStatus.inProgress, + ), + ); + when(() => kycService.getUser()).thenAnswer((_) async => _user()); + when(() => kycService.continueKyc()).thenAnswer( + (_) async => _session( + level: KycLevel.level20, + steps: const [], + currentStep: _currentStep(name, url: 'https://example.com/step'), + ), + ); + }, + build: buildCubit, + act: (cubit) async { + cubit.markLegalDisclaimerAccepted(); + await cubit.checkKyc(); + }, + expect: () => [ + const KycLoading(), + KycSuccess(currentStep: expected, urlOrToken: 'https://example.com/step'), + ], + ); + } + + expectStepMapsTo(KycStepName.contactData, KycStep.registration); + expectStepMapsTo(KycStepName.nationalityData, KycStep.nationality); + expectStepMapsTo(KycStepName.financialData, KycStep.financialData); + }); + group('$KycCubit timeout & generation handling', () { test( 'a late response from a timed-out call does NOT overwrite the fresh state of a retry (regression for #315 / #317)', diff --git a/test/screens/kyc/kyc_page_manager_test.dart b/test/screens/kyc/kyc_page_manager_test.dart index cf2f56b2a..2257d99ba 100644 --- a/test/screens/kyc/kyc_page_manager_test.dart +++ b/test/screens/kyc/kyc_page_manager_test.dart @@ -1,5 +1,8 @@ +import 'package:flutter/widgets.dart'; +import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_bloc/flutter_bloc.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/app_store.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; @@ -14,7 +17,9 @@ import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_serv import 'package:realunit_wallet/packages/wallet/wallet.dart'; import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; import 'package:realunit_wallet/screens/kyc/kyc_page_manager.dart'; +import 'package:realunit_wallet/screens/kyc/subpages/kyc_failure_page.dart'; import 'package:realunit_wallet/screens/kyc/subpages/kyc_pending_page.dart'; +import 'package:realunit_wallet/screens/legal/legal_disclaimer_page.dart'; import '../../helper/helper.dart'; @@ -27,6 +32,8 @@ class _MockAppStore extends Mock implements AppStore {} class _MockAWallet extends Mock implements AWallet {} +class _MockKycCubit extends MockCubit implements KycCubit {} + UserKycDto _kycHeader({KycLevel level = KycLevel.level0}) => UserKycDto(hash: 'h', level: level, dataComplete: false); @@ -127,4 +134,82 @@ void main() { await cubit.close(); }, ); + + Widget viewWithState(KycCubit cubit) => BlocProvider.value( + value: cubit, + child: const KycViewManager(), + ); + + // Exercises the KycPageManager DI wrapper itself (not just KycViewManager): + // the create closure must build a KycCubit from getIt and kick off checkKyc + // with the passed context. + testWidgets( + 'KycPageManager builds the KycCubit from getIt and runs checkKyc with the context', + (tester) async { + final getIt = GetIt.instance; + getIt.registerSingleton(kycService); + getIt.registerSingleton(registrationService); + getIt.registerSingleton(appStore); + addTearDown(() async => getIt.reset()); + + // Fail fast so the created cubit settles into KycFailure without leaving a + // pending 30s timeout timer — the assertion is on the DI wiring, not the + // resulting state. + when(() => kycService.getKycStatus(context: 'RealunitBuy')).thenThrow( + Exception('boom'), + ); + when(() => kycService.getUser()).thenAnswer((_) async => _user()); + + // Non-const construction so the constructor runs at runtime instead of + // folding to a compile-time constant. + // ignore: prefer_const_constructors + await tester.pumpApp(KycPageManager(kycContext: 'RealunitBuy')); + await tester.pumpAndSettle(); + + // Proves both halves of the wiring: the cubit was built from getIt (else + // this mock is never reached) and checkKyc forwarded the context. + verify(() => kycService.getKycStatus(context: 'RealunitBuy')).called(1); + expect(find.byType(KycFailurePage), findsOneWidget); + }, + ); + + // The KycUnsupportedStepFailure arm renders a KycFailurePage with the + // unsupported step name — a state no page test drives directly. + testWidgets( + 'KycViewManager renders KycFailurePage for KycUnsupportedStepFailure', + (tester) async { + final cubit = _MockKycCubit(); + when(() => cubit.state).thenReturn( + const KycUnsupportedStepFailure(KycStepName.personalData), + ); + + await tester.pumpApp(viewWithState(cubit)); + + expect(find.byType(KycFailurePage), findsOneWidget); + }, + ); + + // The legalDisclaimer arm wires LegalDisclaimerPage.onCompleted to + // markLegalDisclaimerAccepted + checkKyc; drive that callback directly. + testWidgets( + 'KycViewManager legalDisclaimer onCompleted marks acceptance and re-checks', + (tester) async { + final cubit = _MockKycCubit(); + when(() => cubit.state).thenReturn( + const KycSuccess(currentStep: KycStep.legalDisclaimer), + ); + when(() => cubit.checkKyc()).thenAnswer((_) async {}); + + await tester.pumpApp(viewWithState(cubit)); + await tester.pumpAndSettle(); + + // Drive the disclaimer's completion callback exactly as the page would on + // acceptance, without exercising the disclaimer UI itself. + final page = tester.widget(find.byType(LegalDisclaimerPage)); + page.onCompleted!(); + + verify(() => cubit.markLegalDisclaimerAccepted()).called(1); + verify(() => cubit.checkKyc()).called(1); + }, + ); } diff --git a/test/screens/kyc/steps/confirm_email/kyc_confirm_email_cubit_test.dart b/test/screens/kyc/steps/confirm_email/kyc_confirm_email_cubit_test.dart index 14b096495..bd791f9f7 100644 --- a/test/screens/kyc/steps/confirm_email/kyc_confirm_email_cubit_test.dart +++ b/test/screens/kyc/steps/confirm_email/kyc_confirm_email_cubit_test.dart @@ -147,5 +147,101 @@ void main() { }); }, ); + + // Concurrency guards: `Future.timeout` does not cancel the underlying HTTP + // call, so a late continuation from a superseded tap must not emit over the + // newer run, and a tap after the cubit closed must not emit at all. These + // pin the guard branches (the early `return`s) that the happy-path tests + // above execute but never actually take. + test( + 'recheck() after close returns early without touching the service (isClosed guard)', + () async { + when(() => registrationService.getRegistrationInfo()).thenAnswer( + (_) async => _info(emailConfirmed: true), + ); + + final cubit = build(); + await cubit.close(); + + // Must not throw "Cannot emit after close": the isClosed guard returns + // before the loading emit, and the service is never reached. + await cubit.recheck(); + + expect(cubit.isClosed, isTrue); + verifyNever(() => registrationService.getRegistrationInfo()); + }, + ); + + test( + 'a superseded recheck bails on the stale generation instead of emitting ' + 'its result (success path)', + () async { + final first = Completer(); + final second = Completer(); + final calls = [first, second]; + var i = 0; + when(() => registrationService.getRegistrationInfo()).thenAnswer( + (_) => calls[i++].future, + ); + + final cubit = build(); + final states = []; + final sub = cubit.stream.listen(states.add); + + // Two overlapping (non-awaited) rechecks: the second supersedes the + // first's generation. + unawaited(cubit.recheck()); + unawaited(cubit.recheck()); + + // Resolve the superseded FIRST call: it would report NotConfirmed, but + // its continuation must bail on the stale generation. + first.complete(_info(emailConfirmed: false)); + await Future.delayed(Duration.zero); + // The winning SECOND call reports Confirmed. + second.complete(_info(emailConfirmed: true)); + await Future.delayed(Duration.zero); + + // Only the winner's result survives; the stale NotConfirmed is dropped + // (otherwise the list would be [Loading, NotConfirmed, Confirmed]). + expect(states, const [KycConfirmEmailLoading(), KycConfirmEmailConfirmed()]); + + await sub.cancel(); + await cubit.close(); + }, + ); + + test( + 'a superseded recheck bails on the stale generation instead of emitting ' + 'over the newer run (catch path)', + () async { + final first = Completer(); + final second = Completer(); + final calls = [first, second]; + var i = 0; + when(() => registrationService.getRegistrationInfo()).thenAnswer( + (_) => calls[i++].future, + ); + + final cubit = build(); + final states = []; + final sub = cubit.stream.listen(states.add); + + unawaited(cubit.recheck()); + unawaited(cubit.recheck()); + + // The superseded FIRST call FAILS: the catch block must also bail on the + // stale generation rather than emit its fail-closed NotConfirmed over + // the newer run. + first.completeError(Exception('late failure')); + await Future.delayed(Duration.zero); + second.complete(_info(emailConfirmed: true)); + await Future.delayed(Duration.zero); + + expect(states, const [KycConfirmEmailLoading(), KycConfirmEmailConfirmed()]); + + await sub.cancel(); + await cubit.close(); + }, + ); }); } diff --git a/test/screens/kyc/steps/registration/steps/kyc_registration_personal_step_test.dart b/test/screens/kyc/steps/registration/steps/kyc_registration_personal_step_test.dart new file mode 100644 index 000000000..dc0340aa4 --- /dev/null +++ b/test/screens/kyc/steps/registration/steps/kyc_registration_personal_step_test.dart @@ -0,0 +1,184 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +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/packages/service/dfx/models/registration/registration_user_type.dart'; +import 'package:realunit_wallet/screens/kyc/steps/registration/cubits/registration_step/kyc_registration_step_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/steps/registration/steps/kyc_registration_personal_step.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; +import 'package:realunit_wallet/widgets/form/dropdown_field.dart'; + +import '../../../../../helper/country_fixture.dart'; +import '../../../../../helper/pump_app.dart'; + +class _MockKycRegistrationStepCubit extends MockCubit + implements KycRegistrationStepCubit {} + +// Matches the 'CH' entry in the committed country fixture so CountryField +// pre-selects it (and propagates it into the nationality controller). +const _switzerland = Country(id: 41, symbol: 'CH', name: 'Switzerland', kycAllowed: true); + +void main() { + late _MockKycRegistrationStepCubit stepCubit; + + setUp(() { + GetIt.instance.registerSingleton(fixtureCountryService()); + + stepCubit = _MockKycRegistrationStepCubit(); + when(() => stepCubit.state).thenReturn( + const KycRegistrationStepState( + step: KycRegistrationStep.personal, + steps: [ + KycRegistrationStep.personal, + KycRegistrationStep.address, + KycRegistrationStep.taxResidence, + ], + ), + ); + }); + + tearDown(() async => GetIt.instance.reset()); + + // Prefill every field with a valid value so the only variable under test is + // the first/last-name input. Birthday and phone seed their sub-widgets from + // the passed controllers; the nationality picker pre-selects from the country + // fixture — leaving the name validators as the sole outstanding gate. + Future<_Harness> pump( + WidgetTester tester, { + String firstName = 'Ada', + String lastName = 'Lovelace', + bool withNationality = true, + }) async { + final harness = _Harness(firstName: firstName, lastName: lastName); + + await tester.pumpApp( + BlocProvider.value( + value: stepCubit, + child: Scaffold( + body: KycRegistrationPersonalStep( + typeCtrl: harness.typeCtrl, + firstNameCtrl: harness.firstNameCtrl, + lastNameCtrl: harness.lastNameCtrl, + phoneCtrl: harness.phoneCtrl, + nationalityCtrl: harness.nationalityCtrl, + birthdayCtrl: harness.birthdayCtrl, + initialNationality: withNationality ? _switzerland : null, + ), + ), + ), + ); + await tester.pumpAndSettle(); + return harness; + } + + Finder scrollable() => find.byType(Scrollable).first; + + Future tapNext(WidgetTester tester) async { + final button = find.byType(AppFilledButton); + await tester.scrollUntilVisible(button, 100, scrollable: scrollable()); + await tester.tap(button); + await tester.pumpAndSettle(); + } + + group('$KycRegistrationPersonalStep', () { + testWidgets('renders the personal fields and a next button', (tester) async { + await pump(tester); + + expect(find.byType(DropdownField), findsOneWidget); + expect(find.byType(AppFilledButton), findsOneWidget); + }); + + testWidgets('advances to the next step once every field is valid', (tester) async { + await pump(tester); + + await tapNext(tester); + + verify(() => stepCubit.next()).called(1); + }); + + testWidgets('does not advance while the names are empty', (tester) async { + await pump(tester, firstName: '', lastName: ''); + + // Empty first/last name → the validators return the empty sentinel and + // keep the form invalid, so the step must not advance. + await tapNext(tester); + + verifyNever(() => stepCubit.next()); + }); + + testWidgets('does not advance while the names are not Swiss-payment text', (tester) async { + // Cyrillic sample ("Test"): non-empty but outside the SwissPaymentText + // character set, so the validators reject it client-side. + await pump(tester, firstName: 'Тест', lastName: 'Тест'); + + await tapNext(tester); + + verifyNever(() => stepCubit.next()); + }); + + testWidgets('updates the account-type controller when the dropdown changes', (tester) async { + final harness = await pump(tester); + + final dropdown = tester.widget>( + find.byType(DropdownField), + ); + // A null selection is ignored (guard); a concrete value updates the + // controller. + dropdown.onChanged!(null); + dropdown.onChanged!(RegistrationUserType.human); + + expect(harness.typeCtrl.value, RegistrationUserType.human); + }); + + testWidgets('dismisses the keyboard when tapping outside the fields', (tester) async { + await pump(tester); + + // Focus the first text field so there is a primary focus to clear. + final firstField = find + .descendant( + of: find.byType(KycRegistrationPersonalStep), + matching: find.byType(EditableText), + ) + .first; + final firstFocus = tester.widget(firstField).focusNode; + await tester.tap(firstField); + await tester.pump(); + expect(firstFocus.hasFocus, isTrue); + + // The opaque GestureDetector wrapping the form clears focus on tap — it is + // the only opaque GestureDetector that is an ancestor of the Form (field + // and button gesture handlers are descendants of the Form). + final dismissArea = find.ancestor( + of: find.descendant( + of: find.byType(KycRegistrationPersonalStep), + matching: find.byType(Form), + ), + matching: find.byWidgetPredicate( + (w) => w is GestureDetector && w.behavior == HitTestBehavior.opaque && w.onTap != null, + ), + ); + expect(dismissArea, findsOneWidget); + tester.widget(dismissArea).onTap!(); + await tester.pump(); + + expect(firstFocus.hasFocus, isFalse); + }); + }); +} + +class _Harness { + _Harness({required String firstName, required String lastName}) + : firstNameCtrl = TextEditingController(text: firstName), + lastNameCtrl = TextEditingController(text: lastName); + + final typeCtrl = ValueNotifier(RegistrationUserType.human); + final TextEditingController firstNameCtrl; + final TextEditingController lastNameCtrl; + final phoneCtrl = ValueNotifier('+41791234567'); + final nationalityCtrl = ValueNotifier(null); + final birthdayCtrl = ValueNotifier('1990-05-15'); +} From dee0814409668e0a4cc368801f95fdbaebcfb375 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:59:02 +0200 Subject: [PATCH 2/2] test(kyc): assert observable account-type transition in dropdown onChanged test Drive an observable transition to the other enum value (corporation) instead of asserting the value the controller already holds, so the test fails if the set-branch assignment is removed. The null guard case still asserts the value stays unchanged. --- .../steps/kyc_registration_personal_step_test.dart | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/screens/kyc/steps/registration/steps/kyc_registration_personal_step_test.dart b/test/screens/kyc/steps/registration/steps/kyc_registration_personal_step_test.dart index dc0340aa4..f6f634b64 100644 --- a/test/screens/kyc/steps/registration/steps/kyc_registration_personal_step_test.dart +++ b/test/screens/kyc/steps/registration/steps/kyc_registration_personal_step_test.dart @@ -126,12 +126,14 @@ void main() { final dropdown = tester.widget>( find.byType(DropdownField), ); - // A null selection is ignored (guard); a concrete value updates the - // controller. + // A null selection is ignored (guard) and leaves the initial value; a + // concrete value replaces it, so assert the observable transition to the + // other enum value (fails if the set-branch assignment is removed). dropdown.onChanged!(null); - dropdown.onChanged!(RegistrationUserType.human); - expect(harness.typeCtrl.value, RegistrationUserType.human); + + dropdown.onChanged!(RegistrationUserType.corporation); + expect(harness.typeCtrl.value, RegistrationUserType.corporation); }); testWidgets('dismisses the keyboard when tapping outside the fields', (tester) async {