diff --git a/lib/screens/kyc/steps/email/cubits/email_verification/kyc_email_verification_cubit.dart b/lib/screens/kyc/steps/email/cubits/email_verification/kyc_email_verification_cubit.dart index 89dae281..a198eaf6 100644 --- a/lib/screens/kyc/steps/email/cubits/email_verification/kyc_email_verification_cubit.dart +++ b/lib/screens/kyc/steps/email/cubits/email_verification/kyc_email_verification_cubit.dart @@ -3,6 +3,7 @@ import 'dart:developer' as developer; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_auth_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/wallet/real_unit_registration_state.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; import 'package:realunit_wallet/packages/utils/jwt_decoder.dart'; @@ -70,23 +71,32 @@ class KycEmailVerificationCubit extends Cubit { // open and the user can retry without the failure being papered over. } - /// Returns `true` when the wallet was successfully registered with the - /// (now-merged) user account. On failure the cubit is already in - /// [KycEmailVerificationRegistrationFailure] so the listener can show the + /// Returns `true` when the merge can proceed — either the wallet was + /// registered (`addWallet` / `alreadyRegistered`), or the account has no + /// prior RealUnit registration (`newRegistration`) and is handed back to the + /// KYC flow for the full registration form. On failure the cubit is already + /// in [KycEmailVerificationRegistrationFailure] so the listener can show the /// error to the user. Future _completeRegistration(int generation) async { try { final info = await _registrationService.getRegistrationInfo(); if (isClosed || generation != _runGeneration) return false; + + // `registerWallet` (POST /register/wallet) only ADDS a wallet to an + // already-existing RealUnit registration, reusing its prior signed data. + // When the merged account has no prior registration the API returns + // `newRegistration`, and calling register/wallet would fail with + // "No RealUnit registration found" (400). The merge is already confirmed + // here (the JWT account changed), so hand back to the KYC flow: `KycCubit` + // routes `newRegistration` to the full registration form (register/complete). + if (info.state == RealUnitRegistrationState.newRegistration) { + return true; + } + + // For addWallet / alreadyRegistered the API always provides userData; a + // null here is a genuine anomaly the user should retry / report. if (info.realUnitUserDataDto == null) { - // Backend race: the auth service reports the merged account while the - // user-data service hasn't propagated yet. Surface as a recoverable - // failure so the user can retry by tapping the confirmation button - // again — by then propagation will usually have completed, and the - // retry path skips the auth-side check thanks to `_mergeDetected`. - developer.log( - 'getRegistrationInfo returned null realUnitUserDataDto after merge', - ); + developer.log('Unexpected null userData for registration state ${info.state}'); emit(const KycEmailVerificationRegistrationFailure()); return false; } diff --git a/lib/screens/kyc/steps/email/kyc_email_page.dart b/lib/screens/kyc/steps/email/kyc_email_page.dart index 9710e06c..146693c0 100644 --- a/lib/screens/kyc/steps/email/kyc_email_page.dart +++ b/lib/screens/kyc/steps/email/kyc_email_page.dart @@ -80,12 +80,11 @@ class _KycEmailFormState extends State { ), ); if (isConfirmed == true && context.mounted) { - // A successful merge confirmation already registered this - // wallet via `KycEmailVerificationCubit._completeRegistration` - // → `RealUnitRegistrationService.registerWallet`. The next - // `checkKyc()` re-fetches `getRegistrationInfo`, sees - // `AlreadyRegistered`, and routes forward — no local sign-gate - // flag needed. + // The merge is confirmed. `checkKyc()` re-fetches + // `getRegistrationInfo` and routes by state: AddWallet / + // AlreadyRegistered were already registered inline by + // `KycEmailVerificationCubit`; NewRegistration (no prior RealUnit + // registration) is routed to the full registration form here. context.read().checkKyc(); } } diff --git a/test/screens/kyc/steps/email/kyc_email_verification_cubit_test.dart b/test/screens/kyc/steps/email/kyc_email_verification_cubit_test.dart index 588a801a..b7799c0b 100644 --- a/test/screens/kyc/steps/email/kyc_email_verification_cubit_test.dart +++ b/test/screens/kyc/steps/email/kyc_email_verification_cubit_test.dart @@ -4,9 +4,9 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_auth_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_status.dart'; import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; -import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; import 'package:realunit_wallet/packages/service/dfx/models/wallet/real_unit_registration_info_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/wallet/real_unit_registration_state.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; @@ -124,8 +124,9 @@ void main() { ); blocTest( - 'changed account id but no userData → RegistrationFailure, no Success ' - '(propagation race: user can retry by tapping the confirm button again)', + 'changed account id + NewRegistration → Success (hands off to the KYC flow), ' + 'no registerWallet — the merged account has no prior RealUnit registration ' + 'so register/wallet would 400 "No RealUnit registration found"', setUp: () { final tokens = [_fakeJwt(1), _fakeJwt(2)]; var i = 0; @@ -141,7 +142,33 @@ void main() { act: (c) => c.checkEmailVerification(), expect: () => [ isA(), - isA(), + isA(), + ], + verify: (_) { + verifyNever(() => registrationService.registerWallet(any())); + }, + ); + + blocTest( + 'NewRegistration WITH userData present still hands off (Success), no ' + 'registerWallet — reproduces the real bug: existing KYC data makes ' + 'userData non-null while the account still has no RealUnit registration', + setUp: () { + final tokens = [_fakeJwt(1), _fakeJwt(2)]; + var i = 0; + when(() => auth.getAuthToken()).thenAnswer((_) async => tokens[i++]); + when(() => registrationService.getRegistrationInfo()).thenAnswer( + (_) async => RealUnitRegistrationInfoDto( + state: RealUnitRegistrationState.newRegistration, + realUnitUserDataDto: _userData, + ), + ); + }, + build: build, + act: (c) => c.checkEmailVerification(), + expect: () => [ + isA(), + isA(), ], verify: (_) { verifyNever(() => registrationService.registerWallet(any())); @@ -175,32 +202,29 @@ void main() { ); blocTest( - 'retry after null-userData race: second call skips account-id check ' - '(propagation completed → registerWallet succeeds → Success)', + 'retry after a transient registerWallet failure (AddWallet): the second ' + 'call skips the account-id check via _mergeDetected → registerWallet ' + 'succeeds → Success', setUp: () { - // First call: account-id changes, userData not yet propagated. - // Second call: same account-id (already merged), userData now present. - // Without the `_mergeDetected` short-circuit the second call would - // hit the same-account-id guard and emit Failure ("email not yet - // confirmed") — verifying the retry path works. - final tokens = [_fakeJwt(1), _fakeJwt(2), _fakeJwt(2), _fakeJwt(2)]; + // First call detects the merge (account 1 → 2). The second call must + // skip the same-account-id guard (which would otherwise emit Failure, + // "email not yet confirmed") thanks to `_mergeDetected`, and retry the + // AddWallet registration. + final tokens = [_fakeJwt(1), _fakeJwt(2)]; var i = 0; when(() => auth.getAuthToken()).thenAnswer((_) async => tokens[i++]); - var walletStatusCallCount = 0; - when(() => registrationService.getRegistrationInfo()).thenAnswer((_) async { - walletStatusCallCount++; - return walletStatusCallCount == 1 - ? RealUnitRegistrationInfoDto( - state: RealUnitRegistrationState.newRegistration, - realUnitUserDataDto: null, - ) - : RealUnitRegistrationInfoDto( - state: RealUnitRegistrationState.addWallet, - realUnitUserDataDto: _userData, - ); + when(() => registrationService.getRegistrationInfo()).thenAnswer( + (_) async => RealUnitRegistrationInfoDto( + state: RealUnitRegistrationState.addWallet, + realUnitUserDataDto: _userData, + ), + ); + var calls = 0; + when(() => registrationService.registerWallet(any())).thenAnswer((_) async { + calls++; + if (calls == 1) throw Exception('transient'); + return RegistrationStatus.completed; }); - when(() => registrationService.registerWallet(any())) - .thenAnswer((_) async => RegistrationStatus.completed); }, build: build, act: (c) async { @@ -214,7 +238,7 @@ void main() { isA(), ], verify: (_) { - verify(() => registrationService.registerWallet(_userData)).called(1); + verify(() => registrationService.registerWallet(_userData)).called(2); }, ); });