Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -70,23 +71,32 @@ class KycEmailVerificationCubit extends Cubit<KycEmailVerificationState> {
// 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<bool> _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;
}
Expand Down
11 changes: 5 additions & 6 deletions lib/screens/kyc/steps/email/kyc_email_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,11 @@ class _KycEmailFormState extends State<KycEmailForm> {
),
);
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<KycCubit>().checkKyc();
}
}
Expand Down
78 changes: 51 additions & 27 deletions test/screens/kyc/steps/email/kyc_email_verification_cubit_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -124,8 +124,9 @@ void main() {
);

blocTest<KycEmailVerificationCubit, KycEmailVerificationState>(
'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;
Expand All @@ -141,7 +142,33 @@ void main() {
act: (c) => c.checkEmailVerification(),
expect: () => [
isA<KycEmailVerificationLoading>(),
isA<KycEmailVerificationRegistrationFailure>(),
isA<KycEmailVerificationSuccess>(),
],
verify: (_) {
verifyNever(() => registrationService.registerWallet(any()));
},
);

blocTest<KycEmailVerificationCubit, KycEmailVerificationState>(
'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<KycEmailVerificationLoading>(),
isA<KycEmailVerificationSuccess>(),
],
verify: (_) {
verifyNever(() => registrationService.registerWallet(any()));
Expand Down Expand Up @@ -175,32 +202,29 @@ void main() {
);

blocTest<KycEmailVerificationCubit, KycEmailVerificationState>(
'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 {
Expand All @@ -214,7 +238,7 @@ void main() {
isA<KycEmailVerificationSuccess>(),
],
verify: (_) {
verify(() => registrationService.registerWallet(_userData)).called(1);
verify(() => registrationService.registerWallet(_userData)).called(2);
},
);
});
Expand Down
Loading