diff --git a/docs/ux/auth-journeys.md b/docs/ux/auth-journeys.md index 083e5fb..954f9ab 100644 --- a/docs/ux/auth-journeys.md +++ b/docs/ux/auth-journeys.md @@ -296,3 +296,66 @@ 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`, 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 +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,