From 3af60f38b1d7ec0293d8d95b63be8be2c99fc732 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Fri, 23 Oct 2026 09:34:30 +0200 Subject: [PATCH 1/4] Show accepted signup delivery without a second send step (#62) --- docs/ux/auth-journeys.md | 61 +++++++ frontend/app/lib/auth/form.dart | 3 +- frontend/app/lib/routing/router.dart | 5 + frontend/app/lib/verification/page.dart | 32 +++- frontend/app/pubspec.lock | 2 +- frontend/app/pubspec.yaml | 1 + frontend/app/test/routing_test.dart | 38 +++++ frontend/app/test/signup_delivery_test.dart | 153 ++++++++++++++++++ .../sxchnge_auth/lib/src/mappers/backend.dart | 8 +- .../sxchnge_auth/lib/src/mappers/results.dart | 6 + .../sxchnge_auth/lib/src/models/auth.dart | 3 + .../lib/src/models/verification.dart | 7 + .../lib/src/providers/verification.dart | 17 +- .../lib/src/services/backend.dart | 12 +- .../sxchnge_auth/lib/src/services/dummy.dart | 3 +- .../lib/src/services/instrumented.dart | 28 +++- .../packages/sxchnge_auth/test/auth_test.dart | 10 +- .../sxchnge_auth/test/backend_test.dart | 71 ++++---- .../sxchnge_auth/test/verification_test.dart | 30 ++++ 19 files changed, 421 insertions(+), 69 deletions(-) create mode 100644 frontend/app/test/signup_delivery_test.dart diff --git a/docs/ux/auth-journeys.md b/docs/ux/auth-journeys.md index 083e5fb..a8c2841 100644 --- a/docs/ux/auth-journeys.md +++ b/docs/ux/auth-journeys.md @@ -296,3 +296,64 @@ real token was checked read-only and remains unused and unexpired, with its account pending verification, ready for the user to reopen the link. PR #61 has not been merged into remote main; this local deployment does not imply a main branch merge. + +### Accepted signup delivery (#62) + +Task [#62](https://git.sendico.io/tech/sxchnge/issues/62), branch +`email-verification-62`. On October 23, signup returned HTTP 202 and the local +SMTP provider accepted its message with status 250, while the user reported an +internal-error screen immediately after Create account. No subsequent +verification request from that attempt reached the BFF. The precise original +client exception was not captured; an ordinary isolated release reproduction +succeeded. The regression below deliberately injects an unavailable follow-up +send service to cover the post-acceptance failure path without claiming that +injected exception was the original cause. + +Backend and preview signup mappers now return a `VerificationDelivery` domain +receipt with the recipient and simulation status. It means that signup accepted +the email-queue operation, not inbox delivery, account activation or a session. +The signup provider exposes it through its operation result. The form captures +the receipt before clearing password controllers, whose change listeners reset +auth state, and passes it as transient router extra. The verification provider +initializes directly in the sent state; mounting the page performs no send. +This replaces the backend adapter's hidden one-use signup cache. Every explicit +resend now invokes its service, including the first one after signup. + +Routes without a receipt retain their existing behavior: unverified login may +request an email, a fresh verification page asks for an address, and emailed +links run confirmation. Recipient changes invalidate the displayed claim until +another send succeeds. New receipts replace stale recipients. The route codec +discards the receipt when serializing history, so reloads do not invent a queued +result or retain the address. No token or password is included in the receipt. + +The existing email-delivery requested/succeeded signals now follow the accepted +signup receipt inside the instrumented service instead of a synthetic send on +page mount. They stay in the signup journey, occur once for the accepted +operation, and remain intermediate queue-acceptance signals. Resends retain +separate attempts and failures; confirmation remains the terminal outcome. +Simulation and privacy rules are unchanged, including the in-memory analytics +and cross-device correlation limitations above. + +Frontend review covered the complete app/auth contribution and preserved the +DTO, mapper, service, domain model, provider and UI boundaries. Regressions cover +accepted signup followed by unavailable resend, explicit retry, normalized +receipt mapping, provider initialization/reset, recipient replacement, transient +history, event correlation and exclusion of form values from analytics. EN/RU +flow tests run at 390px with 1.5x text scaling; existing keyboard, autofill and +accessibility suites also pass. Changed-file Dart format checks, analysis in +`frontend/app` and `frontend/packages/sxchnge_auth`, and `make test-frontend` +(221 tests) pass. The new injected-failure regression failed on the previous +implementation and passes with the receipt handoff. A release-browser fixture +with the same backend adapter and a failing resend service shows Check your +inbox after signup with no send call, then the expected error only after an +explicit resend. It sends no email. Native Safari was not automated. + +The frontend image was rebuilt with freshly resolved stable digest pins and +recreated without restarting BFF. The served JavaScript matches the new image +at SHA-256 `0a45ce6f0b4658b1f03d8e39b9e39fd3fbfcf6f1626cb3f17705dce2da130a6c`. +Normal browser-trust HTTPS serves the frontend and API with HTTP 200. +`sh infra/local/tools.sh verify` passes all seven services, Vault/database +checks and authenticated Sendico currency/ledger reads. No new customer email, +activation or payment was submitted for this change, and no security scan was +requested or run. The user's pre-existing browser tab must reload to use the +new bundle; their original Safari exception remains unobserved. diff --git a/frontend/app/lib/auth/form.dart b/frontend/app/lib/auth/form.dart index 90124c8..ebc2544 100644 --- a/frontend/app/lib/auth/form.dart +++ b/frontend/app/lib/auth/form.dart @@ -99,10 +99,11 @@ class _AuthFormState extends State { return; } final email = _email.text.trim(); + final delivery = signup.state.result!.delivery; TextInput.finishAutofillContext(); _password.clear(); _confirmation.clear(); - context.goNamed(AppRoutes.verificationSent, extra: email); + context.goNamed(AppRoutes.verificationSent, extra: delivery ?? email); } else { final login = context.read(); await login.login( diff --git a/frontend/app/lib/routing/router.dart b/frontend/app/lib/routing/router.dart index e244bc8..8bdbb6f 100644 --- a/frontend/app/lib/routing/router.dart +++ b/frontend/app/lib/routing/router.dart @@ -4,6 +4,8 @@ import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; +import 'package:sxchnge_auth/sxchnge_auth.dart'; + import 'package:sxchnge_wallet/sxchnge_wallet.dart'; import '../auth/page.dart'; @@ -144,6 +146,9 @@ GoRouter createAppRouter({bool Function()? authenticated}) { name: AppRoutes.verificationSent, builder: (context, state) => VerificationPage( email: state.extra is String ? state.extra as String : null, + delivery: state.extra is VerificationDelivery + ? state.extra as VerificationDelivery + : null, ), ), GoRoute( diff --git a/frontend/app/lib/verification/page.dart b/frontend/app/lib/verification/page.dart index f27e9b5..e9e0f3e 100644 --- a/frontend/app/lib/verification/page.dart +++ b/frontend/app/lib/verification/page.dart @@ -15,9 +15,10 @@ import '../routing/routes.dart'; import '../generated/i18n/app_localizations.dart'; class VerificationPage extends StatefulWidget { - const VerificationPage({super.key, this.email, this.token}); + const VerificationPage({super.key, this.email, this.token, this.delivery}); final String? email; final String? token; + final VerificationDelivery? delivery; @override State createState() => _VerificationPageState(); @@ -25,9 +26,12 @@ class VerificationPage extends StatefulWidget { class _VerificationPageState extends State { final _form = GlobalKey(); - late final _email = TextEditingController(text: widget.email); + late final _email = TextEditingController( + text: widget.delivery?.email ?? widget.email, + ); late final _provider = VerificationProvider( context.read(), + delivery: widget.delivery, ); final _emailFocus = FocusNode(); String? _recipient; @@ -44,7 +48,7 @@ class _VerificationPageState extends State { if (!mounted) return; if (widget.token != null) { _provider.confirm(widget.token!); - } else if (_email.text.isNotEmpty) { + } else if (widget.delivery == null && _email.text.isNotEmpty) { _send(); } }); @@ -58,14 +62,26 @@ class _VerificationPageState extends State { @override void didUpdateWidget(covariant VerificationPage oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.token == widget.token) return; - context.read().abandon(JourneyExit.navigation); - _provider.reset(); + if (oldWidget.token == widget.token && + oldWidget.email == widget.email && + identical(oldWidget.delivery, widget.delivery)) { + return; + } + if (oldWidget.token != widget.token) { + context.read().abandon(JourneyExit.navigation); + } + _provider.reset(delivery: widget.delivery); _lastWasSend = false; _validated = false; - _email.clear(); + _recipient = null; + _email.text = widget.delivery?.email ?? widget.email ?? ''; WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && widget.token != null) _provider.confirm(widget.token!); + if (!mounted) return; + if (widget.token != null) { + _provider.confirm(widget.token!); + } else if (widget.delivery == null && _email.text.isNotEmpty) { + _send(); + } }); } diff --git a/frontend/app/pubspec.lock b/frontend/app/pubspec.lock index 302e955..b40d786 100644 --- a/frontend/app/pubspec.lock +++ b/frontend/app/pubspec.lock @@ -198,7 +198,7 @@ packages: source: hosted version: "18.0.1" http: - dependency: transitive + dependency: "direct dev" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" diff --git a/frontend/app/pubspec.yaml b/frontend/app/pubspec.yaml index 9a0ccba..d7994d0 100644 --- a/frontend/app/pubspec.yaml +++ b/frontend/app/pubspec.yaml @@ -31,6 +31,7 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 + http: ^1.6.0 flutter: generate: true uses-material-design: true diff --git a/frontend/app/test/routing_test.dart b/frontend/app/test/routing_test.dart index 2c6bb65..51747f3 100644 --- a/frontend/app/test/routing_test.dart +++ b/frontend/app/test/routing_test.dart @@ -42,6 +42,44 @@ Future openRoute( } void main() { + testWidgets('queued signup receipt is transient and updates its recipient', ( + tester, + ) async { + final router = await openRoute(tester, '/'); + await tester.pumpAndSettle(); + router.goNamed( + AppRoutes.verificationSent, + extra: const VerificationDelivery( + email: 'first@example.com', + isSimulated: false, + ), + ); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget); + expect(find.text('first@example.com'), findsOneWidget); + router.goNamed( + AppRoutes.verificationSent, + extra: const VerificationDelivery( + email: 'second@example.com', + isSimulated: false, + ), + ); + await tester.pumpAndSettle(); + expect(find.text('first@example.com'), findsNothing); + expect(find.text('second@example.com'), findsOneWidget); + final restored = router.routeInformationParser.restoreRouteInformation( + router.routerDelegate.currentConfiguration, + )!; + expect(restored.state.toString(), isNot(contains('second@example.com'))); + expect(restored.uri.toString(), '/verification-sent'); + router.goNamed(AppRoutes.login); + await tester.pumpAndSettle(); + router.goNamed(AppRoutes.verificationSent); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('verification-idle')), findsOneWidget); + expect(find.text('second@example.com'), findsNothing); + }); + for (final language in ['en', 'ru']) { testWidgets( 'verified link keeps success visible and prefills login ($language)', diff --git a/frontend/app/test/signup_delivery_test.dart b/frontend/app/test/signup_delivery_test.dart new file mode 100644 index 0000000..b69a26f --- /dev/null +++ b/frontend/app/test/signup_delivery_test.dart @@ -0,0 +1,153 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:go_router/go_router.dart'; + +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'package:sxchnge_analytics/sxchnge_analytics.dart'; + +import 'package:sxchnge_auth/sxchnge_auth.dart'; + +import 'package:sxchnge_app/main.dart'; +import 'package:sxchnge_app/providers/locale.dart'; + +class DeliveryBackend extends BackendAuthServices { + DeliveryBackend(List requests) + : super( + baseUri: Uri.parse('https://api.example/api/v1'), + clientId: 'web', + client: MockClient((request) async { + requests.add(request.url.path); + return http.Response( + jsonEncode({ + 'data': request.url.path.endsWith('/signup') + ? {'userId': 'customer'} + : {'accepted': true}, + }), + 202, + ); + }), + ); + + int sends = 0; + bool failResend = true; + + @override + Future send({ + required String email, + required String locale, + }) async { + sends++; + if (failResend) { + throw const VerificationException(VerificationError.internal); + } + return super.send(email: email, locale: locale); + } +} + +void main() { + for (final language in ['en', 'ru']) { + testWidgets( + 'accepted signup survives an unavailable resend service ($language)', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + tester.platformDispatcher.textScaleFactorTestValue = 1.5; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + final requests = []; + final backend = DeliveryBackend(requests); + final analytics = DummyAnalyticsService(); + await tester.pumpWidget( + SxchngeApp( + backend: backend, + analyticsService: analytics, + localeProvider: LocaleProvider(initialLocale: Locale(language)), + ), + ); + await tester.pumpAndSettle(); + final signup = find.text(language == 'ru' ? 'Регистрация' : 'Sign up'); + await tester.ensureVisible(signup); + await tester.tap(signup); + await tester.pumpAndSettle(); + for (final entry in { + 'name': 'Ada Lovelace', + 'email': 'ada@example.com', + 'password': 'password preserved', + 'confirmation': 'password preserved', + }.entries) { + await tester.enterText(find.byKey(ValueKey(entry.key)), entry.value); + } + final submit = find.byKey(const ValueKey('submit')); + await tester.ensureVisible(submit); + await tester.tap(submit); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget); + expect(find.text('ada@example.com'), findsOneWidget); + expect(backend.sends, 0); + expect(requests, ['/api/v1/accounts/signup']); + final router = GoRouter.of(tester.element(find.byType(Scaffold))); + final restored = router.routeInformationParser.restoreRouteInformation( + router.routerDelegate.currentConfiguration, + )!; + expect(restored.uri.toString(), '/verification-sent'); + expect(restored.state.toString(), isNot(contains('ada@example.com'))); + expect( + analytics.events.where( + (event) => event.name == 'auth_signup_succeeded', + ), + hasLength(1), + ); + expect( + analytics.events.where( + (event) => event.name == 'auth_email_delivery_succeeded', + ), + hasLength(1), + ); + + final resend = find.byKey(const ValueKey('verification-resend')); + await tester.ensureVisible(resend); + await tester.tap(resend); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('verification-error')), + findsOneWidget, + ); + expect(backend.sends, 1); + expect( + tester + .widget( + find.byKey(const ValueKey('verification-email')), + ) + .controller! + .text, + 'ada@example.com', + ); + backend.failResend = false; + await tester.ensureVisible(resend); + await tester.tap(resend); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget); + expect(backend.sends, 2); + expect(requests.last, '/api/v1/accounts/verification/send'); + expect(requests, hasLength(2)); + expect( + analytics.events.map((event) => event.journeyId).toSet(), + hasLength(1), + ); + expect( + analytics.events.map((event) => event.properties).toString(), + isNot(contains('ada@example.com')), + ); + expect(tester.takeException(), isNull); + }, + ); + } +} diff --git a/frontend/packages/sxchnge_auth/lib/src/mappers/backend.dart b/frontend/packages/sxchnge_auth/lib/src/mappers/backend.dart index 14be575..6aeb7d9 100644 --- a/frontend/packages/sxchnge_auth/lib/src/mappers/backend.dart +++ b/frontend/packages/sxchnge_auth/lib/src/mappers/backend.dart @@ -12,9 +12,13 @@ abstract final class BackendMapper { return VerificationResult(isSimulated: false, verifiedEmail: dto.email); } - static AuthResult signup(BackendDTO dto) { + static AuthResult signup(BackendDTO dto, {required String email}) { if (dto.userId.isEmpty) throw const FormatException('Missing user'); - return AuthResult(userId: dto.userId, isSimulated: false); + return AuthResult( + userId: dto.userId, + isSimulated: false, + delivery: VerificationDelivery(email: email, isSimulated: false), + ); } static FactorChallenge challenge(BackendDTO dto, String id) { diff --git a/frontend/packages/sxchnge_auth/lib/src/mappers/results.dart b/frontend/packages/sxchnge_auth/lib/src/mappers/results.dart index e3ba202..486f26e 100644 --- a/frontend/packages/sxchnge_auth/lib/src/mappers/results.dart +++ b/frontend/packages/sxchnge_auth/lib/src/mappers/results.dart @@ -5,6 +5,12 @@ import '../models/verification.dart'; abstract final class AuthMapper { static AuthResult fromDTO(AuthDTO dto) => AuthResult(userId: dto.userId, isSimulated: dto.simulated); + + static AuthResult signup(AuthDTO dto, {required String email}) => AuthResult( + userId: dto.userId, + isSimulated: dto.simulated, + delivery: VerificationDelivery(email: email, isSimulated: dto.simulated), + ); } abstract final class VerificationMapper { diff --git a/frontend/packages/sxchnge_auth/lib/src/models/auth.dart b/frontend/packages/sxchnge_auth/lib/src/models/auth.dart index 5f7b654..11d3329 100644 --- a/frontend/packages/sxchnge_auth/lib/src/models/auth.dart +++ b/frontend/packages/sxchnge_auth/lib/src/models/auth.dart @@ -1,4 +1,5 @@ import 'two_factor.dart'; +import 'verification.dart'; /// Ephemeral input: never persist, log, or include passwords in provider state. class LoginRequest { @@ -31,9 +32,11 @@ class AuthResult { required this.userId, required this.isSimulated, this.challenge, + this.delivery, }); final String userId; final FactorChallenge? challenge; + final VerificationDelivery? delivery; final bool isSimulated; } diff --git a/frontend/packages/sxchnge_auth/lib/src/models/verification.dart b/frontend/packages/sxchnge_auth/lib/src/models/verification.dart index ed6dbe9..82d21e7 100644 --- a/frontend/packages/sxchnge_auth/lib/src/models/verification.dart +++ b/frontend/packages/sxchnge_auth/lib/src/models/verification.dart @@ -11,6 +11,13 @@ enum VerificationStatus { enum VerificationError { expired, invalid, internal, needsAssistance } +/// An accepted email-queue operation, not proof of inbox delivery or activation. +class VerificationDelivery { + const VerificationDelivery({required this.email, required this.isSimulated}); + final String email; + final bool isSimulated; +} + class VerificationException implements Exception { const VerificationException(this.code); final VerificationError code; diff --git a/frontend/packages/sxchnge_auth/lib/src/providers/verification.dart b/frontend/packages/sxchnge_auth/lib/src/providers/verification.dart index 2d0963d..a4d1da4 100644 --- a/frontend/packages/sxchnge_auth/lib/src/providers/verification.dart +++ b/frontend/packages/sxchnge_auth/lib/src/providers/verification.dart @@ -4,13 +4,22 @@ import '../models/verification.dart'; import '../services/verification.dart'; class VerificationProvider extends ChangeNotifier { - VerificationProvider(this.service); + VerificationProvider(this.service, {VerificationDelivery? delivery}) + : _state = _initialState(delivery); final VerificationService service; - VerificationState _state = const VerificationState(VerificationStatus.idle); + VerificationState _state; VerificationState get state => _state; bool _disposed = false; int _generation = 0; + static VerificationState _initialState(VerificationDelivery? delivery) => + delivery == null + ? const VerificationState(VerificationStatus.idle) + : VerificationState( + VerificationStatus.sent, + isSimulated: delivery.isSimulated, + ); + Future send({required String email, required String locale}) => _run( () => service.send(email: email.trim(), locale: locale), VerificationStatus.sending, @@ -61,10 +70,10 @@ class VerificationProvider extends ChangeNotifier { notifyListeners(); } - void reset() { + void reset({VerificationDelivery? delivery}) { if (_disposed) return; ++_generation; - _state = const VerificationState(VerificationStatus.idle); + _state = _initialState(delivery); notifyListeners(); } diff --git a/frontend/packages/sxchnge_auth/lib/src/services/backend.dart b/frontend/packages/sxchnge_auth/lib/src/services/backend.dart index 42a8388..f2aa666 100644 --- a/frontend/packages/sxchnge_auth/lib/src/services/backend.dart +++ b/frontend/packages/sxchnge_auth/lib/src/services/backend.dart @@ -51,7 +51,6 @@ class BackendAuthServices final _pending = {}; final _grants = {}; final _retryKeys = {}; - String? _queuedSignup; AuthSession? _session; AuthSession? get session => _session; @override @@ -113,9 +112,8 @@ class BackendAuthServices 'locale': r.locale, 'idempotencyKey': key, }); - final result = BackendMapper.signup(dto); + final result = BackendMapper.signup(dto, email: email); _retryKeys.remove('signup:$email'); - _queuedSignup = email; return result; } @@ -141,14 +139,6 @@ class BackendAuthServices required String locale, }) async { final normalized = email.trim().toLowerCase(); - // Signup already queued this email. The existing confirmation page requests - // its initial delivery on mount; consume that receipt once. - if (_queuedSignup == normalized) { - _queuedSignup = null; - return VerificationMapper.fromDTO( - const VerificationDTO(simulated: false), - ); - } final key = _retryKeys.putIfAbsent('activation:$normalized', _key); try { await _post('verification/send', { diff --git a/frontend/packages/sxchnge_auth/lib/src/services/dummy.dart b/frontend/packages/sxchnge_auth/lib/src/services/dummy.dart index 28ba934..82292a1 100644 --- a/frontend/packages/sxchnge_auth/lib/src/services/dummy.dart +++ b/frontend/packages/sxchnge_auth/lib/src/services/dummy.dart @@ -40,8 +40,9 @@ class DummySignupService implements SignupService, SimulationInfo { Future signup(SignupRequest request) async { await Future.delayed(delay); if (failure case final error?) throw AuthException(error); - return AuthMapper.fromDTO( + return AuthMapper.signup( const AuthDTO(userId: 'demo-user', simulated: true), + email: request.email.trim(), ); } } diff --git a/frontend/packages/sxchnge_auth/lib/src/services/instrumented.dart b/frontend/packages/sxchnge_auth/lib/src/services/instrumented.dart index 5f3803b..ce18d18 100644 --- a/frontend/packages/sxchnge_auth/lib/src/services/instrumented.dart +++ b/frontend/packages/sxchnge_auth/lib/src/services/instrumented.dart @@ -111,13 +111,27 @@ class InstrumentedAuthServices simulation: _simulation(loginService), ); @override - Future signup(SignupRequest request) => _run( - JourneyKind.signup, - JourneyStep.signup, - () => signupService.signup(request), - (r) => r.isSimulated, - simulation: _simulation(signupService), - ); + Future signup(SignupRequest request) async { + final result = await _run( + JourneyKind.signup, + JourneyStep.signup, + () => signupService.signup(request), + (r) => r.isSimulated, + simulation: _simulation(signupService), + ); + final delivery = result.delivery; + if (delivery != null) { + final operation = recorder.begin( + JourneyKind.verification, + JourneyStep.emailDelivery, + onboarding: true, + simulated: delivery.isSimulated, + ); + recorder.finish(operation, simulated: delivery.isSimulated); + } + return result; + } + @override Future send({ required String email, diff --git a/frontend/packages/sxchnge_auth/test/auth_test.dart b/frontend/packages/sxchnge_auth/test/auth_test.dart index f8ea695..bed649f 100644 --- a/frontend/packages/sxchnge_auth/test/auth_test.dart +++ b/frontend/packages/sxchnge_auth/test/auth_test.dart @@ -142,11 +142,11 @@ void main() { .isSimulated, isTrue, ); - expect( - (await const DummySignupService(delay: Duration.zero).signup(signup)) - .isSimulated, - isTrue, - ); + final accepted = await const DummySignupService(delay: Duration.zero) + .signup(signup); + expect(accepted.isSimulated, isTrue); + expect(accepted.delivery?.email, 'a@example.com'); + expect(accepted.delivery?.isSimulated, isTrue); await expectLater( const DummyLoginService( delay: Duration.zero, diff --git a/frontend/packages/sxchnge_auth/test/backend_test.dart b/frontend/packages/sxchnge_auth/test/backend_test.dart index 91b5ee3..3b71803 100644 --- a/frontend/packages/sxchnge_auth/test/backend_test.dart +++ b/frontend/packages/sxchnge_auth/test/backend_test.dart @@ -89,35 +89,48 @@ void main() { expect(keys[0], keys[1]); }, ); - test('signup receipt avoids duplicate delivery and subsequent resend reaches API', () async { - final paths = []; - final service = BackendAuthServices( - baseUri: Uri.parse('https://api.example/api/v1'), - clientId: 'web', - client: MockClient((request) async { - paths.add(request.url.path); - if (request.url.path.endsWith('/signup')) { - expect(jsonDecode(request.body)['password'], ' password preserved '); - return response({'userId': 'user'}); - } - return response({'accepted': true}); - }), - ); - addTearDown(service.close); - final result = await service.signup( - const SignupRequest( - name: 'User', - email: 'USER@example.com', - password: ' password preserved ', - locale: 'en', - ), - ); - expect(result.isSimulated, isFalse); - await service.send(email: 'user@example.com', locale: 'en'); - expect(paths, ['/api/v1/accounts/signup']); - await service.send(email: 'user@example.com', locale: 'en'); - expect(paths.last, '/api/v1/accounts/verification/send'); - }); + test( + 'signup returns its receipt and every explicit resend reaches API', + () async { + final paths = []; + final service = BackendAuthServices( + baseUri: Uri.parse('https://api.example/api/v1'), + clientId: 'web', + client: MockClient((request) async { + paths.add(request.url.path); + if (request.url.path.endsWith('/signup')) { + expect( + jsonDecode(request.body)['password'], + ' password preserved ', + ); + return response({'userId': 'user'}); + } + return response({'accepted': true}); + }), + ); + addTearDown(service.close); + final result = await service.signup( + const SignupRequest( + name: 'User', + email: 'USER@example.com', + password: ' password preserved ', + locale: 'en', + ), + ); + expect(result.isSimulated, isFalse); + expect(result.delivery?.email, 'user@example.com'); + expect(result.delivery?.isSimulated, isFalse); + expect(paths, ['/api/v1/accounts/signup']); + await service.send(email: 'user@example.com', locale: 'en'); + expect(paths, [ + '/api/v1/accounts/signup', + '/api/v1/accounts/verification/send', + ]); + await service.send(email: 'user@example.com', locale: 'en'); + expect(paths.last, '/api/v1/accounts/verification/send'); + expect(paths, hasLength(3)); + }, + ); test('login confirms backend session only after factor and consumes local grant once', () async { final now = DateTime.now(); var verified = false; diff --git a/frontend/packages/sxchnge_auth/test/verification_test.dart b/frontend/packages/sxchnge_auth/test/verification_test.dart index fb5743c..e8616ba 100644 --- a/frontend/packages/sxchnge_auth/test/verification_test.dart +++ b/frontend/packages/sxchnge_auth/test/verification_test.dart @@ -28,6 +28,36 @@ class PendingService implements VerificationService { } void main() { + test( + 'accepted delivery initializes sent state without calling the service', + () async { + final service = PendingService(); + final provider = VerificationProvider( + service, + delivery: const VerificationDelivery( + email: 'ada@example.com', + isSimulated: false, + ), + ); + addTearDown(provider.dispose); + expect(provider.state.status, VerificationStatus.sent); + expect(provider.state.isSimulated, isFalse); + expect(provider.state.result, isNull); + expect(service.calls, 0); + final resend = provider.send(email: 'ada@example.com', locale: 'en'); + expect(provider.state.status, VerificationStatus.sending); + expect(service.calls, 1); + service.pending.completeError( + const VerificationException(VerificationError.internal), + ); + await resend; + expect(provider.state.status, VerificationStatus.error); + expect(provider.state.result, isNull); + provider.reset(); + expect(provider.state.status, VerificationStatus.idle); + }, + ); + for (final scenario in { 'demo-valid': VerificationStatus.verified, 'demo-expired': VerificationStatus.expired, From c8ef9c3d14d5a6b050e5b506fa0bba522b3a6fc2 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Fri, 23 Oct 2026 09:35:28 +0200 Subject: [PATCH 2/4] Record delivery PR for accepted signup state (#62) --- docs/ux/auth-journeys.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/ux/auth-journeys.md b/docs/ux/auth-journeys.md index a8c2841..954f9ab 100644 --- a/docs/ux/auth-journeys.md +++ b/docs/ux/auth-journeys.md @@ -300,7 +300,9 @@ branch merge. ### Accepted signup delivery (#62) Task [#62](https://git.sendico.io/tech/sxchnge/issues/62), branch -`email-verification-62`. On October 23, signup returned HTTP 202 and the local +`email-verification-62`, delivered in +[PR #63](https://git.sendico.io/tech/sxchnge/pulls/63). On October 23, signup +returned HTTP 202 and the local SMTP provider accepted its message with status 250, while the user reported an internal-error screen immediately after Create account. No subsequent verification request from that attempt reached the BFF. The precise original From 4605a197ad3658f121d1f7fa6e4e673079c98078 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Fri, 23 Oct 2026 12:19:24 +0200 Subject: [PATCH 3/4] Use dedicated birthday entry controls (#67) --- docs/ux/kyc.md | 47 ++++++ frontend/app/lib/kyc/birth.dart | 206 ++++++++++++++++++++++++++ frontend/app/lib/kyc/profile.dart | 13 +- frontend/app/lib/l10n/en.arb | 6 +- frontend/app/lib/l10n/ru.arb | 6 +- frontend/app/test/birth_test.dart | 236 ++++++++++++++++++++++++++++++ frontend/app/test/kyc_test.dart | 50 ++++++- 7 files changed, 551 insertions(+), 13 deletions(-) create mode 100644 frontend/app/lib/kyc/birth.dart create mode 100644 frontend/app/test/birth_test.dart diff --git a/docs/ux/kyc.md b/docs/ux/kyc.md index 2677666..bdc322a 100644 --- a/docs/ux/kyc.md +++ b/docs/ux/kyc.md @@ -211,3 +211,50 @@ smoke testing on the local demo opened the selector, searched for Poland and confirmed that the profile displays Poland after selection. Focused and full journey tests verify the stored `PL` code. Generated localization/build outputs remain ignored and uncommitted. + +## Birthday entry — task #67 + +Task [#67](https://git.sendico.io/tech/sxchnge/issues/67), branch `kyc-67`. +This change is stacked on the residence-country selector in PR #65; merge #65 +before the birthday PR. + +The profile uses labeled Day, Month and Year controls. Day and year accept numeric +entry with birthday autofill hints; month selection uses localized month names +and standard Material keyboard/touch interaction. Customers can enter their birth +year directly. No calendar paging or transport-format punctuation is required. +The controls share a row when space allows and stack at narrow widths or enlarged +text sizes. English and Russian labels, month names and errors follow the app locale. + +Valid calendar dates are normalized to the existing `IdentityProfile.dateOfBirth` +`YYYY-MM-DD` value through `IdentityProvider.updateProfile`. Partial, impossible +and future dates clear that profile value so Continue cannot retain an earlier +valid birthday. Local input components remain visible while correcting a date; +complete invalid dates receive a specific live-region error below the group. +Leap years, zero day/year and calendar rollover are checked. Today's date follows +the existing model policy; no minimum age or arbitrary oldest birth year is added. +Prefilled dates, locale changes and external profile replacement synchronize the +controls. Back navigation restores the valid saved date. Busy state disables all +three controls. Controllers are disposed with the widget. + +The existing DTO, mapper, service, domain model and provider boundaries are retained. +No dependency, transport or backend change is needed. This remains part of the KYC +preparation-to-submission journey defined above: existing acceptance, failure, +retry and abandonment signals apply, without new field events or date collection. +The existing server-side review measurement gaps remain unchanged. + +UX review and automated checks cover entry/correction, leap dates, invalid and +future-date recovery, numeric restrictions, prefill/replacement/clearing, locale +switching, disabled state, Tab/Enter/arrow-key month selection, Escape cancellation, +and Russian controls/errors at 320px with 1.6 text scale. Labeled and minimum-size +tap-target guidelines pass. Integration checks verify that invalid dates block +Continue, Back preserves the birthday, and EN/RU flows still reach submission +acceptance. Native screen-reader and mobile keyboard/autofill testing remain +release checks; widget semantics are not a claim of device-level validation. + +Validation: all four changed Dart files pass the format check; `flutter analyze` +in `frontend/app` reports no issues; `make test-frontend` passes all 230 tests, +including the seven new birthday widget tests and the new navigation/gating test. +`flutter build web` passes, including its Wasm compatibility dry run. Browser +smoke testing of the release build entered 23 April 1990, verified the inline +error for 31 April and restored 23 April without losing month or year. +Generated localization and build outputs remain ignored and uncommitted. diff --git a/frontend/app/lib/kyc/birth.dart b/frontend/app/lib/kyc/birth.dart new file mode 100644 index 0000000..c918804 --- /dev/null +++ b/frontend/app/lib/kyc/birth.dart @@ -0,0 +1,206 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:intl/intl.dart'; + +import '../generated/i18n/app_localizations.dart'; + +/// Birthday entry with a canonical date string at the profile boundary. +class BirthField extends StatefulWidget { + const BirthField({ + super.key, + required this.value, + required this.onChanged, + this.enabled = true, + }); + + final String value; + final ValueChanged onChanged; + final bool enabled; + + @override + State createState() => _BirthFieldState(); +} + +class _BirthFieldState extends State { + final _day = TextEditingController(); + final _year = TextEditingController(); + final _monthField = GlobalKey>(); + int? _month; + late String _emitted; + + @override + void initState() { + super.initState(); + _readValue(); + } + + @override + void didUpdateWidget(BirthField oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.value != _emitted) { + _readValue(); + _monthField.currentState?.didChange(_month); + } + } + + void _readValue() { + _emitted = widget.value; + final parts = RegExp(r'^(\d{4})-(\d{2})-(\d{2})$').firstMatch(widget.value); + _year.text = parts?.group(1) ?? ''; + final month = int.tryParse(parts?.group(2) ?? ''); + _month = month != null && month >= 1 && month <= 12 ? month : null; + _day.text = parts?.group(3) ?? ''; + } + + DateTime? get _date { + final day = int.tryParse(_day.text); + final year = int.tryParse(_year.text); + if (day == null || + year == null || + year < 1 || + _year.text.length != 4 || + _month == null) { + return null; + } + final date = DateTime(year, _month!, day); + return date.year == year && date.month == _month && date.day == day + ? date + : null; + } + + bool _future(DateTime date) => + date.isAfter(DateUtils.dateOnly(DateTime.now())); + + void _change() { + setState(() {}); + final date = _date; + _emitted = date == null || _future(date) + ? '' + : '${_year.text}-${_month.toString().padLeft(2, '0')}-' + '${_day.text.padLeft(2, '0')}'; + widget.onChanged(_emitted); + } + + @override + void dispose() { + _day.dispose(); + _year.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; + final date = _date; + final complete = + _day.text.isNotEmpty && _month != null && _year.text.length == 4; + final error = !complete + ? null + : date == null + ? l.kycBirthInvalid + : _future(date) + ? l.kycBirthFuture + : null; + final months = DateFormat.MMMM(Localizations.localeOf(context).toString()); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l.kycBirth, style: Theme.of(context).textTheme.labelLarge), + const SizedBox(height: 8), + LayoutBuilder( + builder: (context, constraints) { + final day = _number( + key: 'birth-day', + controller: _day, + label: l.kycBirthDay, + length: 2, + autofill: AutofillHints.birthdayDay, + ); + final month = DropdownButtonFormField( + key: _monthField, + initialValue: _month, + isExpanded: true, + decoration: InputDecoration(labelText: l.kycBirthMonth), + items: [ + for (var month = 1; month <= 12; month++) + DropdownMenuItem( + value: month, + child: Text(months.format(DateTime(2000, month))), + ), + ], + onChanged: !widget.enabled + ? null + : (value) { + _month = value; + _change(); + }, + ); + final year = _number( + key: 'birth-year', + controller: _year, + label: l.kycBirthYear, + length: 4, + autofill: AutofillHints.birthdayYear, + ); + final scale = MediaQuery.textScalerOf(context).scale(16) / 16; + if (constraints.maxWidth < 400 * scale) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + day, + const SizedBox(height: 16), + month, + const SizedBox(height: 16), + year, + ], + ); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(flex: 2, child: day), + const SizedBox(width: 12), + Expanded(flex: 4, child: month), + const SizedBox(width: 12), + Expanded(flex: 3, child: year), + ], + ); + }, + ), + if (error != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Semantics( + liveRegion: true, + child: Text( + error, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ), + ], + ); + } + + Widget _number({ + required String key, + required TextEditingController controller, + required String label, + required int length, + required String autofill, + }) => TextFormField( + key: ValueKey(key), + controller: controller, + enabled: widget.enabled, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.next, + autofillHints: [autofill], + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(length), + ], + decoration: InputDecoration(labelText: label), + onChanged: (_) => _change(), + ); +} diff --git a/frontend/app/lib/kyc/profile.dart b/frontend/app/lib/kyc/profile.dart index fc28939..557addb 100644 --- a/frontend/app/lib/kyc/profile.dart +++ b/frontend/app/lib/kyc/profile.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:sxchnge_kyc/sxchnge_kyc.dart'; +import 'birth.dart'; import 'country.dart'; import '../generated/i18n/app_localizations.dart'; @@ -37,17 +38,11 @@ class ProfileForm extends StatelessWidget { onChanged: (value) => update(name: value), ), const SizedBox(height: 16), - TextFormField( + BirthField( key: const ValueKey('kyc-birth'), - initialValue: p.profile.dateOfBirth, + value: p.profile.dateOfBirth, enabled: !p.busy, - keyboardType: TextInputType.datetime, - autofillHints: const [AutofillHints.birthday], - decoration: InputDecoration( - labelText: l.kycBirth, - helperText: l.kycBirthHelp, - ), - onChanged: (value) => update(birth: value.trim()), + onChanged: (value) => update(birth: value), ), const SizedBox(height: 16), CountryField( diff --git a/frontend/app/lib/l10n/en.arb b/frontend/app/lib/l10n/en.arb index edfccf6..4c775ab 100644 --- a/frontend/app/lib/l10n/en.arb +++ b/frontend/app/lib/l10n/en.arb @@ -269,7 +269,11 @@ "kycProfileHelp": "Enter your details exactly as shown on your identity document.", "kycFullName": "Full legal name", "kycBirth": "Date of birth", - "kycBirthHelp": "YYYY-MM-DD, for example 1990-04-23", + "kycBirthDay": "Day", + "kycBirthMonth": "Month", + "kycBirthYear": "Year", + "kycBirthInvalid": "Enter a valid date of birth.", + "kycBirthFuture": "Date of birth cannot be in the future.", "kycCountry": "Country of residence", "kycCountrySelect": "Select a country", "kycCountrySearch": "Search countries", diff --git a/frontend/app/lib/l10n/ru.arb b/frontend/app/lib/l10n/ru.arb index 0ec126f..c2c465c 100644 --- a/frontend/app/lib/l10n/ru.arb +++ b/frontend/app/lib/l10n/ru.arb @@ -269,7 +269,11 @@ "kycProfileHelp": "Укажите данные точно как в документе, удостоверяющем личность.", "kycFullName": "Полное имя по документу", "kycBirth": "Дата рождения", - "kycBirthHelp": "ГГГГ-ММ-ДД, например 1990-04-23", + "kycBirthDay": "День", + "kycBirthMonth": "Месяц", + "kycBirthYear": "Год", + "kycBirthInvalid": "Укажите корректную дату рождения.", + "kycBirthFuture": "Дата рождения не может быть в будущем.", "kycCountry": "Страна проживания", "kycCountrySelect": "Выберите страну", "kycCountrySearch": "Поиск страны", diff --git a/frontend/app/test/birth_test.dart b/frontend/app/test/birth_test.dart new file mode 100644 index 0000000..00f4041 --- /dev/null +++ b/frontend/app/test/birth_test.dart @@ -0,0 +1,236 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:sxchnge_theme/sxchnge_theme.dart'; + +import 'package:sxchnge_app/kyc/birth.dart'; + +import 'package:sxchnge_app/generated/i18n/app_localizations.dart'; + +void main() { + final day = find.byKey(const ValueKey('birth-day')); + final year = find.byKey(const ValueKey('birth-year')); + final month = find.byType(DropdownButtonFormField); + + Future mount( + WidgetTester t, + ValueNotifier value, { + Locale locale = const Locale('en'), + bool enabled = true, + }) async { + await t.pumpWidget( + MaterialApp( + theme: SxchngeTheme.light(), + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ValueListenableBuilder( + valueListenable: value, + builder: (_, date, _) => BirthField( + value: date, + enabled: enabled, + onChanged: (date) => value.value = date, + ), + ), + ), + ), + ), + ); + await t.pumpAndSettle(); + } + + Future select(WidgetTester t, String name) async { + await t.ensureVisible(month); + await t.tap(month); + await t.pumpAndSettle(); + await t.tap(find.text(name).last); + await t.pumpAndSettle(); + } + + testWidgets('enters and corrects a birthday without ISO syntax', (t) async { + final value = ValueNotifier(''); + addTearDown(value.dispose); + await mount(t, value); + expect(find.text('Date of birth'), findsOneWidget); + await t.enterText(day, '23'); + await select(t, 'April'); + expect(value.value, ''); + await t.enterText(year, '1990'); + await t.pumpAndSettle(); + expect(value.value, '1990-04-23'); + await t.enterText(day, '9'); + await t.pumpAndSettle(); + expect(value.value, '1990-04-09'); + await t.enterText(year, '19'); + await t.pumpAndSettle(); + expect(value.value, ''); + // A provider rebuild must retain the other components of an incomplete date. + expect(t.widget(day).controller!.text, '9'); + expect(find.text('April'), findsOneWidget); + await t.enterText(year, '1985'); + await t.pumpAndSettle(); + expect(value.value, '1985-04-09'); + }); + + testWidgets('leap dates, impossible days and future dates recover inline', ( + t, + ) async { + final value = ValueNotifier('2000-02-29'); + addTearDown(value.dispose); + await mount(t, value); + expect(find.text('February'), findsOneWidget); + await t.enterText(year, '2001'); + await t.pumpAndSettle(); + expect(value.value, ''); + expect(find.text('Enter a valid date of birth.'), findsOneWidget); + expect(t.widget(day).controller!.text, '29'); + await t.enterText(year, '2004'); + await t.pumpAndSettle(); + expect(value.value, '2004-02-29'); + expect(find.text('Enter a valid date of birth.'), findsNothing); + await t.enterText(day, '31'); + await select(t, 'April'); + expect(value.value, ''); + expect(find.text('Enter a valid date of birth.'), findsOneWidget); + await t.enterText(day, '30'); + await t.enterText(year, '${DateTime.now().year + 1}'); + await t.pumpAndSettle(); + expect(value.value, ''); + expect(find.text('Date of birth cannot be in the future.'), findsOneWidget); + await t.enterText(year, '1990'); + await t.pumpAndSettle(); + expect(value.value, '1990-04-30'); + await t.enterText(day, '0'); + await t.pumpAndSettle(); + expect(value.value, ''); + expect(find.text('Enter a valid date of birth.'), findsOneWidget); + await t.enterText(day, '1'); + await t.enterText(year, '0000'); + await t.pumpAndSettle(); + expect(value.value, ''); + expect(find.text('Enter a valid date of birth.'), findsOneWidget); + }); + + testWidgets( + 'prefill, external replacement, locale change and clear synchronize', + (t) async { + final value = ValueNotifier('1990-04-23'); + addTearDown(value.dispose); + await mount(t, value); + expect(t.widget(year).controller!.text, '1990'); + value.value = '1985-12-09'; + await t.pumpAndSettle(); + expect(t.widget(day).controller!.text, '09'); + expect(t.widget(year).controller!.text, '1985'); + expect(find.text('December'), findsOneWidget); + await mount(t, value, locale: const Locale('ru')); + expect(find.text('Дата рождения'), findsOneWidget); + expect(find.text('декабрь'), findsOneWidget); + expect(value.value, '1985-12-09'); + await select(t, 'апрель'); + expect(value.value, '1985-04-09'); + value.value = ''; + await t.pumpAndSettle(); + expect(t.widget(day).controller!.text, ''); + expect(t.widget(year).controller!.text, ''); + expect(find.text('апрель'), findsNothing); + }, + ); + + testWidgets('digits and length limits keep each component usable', (t) async { + final value = ValueNotifier('1990-04-23'); + addTearDown(value.dispose); + await mount(t, value); + await t.enterText(day, ''); + await t.enterText(day, 'a123'); + await t.enterText(year, ''); + await t.enterText(year, 'b19856'); + await t.pumpAndSettle(); + expect(t.widget(day).controller!.text, '12'); + expect(t.widget(year).controller!.text, '1985'); + expect(value.value, '1985-04-12'); + }); + + testWidgets('busy state disables every birthday control', (t) async { + final value = ValueNotifier('1990-04-23'); + addTearDown(value.dispose); + await mount(t, value, enabled: false); + expect(t.widget(day).enabled, isFalse); + expect(t.widget(year).enabled, isFalse); + expect(t.widget>(month).onChanged, isNull); + expect(value.value, '1990-04-23'); + }); + + testWidgets('keyboard can traverse, choose month, cancel and edit year', ( + t, + ) async { + final value = ValueNotifier('1990-04-23'); + addTearDown(value.dispose); + await mount(t, value); + await t.sendKeyEvent(LogicalKeyboardKey.tab); + await t.pump(); + expect( + t + .widget( + find.descendant(of: day, matching: find.byType(EditableText)), + ) + .focusNode + .hasFocus, + isTrue, + ); + await t.sendKeyEvent(LogicalKeyboardKey.tab); + await t.sendKeyEvent(LogicalKeyboardKey.enter); + await t.pumpAndSettle(); + await t.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await t.sendKeyEvent(LogicalKeyboardKey.enter); + await t.pumpAndSettle(); + expect(value.value, '1990-05-23'); + await t.sendKeyEvent(LogicalKeyboardKey.enter); + await t.pumpAndSettle(); + await t.sendKeyEvent(LogicalKeyboardKey.escape); + await t.pumpAndSettle(); + expect(value.value, '1990-05-23'); + await t.sendKeyEvent(LogicalKeyboardKey.tab); + await t.pump(); + expect( + t + .widget( + find.descendant(of: year, matching: find.byType(EditableText)), + ) + .focusNode + .hasFocus, + isTrue, + ); + }); + + testWidgets( + 'Russian controls and errors fit 320px with large text and labeled targets', + (t) async { + t.view.physicalSize = const Size(320, 900); + t.view.devicePixelRatio = 1; + t.platformDispatcher.textScaleFactorTestValue = 1.6; + addTearDown(t.view.resetPhysicalSize); + addTearDown(t.view.resetDevicePixelRatio); + addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue); + final semantics = t.ensureSemantics(); + final value = ValueNotifier('1990-09-23'); + addTearDown(value.dispose); + await mount(t, value, locale: const Locale('ru')); + expect(find.text('сентябрь'), findsOneWidget); + expect(t, meetsGuideline(labeledTapTargetGuideline)); + expect(t, meetsGuideline(androidTapTargetGuideline)); + await t.enterText(day, '31'); + await t.pumpAndSettle(); + expect(find.text('Укажите корректную дату рождения.'), findsOneWidget); + await select(t, 'март'); + expect(value.value, '1990-03-31'); + expect(t.takeException(), isNull); + semantics.dispose(); + }, + ); +} diff --git a/frontend/app/test/kyc_test.dart b/frontend/app/test/kyc_test.dart index e59275c..172b6ef 100644 --- a/frontend/app/test/kyc_test.dart +++ b/frontend/app/test/kyc_test.dart @@ -11,6 +11,7 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart'; import 'package:sxchnge_kyc/sxchnge_kyc.dart'; +import 'package:sxchnge_app/kyc/birth.dart'; import 'package:sxchnge_app/kyc/country.dart'; import 'package:sxchnge_app/main.dart'; import 'package:sxchnge_app/providers/locale.dart'; @@ -66,10 +67,16 @@ void main() { return service; } - Future submit(WidgetTester t) async { + Future profile(WidgetTester t) async { await tap(t, find.byKey(const ValueKey('kyc-start'))); await t.enterText(find.byKey(const ValueKey('kyc-name')), 'Example Person'); - await t.enterText(find.byKey(const ValueKey('kyc-birth')), '1990-04-23'); + await t.enterText(find.byKey(const ValueKey('birth-day')), '23'); + await tap(t, find.byType(DropdownButtonFormField)); + // The month label follows the selected app locale. + await tap(t, find.textContaining(RegExp(r'^(April|апрель)$')).last); + await t.enterText(find.byKey(const ValueKey('birth-year')), '1990'); + await t.pumpAndSettle(); + expect(t.widget(find.byType(BirthField)).value, '1990-04-23'); await tap( t, find.descendant( @@ -85,6 +92,10 @@ void main() { 'PL', ); await t.pumpAndSettle(); + } + + Future submit(WidgetTester t) async { + await profile(t); await tap(t, find.byKey(const ValueKey('pick-passport'))); await tap(t, find.byKey(const ValueKey('kyc-next'))); await tap(t, find.byKey(const ValueKey('pick-selfie'))); @@ -93,6 +104,41 @@ void main() { await tap(t, find.byKey(const ValueKey('kyc-submit'))); } + testWidgets( + 'invalid birthday blocks advancing and valid birthday survives Back', + (t) async { + await open(t); + await profile(t); + await tap(t, find.byKey(const ValueKey('pick-passport'))); + final next = find.byKey(const ValueKey('kyc-next')); + await t.enterText(find.byKey(const ValueKey('birth-day')), '31'); + await t.pumpAndSettle(); + expect(t.widget(next).onPressed, isNull); + expect(find.text('Enter a valid date of birth.'), findsOneWidget); + await t.enterText(find.byKey(const ValueKey('birth-day')), '23'); + await t.pumpAndSettle(); + expect(t.widget(next).onPressed, isNotNull); + await tap(t, next); + await tap(t, find.text('Back')); + expect(t.widget(find.byType(BirthField)).value, '1990-04-23'); + expect( + t + .widget(find.byKey(const ValueKey('birth-day'))) + .controller! + .text, + '23', + ); + expect(find.text('April'), findsOneWidget); + expect( + t + .widget(find.byKey(const ValueKey('birth-year'))) + .controller! + .text, + '1990', + ); + }, + ); + testWidgets('two photos, review, pending and approval with service limits', ( t, ) async { From 288a2f0c023d6a3b777b60a1adb04cb7e2c73cc2 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Fri, 23 Oct 2026 12:20:01 +0200 Subject: [PATCH 4/4] Record birthday control delivery PR (#67) --- docs/ux/kyc.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/ux/kyc.md b/docs/ux/kyc.md index bdc322a..ef23382 100644 --- a/docs/ux/kyc.md +++ b/docs/ux/kyc.md @@ -215,6 +215,7 @@ remain ignored and uncommitted. ## Birthday entry — task #67 Task [#67](https://git.sendico.io/tech/sxchnge/issues/67), branch `kyc-67`. +Delivery: [PR #70](https://git.sendico.io/tech/sxchnge/pulls/70). This change is stacked on the residence-country selector in PR #65; merge #65 before the birthday PR.