From 5c2458ecadfee3869cdf73af50337a53e96275df Mon Sep 17 00:00:00 2001 From: misha Date: Thu, 13 Aug 2026 12:28:34 +0300 Subject: [PATCH] feat(consultant_refistration): added explicit validation for scoring data --- .../models/consultant_profile_model.dart | 33 +- .../register_consultant_cubit.dart | 32 +- .../pages/passport_data_screen.dart | 8 + .../stepper_become_consultant_screen.dart | 586 +++++++++--------- .../utils/passport_data_validator.dart | 185 ++++++ .../models/consultant_profile_model_test.dart | 4 + .../bloc/register_consultant_cubit_test.dart | 43 +- .../pages/passport_data_screen_test.dart | 15 + .../utils/passport_data_validator_test.dart | 92 +++ 9 files changed, 656 insertions(+), 342 deletions(-) create mode 100644 lib/features/consultant/become_consultant/utils/passport_data_validator.dart create mode 100644 test/features/consultant/become_consultant/utils/passport_data_validator_test.dart diff --git a/lib/features/consultant/become_consultant/models/consultant_profile_model.dart b/lib/features/consultant/become_consultant/models/consultant_profile_model.dart index 5d454ff2..c80e46b2 100644 --- a/lib/features/consultant/become_consultant/models/consultant_profile_model.dart +++ b/lib/features/consultant/become_consultant/models/consultant_profile_model.dart @@ -1,3 +1,4 @@ +import 'package:eatvio_app/features/consultant/become_consultant/utils/passport_data_validator.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'consultant_profile_model.freezed.dart'; @@ -54,37 +55,7 @@ abstract class ConsultantProfileModel with _$ConsultantProfileModel { factory ConsultantProfileModel.fromJson(Map json) => _$ConsultantProfileModelFromJson(json); - bool get hasValidPassportData { - final last = lastName ?? ''; - final first = firstName ?? ''; - final innValue = inn ?? ''; - final snilsValue = snils ?? ''; - final series = passportSeries ?? ''; - final number = passportNumber ?? ''; - final issueDate = passportIssueDate ?? ''; - final issuer = passportIssuer ?? ''; - final issuerCode = passportIssuerCode ?? ''; - final birth = birthDate ?? ''; - final birthPlaceValue = birthPlace ?? ''; - final registration = registrationAddress ?? ''; - final residence = residenceAddress ?? ''; - final citizenshipValue = citizenship ?? ''; - - return last.trim().isNotEmpty && - first.trim().isNotEmpty && - RegExp(r'^\d{12}$').hasMatch(innValue) && - RegExp(r'^\d{3}-\d{3}-\d{3} \d{2}$').hasMatch(snilsValue) && - RegExp(r'^\d{4}$').hasMatch(series) && - RegExp(r'^\d{6}$').hasMatch(number) && - RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(issueDate) && - issuer.trim().isNotEmpty && - RegExp(r'^\d{3}-\d{3}$').hasMatch(issuerCode) && - RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(birth) && - birthPlaceValue.trim().isNotEmpty && - registration.trim().isNotEmpty && - residence.trim().isNotEmpty && - RegExp(r'^[A-Za-z]{2}$').hasMatch(citizenshipValue); - } + bool get hasValidPassportData => PassportDataValidator.validate(this).isEmpty; } @freezed diff --git a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart index 9f835e5c..c131bbcb 100644 --- a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart +++ b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart @@ -12,6 +12,7 @@ import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; import 'package:eatvio_app/features/consultant/become_consultant/utils/laravel_validation_errors.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/utils/passport_data_validator.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class RegisterConsultantCubit extends Cubit { @@ -240,6 +241,9 @@ class RegisterConsultantCubit extends Cubit { bool? acceptOffer, bool? acceptFrdoAgreement, ]) { + final nextFieldErrors = state.fieldErrors.isEmpty + ? const {} + : PassportDataValidator.validate(consultant); emit( state.copyWith( consultant: consultant, @@ -247,11 +251,17 @@ class RegisterConsultantCubit extends Cubit { acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement, loading: false, error: null, - fieldErrors: const {}, + fieldErrors: nextFieldErrors, ), ); } + Map validatePassportFields() { + final errors = PassportDataValidator.validate(state.consultant); + emit(state.copyWith(fieldErrors: errors, error: null)); + return errors; + } + void updateAgreements({bool? acceptOffer, bool? acceptFrdoAgreement}) { emit( state.copyWith( @@ -322,8 +332,8 @@ class RegisterConsultantCubit extends Cubit { } Future registerConsultant() async { - if (!state.consultant.hasValidPassportData) { - emit(state.copyWith(error: 'Заполните паспортные данные')); + final passportErrors = validatePassportFields(); + if (passportErrors.isNotEmpty) { return; } @@ -401,6 +411,10 @@ class RegisterConsultantCubit extends Cubit { if (!state.alfaScoringStatus.canRetry || state.scoringRetryInProgress) { return; } + final passportErrors = validatePassportFields(); + if (passportErrors.isNotEmpty) { + return; + } emit( state.copyWith( scoringRetryInProgress: true, @@ -413,18 +427,6 @@ class RegisterConsultantCubit extends Cubit { ); _update = true; try { - if (!state.consultant.hasValidPassportData) { - emit( - state.copyWith( - scoringRetryInProgress: false, - isScoringPolling: false, - awaitingScoringOutcome: false, - alfaScoringStatus: AlfaScoringStatus.scoringFailed, - error: 'Заполните паспортные данные', - ), - ); - return; - } await consultantRepository.registerConsultant( consultant: buildSubmitPayload(), update: true, diff --git a/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart index 466f4418..a14a9399 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart @@ -7,6 +7,7 @@ import 'package:eatvio_app/features/consultant/become_consultant/presentation/bl import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/passport_field_with_error.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/passport_section_title.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:mask_text_input_formatter/mask_text_input_formatter.dart'; @@ -332,6 +333,7 @@ class _PassportDataScreenState extends State { controller: innController, keyboardType: TextInputType.number, maxLength: 12, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], hasError: errors.containsKey('inn'), onChanged: (_) => _syncToCubit(), ), @@ -446,6 +448,9 @@ class _PassportDataScreenState extends State { controller: seriesController, keyboardType: TextInputType.number, maxLength: 4, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], hasError: errors.containsKey('passport_series'), onChanged: (_) => _syncToCubit(), ), @@ -460,6 +465,9 @@ class _PassportDataScreenState extends State { controller: numberController, keyboardType: TextInputType.number, maxLength: 6, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], hasError: errors.containsKey('passport_number'), onChanged: (_) => _syncToCubit(), ), diff --git a/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart index 12b016af..49e30959 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart @@ -146,212 +146,230 @@ class _StepperBecomeConsultantScreenState Widget build(BuildContext context) { return BlocProvider( create: (context) => registrationConsultantCubit, - child: BlocConsumer( + child: BlocListener( listenWhen: (previous, current) => - previous.awaitingScoringOutcome && - !current.awaitingScoringOutcome && - current.alfaScoringStatus.isComplete, + previous.fieldErrors.isEmpty && + current.fieldErrors.isNotEmpty && + current.error == null, listener: (context, state) { - _showSuccessSheet(context); + pageController.jumpToPage(5); }, - builder: (context, state) { - if (state.loading) { - return const Scaffold(body: Loader()); - } - if (state.error != null && - state.requestModel == null && - widget.slug != null && - state.alfaScoringStatus == AlfaScoringStatus.none) { - return Scaffold( - appBar: AppBar( - centerTitle: true, - leading: BackButton( - onPressed: () { - if (context.canPop()) { - GoRouter.of(context).pop(); - return; - } - GoRouter.of(context).go('/feed-screen'); - }, - ), - title: Text( - '', - maxLines: 2, - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - softWrap: true, - style: TextStyles.headingXLSemibold.copyWith( - color: appTheme.black, - ), - ), - ), - body: DataErrorWidget( - onReload: () => context - .read() - .reloadData(widget.slug), - ), - ); - } - - if (progressValue == 0) { - progressValue = 1 / totalSteps; - } - - final submitDisabled = - isLoading || - state.isScoringPolling || - state.scoringRetryInProgress; - - return Scaffold( - floatingActionButtonLocation: - FloatingActionButtonLocation.centerFloat, - floatingActionButton: Row( - children: [ - Container( - height: 48, - width: MediaQuery.of(context).size.width * 0.2, - padding: const EdgeInsets.only(left: 16, right: 4), - child: FloatingActionButton.extended( - heroTag: 'backButtonRegistration', - highlightElevation: 0, - focusElevation: 0, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - backgroundColor: const Color(0xFFEAF2FD), - onPressed: () async { - setState(() { - pageController.previousPage( - duration: const Duration(milliseconds: 350), - curve: Curves.easeInOut, - ); - }); + child: BlocConsumer( + listenWhen: (previous, current) => + previous.awaitingScoringOutcome && + !current.awaitingScoringOutcome && + current.alfaScoringStatus.isComplete, + listener: (context, state) { + _showSuccessSheet(context); + }, + builder: (context, state) { + if (state.loading) { + return const Scaffold(body: Loader()); + } + if (state.error != null && + state.requestModel == null && + widget.slug != null && + state.alfaScoringStatus == AlfaScoringStatus.none) { + return Scaffold( + appBar: AppBar( + centerTitle: true, + leading: BackButton( + onPressed: () { + if (context.canPop()) { + GoRouter.of(context).pop(); + return; + } + GoRouter.of(context).go('/feed-screen'); }, - label: Icon( - Icons.arrow_back_ios_new_rounded, - color: appTheme.blueA400, + ), + title: Text( + '', + maxLines: 2, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + softWrap: true, + style: TextStyles.headingXLSemibold.copyWith( + color: appTheme.black, ), ), ), - if (widget.isCanceled == true && step != totalSteps) - InkWell( - onTap: () { - final canceledModeration = - state.requestModel?.canceledModeration; - if (canceledModeration == null) return; - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(canceledModeration.title), - content: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (canceledModeration.additional != null) - for (final item - in canceledModeration.additional!) ...[ - Text('• $item'), - ], + body: DataErrorWidget( + onReload: () => context + .read() + .reloadData(widget.slug), + ), + ); + } + + if (progressValue == 0) { + progressValue = 1 / totalSteps; + } + + final submitDisabled = + isLoading || + state.isScoringPolling || + state.scoringRetryInProgress; + + return Scaffold( + floatingActionButtonLocation: + FloatingActionButtonLocation.centerFloat, + floatingActionButton: Row( + children: [ + Container( + height: 48, + width: MediaQuery.of(context).size.width * 0.2, + padding: const EdgeInsets.only(left: 16, right: 4), + child: FloatingActionButton.extended( + heroTag: 'backButtonRegistration', + highlightElevation: 0, + focusElevation: 0, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + backgroundColor: const Color(0xFFEAF2FD), + onPressed: () async { + setState(() { + pageController.previousPage( + duration: const Duration(milliseconds: 350), + curve: Curves.easeInOut, + ); + }); + }, + label: Icon( + Icons.arrow_back_ios_new_rounded, + color: appTheme.blueA400, + ), + ), + ), + if (widget.isCanceled == true && step != totalSteps) + InkWell( + onTap: () { + final canceledModeration = + state.requestModel?.canceledModeration; + if (canceledModeration == null) return; + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(canceledModeration.title), + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (canceledModeration.additional != null) + for (final item + in canceledModeration.additional!) ...[ + Text('• $item'), + ], + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('OK'), + ), ], ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('OK'), - ), - ], + ); + }, + borderRadius: BorderRadius.circular(20), + child: Ink( + height: 48, + width: MediaQuery.of(context).size.width * 0.6, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: const Color(0x33FFCC00), ), - ); - }, - borderRadius: BorderRadius.circular(20), - child: Ink( - height: 48, - width: MediaQuery.of(context).size.width * 0.6, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - color: const Color(0x33FFCC00), - ), - child: const Center( - child: Text( - 'Комментарий', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: Color(0xFF323F4B), - height: 1, + child: const Center( + child: Text( + 'Комментарий', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF323F4B), + height: 1, + ), ), ), ), ), - ), - Container( - height: 48, - width: widget.isCanceled == true && step != totalSteps - ? MediaQuery.of(context).size.width * 0.2 - : MediaQuery.of(context).size.width * 0.8, - padding: const EdgeInsets.only(left: 4, right: 16), - child: FloatingActionButton.extended( - heroTag: 'nextButtonRegistration', - highlightElevation: 0, - elevation: 0, - focusElevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - backgroundColor: submitDisabled - ? Colors.grey - : widget.isCanceled == true && step != totalSteps - ? const Color(0xFFEAF2FD) - : appTheme.blueA400, - onPressed: submitDisabled - ? null - : () async { - if (step == totalSteps) { - if (!state.consultant.hasValidPassportData) { - showDialog( - context: context, - builder: (context) => AlertDialog( - actionsPadding: const EdgeInsets.symmetric( - horizontal: 4, - ), - content: const Text( - 'Заполните паспортные данные', - ), - actions: [ - TextButton( - child: const Text('Ок'), - onPressed: () => - Navigator.of(context).pop(), - ), - ], - ), - ); - return; - } - if (registrationConsultantCubit - .hasAcceptedRequiredAgreements) { - setState(() { - isLoading = true; - }); - try { - await registrationConsultantCubit - .registerConsultant(); - if (!context.mounted) return; - if (registrationConsultantCubit.state.error != - null) { - showDialog( + Container( + height: 48, + width: widget.isCanceled == true && step != totalSteps + ? MediaQuery.of(context).size.width * 0.2 + : MediaQuery.of(context).size.width * 0.8, + padding: const EdgeInsets.only(left: 4, right: 16), + child: FloatingActionButton.extended( + heroTag: 'nextButtonRegistration', + highlightElevation: 0, + elevation: 0, + focusElevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + backgroundColor: submitDisabled + ? Colors.grey + : widget.isCanceled == true && step != totalSteps + ? const Color(0xFFEAF2FD) + : appTheme.blueA400, + onPressed: submitDisabled + ? null + : () async { + if (step == totalSteps) { + final passportErrors = + registrationConsultantCubit + .validatePassportFields(); + if (passportErrors.isNotEmpty) { + pageController.jumpToPage(5); + return; + } + if (registrationConsultantCubit + .hasAcceptedRequiredAgreements) { + setState(() { + isLoading = true; + }); + try { + await registrationConsultantCubit + .registerConsultant(); + if (!context.mounted) return; + if (registrationConsultantCubit + .state + .error != + null) { + showDialog( + context: context, + builder: (context) => AlertDialog( + actionsPadding: + const EdgeInsets.symmetric( + horizontal: 4, + ), + content: Text( + registrationConsultantCubit + .state + .error!, + ), + actions: [ + TextButton( + child: const Text('Ок'), + onPressed: () => + Navigator.of(context).pop(), + ), + ], + ), + ); + return; + } + // Success sheet is shown by BlocListener when scoring completes. + } catch (e) { + if (!context.mounted) return; + showDialog( context: context, builder: (context) => AlertDialog( actionsPadding: const EdgeInsets.symmetric( horizontal: 4, ), - content: Text( - registrationConsultantCubit - .state - .error!, - ), + content: Text(e.toString()), actions: [ TextButton( child: const Text('Ок'), @@ -361,140 +379,120 @@ class _StepperBecomeConsultantScreenState ], ), ); - return; + } finally { + setState(() { + isLoading = false; + }); } - // Success sheet is shown by BlocListener when scoring completes. - } catch (e) { - if (!context.mounted) return; - showDialog( + } else { + showDialog( context: context, builder: (context) => AlertDialog( actionsPadding: const EdgeInsets.symmetric( horizontal: 4, ), - content: Text(e.toString()), + content: Text( + state.isUpdating + ? 'Примите договор публичной оферты' + : 'Примите оферту и согласие ФРДО', + ), actions: [ TextButton( child: const Text('Ок'), - onPressed: () => - Navigator.of(context).pop(), + onPressed: () { + Navigator.of(context).pop(); + }, ), ], ), ); - } finally { - setState(() { - isLoading = false; - }); } } else { - showDialog( - context: context, - builder: (context) => AlertDialog( - actionsPadding: const EdgeInsets.symmetric( - horizontal: 4, - ), - content: Text( - state.isUpdating - ? 'Примите договор публичной оферты' - : 'Примите оферту и согласие ФРДО', - ), - actions: [ - TextButton( - child: const Text('Ок'), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - ], - ), - ); + FocusManager.instance.primaryFocus?.unfocus(); + setState(() { + pageController.nextPage( + duration: const Duration(milliseconds: 350), + curve: Curves.easeInOut, + ); + }); } - } else { - FocusManager.instance.primaryFocus?.unfocus(); - setState(() { - pageController.nextPage( - duration: const Duration(milliseconds: 350), - curve: Curves.easeInOut, - ); - }); - } - }, - label: submitDisabled && step == totalSteps - ? const CircularProgressIndicator() - : widget.isCanceled == true && step != totalSteps - ? Icon( - Icons.arrow_forward_ios_outlined, - color: appTheme.blueA400, - ) - : Text( - step == totalSteps ? 'Отправить' : 'Продолжить', - style: theme.textTheme.titleSmall!.copyWith( - color: Colors.white, + }, + label: submitDisabled && step == totalSteps + ? const CircularProgressIndicator() + : widget.isCanceled == true && step != totalSteps + ? Icon( + Icons.arrow_forward_ios_outlined, + color: appTheme.blueA400, + ) + : Text( + step == totalSteps ? 'Отправить' : 'Продолжить', + style: theme.textTheme.titleSmall!.copyWith( + color: Colors.white, + ), ), - ), + ), + ), + ], + ), + appBar: AppBar( + automaticallyImplyLeading: false, + leading: Builder( + builder: (context) => IconButton( + icon: const Icon(Icons.close_rounded), + onPressed: () => GoRouter.of(context).pop(), ), ), - ], - ), - appBar: AppBar( - automaticallyImplyLeading: false, - leading: Builder( - builder: (context) => IconButton( - icon: const Icon(Icons.close_rounded), - onPressed: () => GoRouter.of(context).pop(), + centerTitle: true, + title: SvgPicture.asset( + ImageConstant.imgLogoEatvio, + height: 48, + fit: BoxFit.contain, ), - ), - centerTitle: true, - title: SvgPicture.asset( - ImageConstant.imgLogoEatvio, - height: 48, - fit: BoxFit.contain, - ), - actions: [ - Padding( - padding: const EdgeInsets.only(right: 16), - child: Text('шаг $step из $totalSteps'), - ), - ], - bottom: PreferredSize( - preferredSize: Size(MediaQuery.of(context).size.width, 32), - child: Padding( - padding: const EdgeInsets.all(16), - child: TweenAnimationBuilder( - duration: const Duration(milliseconds: 350), - curve: Curves.easeInOut, - tween: Tween( - begin: 1 / totalSteps, - end: progressValue, - ), - builder: (context, value, _) => LinearProgressIndicator( - value: value, - borderRadius: BorderRadius.circular(16), - minHeight: 8, - backgroundColor: Colors.grey.shade100, - valueColor: AlwaysStoppedAnimation( - appTheme.blueA400, + actions: [ + Padding( + padding: const EdgeInsets.only(right: 16), + child: Text('шаг $step из $totalSteps'), + ), + ], + bottom: PreferredSize( + preferredSize: Size(MediaQuery.of(context).size.width, 32), + child: Padding( + padding: const EdgeInsets.all(16), + child: TweenAnimationBuilder( + duration: const Duration(milliseconds: 350), + curve: Curves.easeInOut, + tween: Tween( + begin: 1 / totalSteps, + end: progressValue, + ), + builder: (context, value, _) => LinearProgressIndicator( + value: value, + borderRadius: BorderRadius.circular(16), + minHeight: 8, + backgroundColor: Colors.grey.shade100, + valueColor: AlwaysStoppedAnimation( + appTheme.blueA400, + ), ), ), ), ), ), - ), - body: PageView( - physics: const NeverScrollableScrollPhysics(), - controller: pageController, - onPageChanged: (index) { - setState(() { - step = index + 1; - progressValue = step / totalSteps; - }); - }, - children: _pages(), - ), - ); - }, + body: PageView( + physics: const NeverScrollableScrollPhysics(), + controller: pageController, + onPageChanged: (index) { + setState(() { + step = index + 1; + progressValue = step / totalSteps; + }); + }, + children: _pages(), + ), + ); + }, + ), ), ); } diff --git a/lib/features/consultant/become_consultant/utils/passport_data_validator.dart b/lib/features/consultant/become_consultant/utils/passport_data_validator.dart new file mode 100644 index 00000000..73c0f1a6 --- /dev/null +++ b/lib/features/consultant/become_consultant/utils/passport_data_validator.dart @@ -0,0 +1,185 @@ +import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; + +/// Client-side validation for individual-person passport fields. +/// +/// Messages match [mapConsultantValidationMessage] so 422 and local errors +/// look the same in the UI. Dates also reject non-calendar values and +/// dates after today. +abstract final class PassportDataValidator { + static const futureDateMessage = 'Дата не может быть в будущем'; + + static Map validate( + ConsultantProfileModel consultant, { + DateTime? now, + }) { + final today = now ?? DateTime.now(); + final errors = {}; + + _requireText( + errors, + key: 'last_name', + value: consultant.lastName, + emptyMessage: 'Укажите фамилию', + ); + _requireText( + errors, + key: 'first_name', + value: consultant.firstName, + emptyMessage: 'Укажите имя', + ); + _requirePattern( + errors, + key: 'inn', + value: consultant.inn, + pattern: RegExp(r'^\d{12}$'), + emptyMessage: 'Укажите ИНН', + invalidMessage: 'Некорректный ИНН', + ); + _requirePattern( + errors, + key: 'snils', + value: consultant.snils, + pattern: RegExp(r'^\d{3}-\d{3}-\d{3} \d{2}$'), + emptyMessage: 'Укажите СНИЛС', + invalidMessage: 'Некорректный СНИЛС', + ); + _requirePattern( + errors, + key: 'passport_series', + value: consultant.passportSeries, + pattern: RegExp(r'^\d{4}$'), + emptyMessage: 'Укажите серию паспорта', + invalidMessage: 'Некорректная серия паспорта', + ); + _requirePattern( + errors, + key: 'passport_number', + value: consultant.passportNumber, + pattern: RegExp(r'^\d{6}$'), + emptyMessage: 'Укажите номер паспорта', + invalidMessage: 'Некорректный номер паспорта', + ); + _requireText( + errors, + key: 'passport_issuer', + value: consultant.passportIssuer, + emptyMessage: 'Укажите, кем выдан паспорт', + ); + _requirePattern( + errors, + key: 'passport_issuer_code', + value: consultant.passportIssuerCode, + pattern: RegExp(r'^\d{3}-\d{3}$'), + emptyMessage: 'Укажите код подразделения', + invalidMessage: 'Некорректный код подразделения', + ); + _requireDate( + errors, + key: 'passport_issue_date', + value: consultant.passportIssueDate, + emptyMessage: 'Укажите дату выдачи паспорта', + invalidMessage: 'Некорректная дата выдачи', + today: today, + ); + _requireDate( + errors, + key: 'birth_date', + value: consultant.birthDate, + emptyMessage: 'Укажите дату рождения', + invalidMessage: 'Некорректная дата рождения', + today: today, + ); + _requireText( + errors, + key: 'birth_place', + value: consultant.birthPlace, + emptyMessage: 'Укажите место рождения', + ); + _requireText( + errors, + key: 'registration_address', + value: consultant.registrationAddress, + emptyMessage: 'Укажите адрес регистрации', + ); + _requireText( + errors, + key: 'residence_address', + value: consultant.residenceAddress, + emptyMessage: 'Укажите адрес проживания', + ); + _requirePattern( + errors, + key: 'citizenship', + value: consultant.citizenship, + pattern: RegExp(r'^[A-Za-z]{2}$'), + emptyMessage: 'Укажите гражданство', + invalidMessage: 'Некорректный код гражданства', + ); + + return errors; + } + + static void _requireText( + Map errors, { + required String key, + required String? value, + required String emptyMessage, + }) { + if (value == null || value.trim().isEmpty) { + errors[key] = emptyMessage; + } + } + + static void _requirePattern( + Map errors, { + required String key, + required String? value, + required RegExp pattern, + required String emptyMessage, + required String invalidMessage, + }) { + final raw = value?.trim() ?? ''; + if (raw.isEmpty) { + errors[key] = emptyMessage; + return; + } + if (!pattern.hasMatch(raw)) { + errors[key] = invalidMessage; + } + } + + static void _requireDate( + Map errors, { + required String key, + required String? value, + required String emptyMessage, + required String invalidMessage, + required DateTime today, + }) { + final raw = value?.trim() ?? ''; + if (raw.isEmpty) { + errors[key] = emptyMessage; + return; + } + + final match = RegExp(r'^(\d{4})-(\d{2})-(\d{2})$').firstMatch(raw); + if (match == null) { + errors[key] = invalidMessage; + return; + } + + final year = int.parse(match.group(1)!); + final month = int.parse(match.group(2)!); + final day = int.parse(match.group(3)!); + final parsed = DateTime(year, month, day); + if (parsed.year != year || parsed.month != month || parsed.day != day) { + errors[key] = invalidMessage; + return; + } + + final todayDate = DateTime(today.year, today.month, today.day); + if (parsed.isAfter(todayDate)) { + errors[key] = futureDateMessage; + } + } +} diff --git a/test/features/consultant/become_consultant/models/consultant_profile_model_test.dart b/test/features/consultant/become_consultant/models/consultant_profile_model_test.dart index 12676d48..82a2ab44 100644 --- a/test/features/consultant/become_consultant/models/consultant_profile_model_test.dart +++ b/test/features/consultant/become_consultant/models/consultant_profile_model_test.dart @@ -158,6 +158,10 @@ void main() { expect(valid.hasValidPassportData, isTrue); expect(valid.copyWith(inn: '123').hasValidPassportData, isFalse); expect(valid.copyWith(lastName: '').hasValidPassportData, isFalse); + expect( + valid.copyWith(birthDate: '2020-02-31').hasValidPassportData, + isFalse, + ); }); }); } diff --git a/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart b/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart index 6f6a0090..f9118d8f 100644 --- a/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart +++ b/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart @@ -463,7 +463,30 @@ void main() { expect(cubit.state.isScoringPolling, isFalse); }); - test('registerConsultant blocks submit without passport data', () async { + test( + 'retryScoring writes fieldErrors when passport data is invalid', + () async { + cubit.emit( + cubit.state.copyWith( + alfaScoringStatus: AlfaScoringStatus.scoringFailed, + loading: false, + ), + ); + + await cubit.retryScoring(); + + expect(cubit.state.fieldErrors['inn'], 'Укажите ИНН'); + expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.scoringFailed); + verifyNever( + () => repository.registerConsultant( + consultant: any(named: 'consultant'), + update: any(named: 'update'), + ), + ); + }, + ); + + test('registerConsultant writes fieldErrors without calling API', () async { when( () => repository.registerConsultant( consultant: any(named: 'consultant'), @@ -473,7 +496,10 @@ void main() { await cubit.registerConsultant(); - expect(cubit.state.error, 'Заполните паспортные данные'); + expect(cubit.state.fieldErrors['last_name'], 'Укажите фамилию'); + expect(cubit.state.fieldErrors['inn'], 'Укажите ИНН'); + expect(cubit.state.fieldErrors['birth_date'], 'Укажите дату рождения'); + expect(cubit.state.error, isNull); verifyNever( () => repository.registerConsultant( consultant: any(named: 'consultant'), @@ -482,6 +508,19 @@ void main() { ); }); + test('editing a field clears only that field error', () async { + await cubit.registerConsultant(); + expect(cubit.state.fieldErrors.containsKey('last_name'), isTrue); + expect(cubit.state.fieldErrors.containsKey('inn'), isTrue); + + cubit.updateConsultantProfileModel( + cubit.state.consultant.copyWith(lastName: 'Иванов'), + ); + + expect(cubit.state.fieldErrors.containsKey('last_name'), isFalse); + expect(cubit.state.fieldErrors['inn'], 'Укажите ИНН'); + }); + test('registerConsultant maps Laravel 422 into fieldErrors', () async { when( () => repository.registerConsultant( diff --git a/test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart b/test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart index 1b508e45..1385a5a7 100644 --- a/test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart @@ -4,6 +4,7 @@ import 'package:eatvio_app/features/consultant/become_consultant/presentation/bl import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; +import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -60,5 +61,19 @@ void main() { expect(find.text('Банк получатель (поиск)'), findsOneWidget); expect(find.text('Я являюсь налоговым резидентом США'), findsOneWidget); }); + + testWidgets('shows ValidationErrorWidget for invalid field', ( + tester, + ) async { + cubit.emit( + cubit.state.copyWith(fieldErrors: const {'inn': 'Некорректный ИНН'}), + ); + + await tester.pumpWidget(wrap(const PassportDataScreen())); + await tester.pump(); + + expect(find.byType(ValidationErrorWidget), findsOneWidget); + expect(find.text('Некорректный ИНН'), findsOneWidget); + }); }); } diff --git a/test/features/consultant/become_consultant/utils/passport_data_validator_test.dart b/test/features/consultant/become_consultant/utils/passport_data_validator_test.dart new file mode 100644 index 00000000..3e32f758 --- /dev/null +++ b/test/features/consultant/become_consultant/utils/passport_data_validator_test.dart @@ -0,0 +1,92 @@ +import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/utils/passport_data_validator.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const valid = ConsultantProfileModel( + lastName: 'Иванов', + firstName: 'Иван', + inn: '123456789012', + snils: '123-456-789 00', + passportSeries: '1234', + passportNumber: '567890', + passportIssueDate: '2020-01-15', + passportIssuer: 'ОВД', + passportIssuerCode: '772-053', + birthDate: '1990-05-20', + birthPlace: 'Москва', + registrationAddress: 'Адрес 1', + residenceAddress: 'Адрес 2', + citizenship: 'RU', + ); + + group('PassportDataValidator', () { + test('returns empty map for a valid individual person profile', () { + expect(PassportDataValidator.validate(valid), isEmpty); + }); + + test('requires empty text fields', () { + final errors = PassportDataValidator.validate( + valid.copyWith(lastName: '', firstName: null), + ); + + expect(errors['last_name'], 'Укажите фамилию'); + expect(errors['first_name'], 'Укажите имя'); + }); + + test('rejects short INN and malformed SNILS', () { + final errors = PassportDataValidator.validate( + valid.copyWith(inn: '123', snils: '12345678900'), + ); + + expect(errors['inn'], 'Некорректный ИНН'); + expect(errors['snils'], 'Некорректный СНИЛС'); + }); + + test('rejects empty INN and SNILS', () { + final errors = PassportDataValidator.validate( + valid.copyWith(inn: '', snils: ''), + ); + + expect(errors['inn'], 'Укажите ИНН'); + expect(errors['snils'], 'Укажите СНИЛС'); + }); + + test('rejects non-calendar dates', () { + final errors = PassportDataValidator.validate( + valid.copyWith( + birthDate: '2020-02-31', + passportIssueDate: '1999-13-32', + ), + ); + + expect(errors['birth_date'], 'Некорректная дата рождения'); + expect(errors['passport_issue_date'], 'Некорректная дата выдачи'); + }); + + test('rejects dates in the future', () { + final errors = PassportDataValidator.validate( + valid.copyWith( + birthDate: '2030-01-01', + passportIssueDate: '2030-01-01', + ), + now: DateTime(2026, 8, 13), + ); + + expect(errors['birth_date'], PassportDataValidator.futureDateMessage); + expect( + errors['passport_issue_date'], + PassportDataValidator.futureDateMessage, + ); + }); + + test('accepts today as a valid date', () { + final errors = PassportDataValidator.validate( + valid.copyWith(passportIssueDate: '2026-08-13'), + now: DateTime(2026, 8, 13, 18), + ); + + expect(errors.containsKey('passport_issue_date'), isFalse); + }); + }); +}