From 27424ed886e6cc3c89185c2455235215ba481147 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Fri, 11 Sep 2026 15:36:58 +0200 Subject: [PATCH] Add shared authentication and email verification services Provide reusable models, injectable service contracts, lifecycle-safe providers and explicit dummy implementations for login, signup and verification. Add focused error and async lifecycle tests. Related to #4 and #5. --- frontend/packages/sxchnge_auth/.gitignore | 3 + frontend/packages/sxchnge_auth/README.md | 63 +++++++ .../sxchnge_auth/lib/src/models/auth.dart | 56 ++++++ .../lib/src/models/verification.dart | 31 +++ .../sxchnge_auth/lib/src/providers/auth.dart | 46 +++++ .../sxchnge_auth/lib/src/providers/login.dart | 18 ++ .../lib/src/providers/signup.dart | 24 +++ .../lib/src/providers/verification.dart | 73 ++++++++ .../lib/src/services/contracts.dart | 11 ++ .../sxchnge_auth/lib/src/services/dummy.dart | 36 ++++ .../lib/src/services/verification.dart | 44 +++++ .../sxchnge_auth/lib/sxchnge_auth.dart | 12 ++ frontend/packages/sxchnge_auth/pubspec.yaml | 13 ++ .../packages/sxchnge_auth/test/auth_test.dart | 177 ++++++++++++++++++ .../sxchnge_auth/test/verification_test.dart | 101 ++++++++++ 15 files changed, 708 insertions(+) create mode 100644 frontend/packages/sxchnge_auth/.gitignore create mode 100644 frontend/packages/sxchnge_auth/README.md create mode 100644 frontend/packages/sxchnge_auth/lib/src/models/auth.dart create mode 100644 frontend/packages/sxchnge_auth/lib/src/models/verification.dart create mode 100644 frontend/packages/sxchnge_auth/lib/src/providers/auth.dart create mode 100644 frontend/packages/sxchnge_auth/lib/src/providers/login.dart create mode 100644 frontend/packages/sxchnge_auth/lib/src/providers/signup.dart create mode 100644 frontend/packages/sxchnge_auth/lib/src/providers/verification.dart create mode 100644 frontend/packages/sxchnge_auth/lib/src/services/contracts.dart create mode 100644 frontend/packages/sxchnge_auth/lib/src/services/dummy.dart create mode 100644 frontend/packages/sxchnge_auth/lib/src/services/verification.dart create mode 100644 frontend/packages/sxchnge_auth/lib/sxchnge_auth.dart create mode 100644 frontend/packages/sxchnge_auth/pubspec.yaml create mode 100644 frontend/packages/sxchnge_auth/test/auth_test.dart create mode 100644 frontend/packages/sxchnge_auth/test/verification_test.dart diff --git a/frontend/packages/sxchnge_auth/.gitignore b/frontend/packages/sxchnge_auth/.gitignore new file mode 100644 index 0000000..61608ef --- /dev/null +++ b/frontend/packages/sxchnge_auth/.gitignore @@ -0,0 +1,3 @@ +.dart_tool/ +build/ +pubspec.lock diff --git a/frontend/packages/sxchnge_auth/README.md b/frontend/packages/sxchnge_auth/README.md new file mode 100644 index 0000000..79eca2b --- /dev/null +++ b/frontend/packages/sxchnge_auth/README.md @@ -0,0 +1,63 @@ +# sxchnge_auth + +Shared Flutter authentication providers and service contracts for web and mobile. Private package, version 0.1.0; Flutter 3.47+ / Dart 3.13+. + +## Install and use + +From `frontend/app` (or a sibling `frontend/mobile` app): + +```yaml +dependencies: + sxchnge_auth: + path: ../packages/sxchnge_auth +``` + +```dart +import 'package:sxchnge_auth/sxchnge_auth.dart'; + +final login = LoginProvider(const DummyLoginService()); +login.addListener(() { + // Render login.state.status, result, or typed error in your own UI. +}); +await login.login( + email: 'demo@example.com', + password: 'example-password', + locale: 'en', +); +login.dispose(); +``` + +`SignupProvider` accepts a `SignupService` and exposes `signup(name:, email:, password:, locale:)`. Both providers extend ChangeNotifier and can be installed with `ChangeNotifierProvider`, ListenableBuilder, or another Flutter state binding. The package itself depends only on Flutter foundation; it contains no widgets, routes, localization, web-only code, network client, or storage. + +## Lifecycle and services + +Providers expose immutable `AuthState`: idle, loading, success, or error. They trim email/name, preserve the password verbatim, pass the locale to services, suppress duplicate submissions while loading, and clear old errors before retrying. Reset invalidates older responses and allows a new request; disposal ignores all later completions. Reset does not cancel remote work. Do not treat a provider reset as undoing a production signup. + +Services return `AuthResult` or throw `AuthException` with a typed `AuthError`. Unexpected exceptions are reduced to `unexpected`; raw errors and credentials are not retained in provider state. Credentials exist only as ephemeral request input. Localize error codes in each consuming application. + +`DummyLoginService` and `DummySignupService` default to 600ms latency and simulated success. They do not validate credentials against an account registry, create accounts, issue tokens, open sessions, send requests, or persist data. Configure errors explicitly: + +```dart +final service = DummyLoginService( + delay: Duration.zero, + failure: AuthError.invalidCredentials, +); +``` + +Supported failure codes: `invalidCredentials`, `emailInUse`, `unavailable`, and `unexpected`. Inject another implementation for deterministic retry scenarios or production integration. `AuthResult.isSimulated` distinguishes dummy responses; a successful operation alone must never be treated as an authorization decision. The package does not implement authenticated application/session state yet. + +## Sendico reuse + +Adapted the ChangeNotifier lifecycle, service separation, locale-carrying request, and typed-outcome patterns from Sendico's `pshared/lib/provider/account.dart`, `models/auth/login_outcome.dart`, and `service/authorization/client.dart`. These were patterns rather than directly portable implementations: the originals depend on Sendico account/organization models, device IDs, static API services, token storage, and verification flows. Those dependencies are deliberately absent here. The B2B organization signup contract is not copied into the consumer product. + +When a supported sxchnge BFF contract exists, implement transport DTOs and mappers behind the service interfaces, returning domain results. No dummy DTO/mapping layer is needed without serialized transport. Keep web cookie and mobile credential handling in production platform adapters, not the providers. + +## Validation + +`flutter analyze` and `flutter test` from this directory; `make test-frontend` from the repository root also runs all consuming app and theme tests. Tests cover duplicate submission, request forwarding, typed/unexpected failures, retry, reset races, disposal, and both dummy implementations. + +## Email verification + +`VerificationService` exposes `send(email, locale)` and `confirm(token)` as named-argument methods. Inject an implementation into `VerificationProvider`, shared by web and mobile. The provider exposes immutable `VerificationState` (idle/sending/sent/checking/verified/expired/invalid/error), suppresses concurrent duplicate operations, and ignores completions after reset or disposal. Typed `VerificationException` maps confirmation failures; unknown exceptions become internal errors without exposing exception text. Sending failures always become the general error state. + +`DummyVerificationService` supports `demo-valid`, `demo-expired`, and `demo-internal` confirmation tokens; other tokens are invalid. Configure `delay` and `sendFailure` for tests. Results are explicitly simulated. It performs no network, storage, mail delivery, or account mutation. A production adapter must implement backend validation, delivery, rate limits, and safe token handling; this client provider is not an authorization boundary. diff --git a/frontend/packages/sxchnge_auth/lib/src/models/auth.dart b/frontend/packages/sxchnge_auth/lib/src/models/auth.dart new file mode 100644 index 0000000..8c88b15 --- /dev/null +++ b/frontend/packages/sxchnge_auth/lib/src/models/auth.dart @@ -0,0 +1,56 @@ +/// Ephemeral input: never persist, log, or include passwords in provider state. +class LoginRequest { + const LoginRequest({ + required this.email, + required this.password, + required this.locale, + }); + final String email; + final String password; + final String locale; +} + +class SignupRequest { + const SignupRequest({ + required this.name, + required this.email, + required this.password, + required this.locale, + }); + final String name; + final String email; + final String password; + final String locale; +} + +/// An operation result, not proof of a session or an authorization decision. +class AuthResult { + const AuthResult({required this.userId, required this.isSimulated}); + final String userId; + final bool isSimulated; +} + +enum AuthError { invalidCredentials, emailInUse, unavailable, unexpected } + +class AuthException implements Exception { + const AuthException(this.code); + final AuthError code; + @override + String toString() => 'AuthException(${code.name})'; +} + +enum AuthStatus { idle, loading, success, error } + +class AuthState { + const AuthState._(this.status, {this.result, this.error}); + const AuthState.idle() : this._(AuthStatus.idle); + const AuthState.loading() : this._(AuthStatus.loading); + const AuthState.success(AuthResult result) + : this._(AuthStatus.success, result: result); + const AuthState.failure(AuthError error) + : this._(AuthStatus.error, error: error); + final AuthStatus status; + final AuthResult? result; + final AuthError? error; + bool get isLoading => status == AuthStatus.loading; +} diff --git a/frontend/packages/sxchnge_auth/lib/src/models/verification.dart b/frontend/packages/sxchnge_auth/lib/src/models/verification.dart new file mode 100644 index 0000000..e0d8fbb --- /dev/null +++ b/frontend/packages/sxchnge_auth/lib/src/models/verification.dart @@ -0,0 +1,31 @@ +enum VerificationStatus { + idle, + sending, + sent, + checking, + verified, + expired, + invalid, + error, +} + +enum VerificationError { expired, invalid, internal } + +class VerificationException implements Exception { + const VerificationException(this.code); + final VerificationError code; +} + +class VerificationResult { + const VerificationResult({required this.isSimulated}); + final bool isSimulated; +} + +class VerificationState { + const VerificationState(this.status, {this.isSimulated = false}); + final VerificationStatus status; + final bool isSimulated; + bool get isLoading => + status == VerificationStatus.sending || + status == VerificationStatus.checking; +} diff --git a/frontend/packages/sxchnge_auth/lib/src/providers/auth.dart b/frontend/packages/sxchnge_auth/lib/src/providers/auth.dart new file mode 100644 index 0000000..ec0c1c8 --- /dev/null +++ b/frontend/packages/sxchnge_auth/lib/src/providers/auth.dart @@ -0,0 +1,46 @@ +import 'package:flutter/foundation.dart'; + +import '../models/auth.dart'; + +/// Shared operation lifecycle adapted from Sendico's ChangeNotifier providers. +/// Reset invalidates pending results; it does not cancel service-side work. +abstract class AuthProvider extends ChangeNotifier { + AuthState _state = const AuthState.idle(); + int _generation = 0; + bool _disposed = false; + AuthState get state => _state; + + @protected + Future run(Future Function() operation) async { + if (_disposed || _state.isLoading) return; + final generation = ++_generation; + _state = const AuthState.loading(); + notifyListeners(); + try { + final result = await operation(); + if (_disposed || generation != _generation) return; + _state = AuthState.success(result); + } catch (error) { + if (_disposed || generation != _generation) return; + // Never retain or expose arbitrary exceptions that may contain credentials. + _state = AuthState.failure( + error is AuthException ? error.code : AuthError.unexpected, + ); + } + if (!_disposed && generation == _generation) notifyListeners(); + } + + void reset() { + if (_disposed) return; + ++_generation; + _state = const AuthState.idle(); + notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + ++_generation; + super.dispose(); + } +} diff --git a/frontend/packages/sxchnge_auth/lib/src/providers/login.dart b/frontend/packages/sxchnge_auth/lib/src/providers/login.dart new file mode 100644 index 0000000..2ac3325 --- /dev/null +++ b/frontend/packages/sxchnge_auth/lib/src/providers/login.dart @@ -0,0 +1,18 @@ +import '../models/auth.dart'; +import '../services/contracts.dart'; +import 'auth.dart'; + +class LoginProvider extends AuthProvider { + LoginProvider(this.service); + final LoginService service; + + Future login({ + required String email, + required String password, + required String locale, + }) => run( + () => service.login( + LoginRequest(email: email.trim(), password: password, locale: locale), + ), + ); +} diff --git a/frontend/packages/sxchnge_auth/lib/src/providers/signup.dart b/frontend/packages/sxchnge_auth/lib/src/providers/signup.dart new file mode 100644 index 0000000..ff85679 --- /dev/null +++ b/frontend/packages/sxchnge_auth/lib/src/providers/signup.dart @@ -0,0 +1,24 @@ +import '../models/auth.dart'; +import '../services/contracts.dart'; +import 'auth.dart'; + +class SignupProvider extends AuthProvider { + SignupProvider(this.service); + final SignupService service; + + Future signup({ + required String name, + required String email, + required String password, + required String locale, + }) => run( + () => service.signup( + SignupRequest( + name: name.trim(), + email: email.trim(), + password: password, + locale: locale, + ), + ), + ); +} diff --git a/frontend/packages/sxchnge_auth/lib/src/providers/verification.dart b/frontend/packages/sxchnge_auth/lib/src/providers/verification.dart new file mode 100644 index 0000000..fa37f13 --- /dev/null +++ b/frontend/packages/sxchnge_auth/lib/src/providers/verification.dart @@ -0,0 +1,73 @@ +import 'package:flutter/foundation.dart'; + +import '../models/verification.dart'; +import '../services/verification.dart'; + +class VerificationProvider extends ChangeNotifier { + VerificationProvider(this.service); + final VerificationService service; + VerificationState _state = const VerificationState(VerificationStatus.idle); + VerificationState get state => _state; + bool _disposed = false; + int _generation = 0; + + Future send({required String email, required String locale}) => _run( + () => service.send(email: email.trim(), locale: locale), + VerificationStatus.sending, + VerificationStatus.sent, + ); + Future confirm(String token) => _run( + () => token.trim().isEmpty + ? Future.error(const VerificationException(VerificationError.invalid)) + : service.confirm(token: token), + VerificationStatus.checking, + VerificationStatus.verified, + ); + + Future _run( + Future Function() operation, + VerificationStatus loading, + VerificationStatus success, + ) async { + if (_disposed || _state.isLoading) return; + final generation = ++_generation; + _state = VerificationState(loading); + notifyListeners(); + if (_disposed || generation != _generation) return; + try { + final result = await operation(); + if (_disposed || generation != _generation) return; + _state = VerificationState(success, isSimulated: result.isSimulated); + } catch (error) { + if (_disposed || generation != _generation) return; + final code = error is VerificationException + ? error.code + : VerificationError.internal; + // Send failures must not be presented as confirmation-token failures. + _state = VerificationState( + loading == VerificationStatus.sending + ? VerificationStatus.error + : switch (code) { + VerificationError.expired => VerificationStatus.expired, + VerificationError.invalid => VerificationStatus.invalid, + VerificationError.internal => VerificationStatus.error, + }, + ); + } + notifyListeners(); + } + + void reset() { + if (_disposed) return; + ++_generation; + _state = const VerificationState(VerificationStatus.idle); + notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + ++_generation; + super.dispose(); + } +} diff --git a/frontend/packages/sxchnge_auth/lib/src/services/contracts.dart b/frontend/packages/sxchnge_auth/lib/src/services/contracts.dart new file mode 100644 index 0000000..37abfc9 --- /dev/null +++ b/frontend/packages/sxchnge_auth/lib/src/services/contracts.dart @@ -0,0 +1,11 @@ +import '../models/auth.dart'; + +/// Production adapters own transport, DTO mapping, and authentication effects. +/// Providers and widgets only consume domain results and typed failures. +abstract interface class LoginService { + Future login(LoginRequest request); +} + +abstract interface class SignupService { + Future signup(SignupRequest request); +} diff --git a/frontend/packages/sxchnge_auth/lib/src/services/dummy.dart b/frontend/packages/sxchnge_auth/lib/src/services/dummy.dart new file mode 100644 index 0000000..9b9ab82 --- /dev/null +++ b/frontend/packages/sxchnge_auth/lib/src/services/dummy.dart @@ -0,0 +1,36 @@ +import '../models/auth.dart'; +import 'contracts.dart'; + +/// Deterministic local simulation. No network, storage, credentials or sessions +/// are retained. Configure failures explicitly rather than using magic emails. +class DummyLoginService implements LoginService { + const DummyLoginService({ + this.delay = const Duration(milliseconds: 600), + this.failure, + }); + final Duration delay; + final AuthError? failure; + + @override + Future login(LoginRequest request) async { + await Future.delayed(delay); + if (failure case final error?) throw AuthException(error); + return const AuthResult(userId: 'demo-user', isSimulated: true); + } +} + +class DummySignupService implements SignupService { + const DummySignupService({ + this.delay = const Duration(milliseconds: 600), + this.failure, + }); + final Duration delay; + final AuthError? failure; + + @override + Future signup(SignupRequest request) async { + await Future.delayed(delay); + if (failure case final error?) throw AuthException(error); + return const AuthResult(userId: 'demo-user', isSimulated: true); + } +} diff --git a/frontend/packages/sxchnge_auth/lib/src/services/verification.dart b/frontend/packages/sxchnge_auth/lib/src/services/verification.dart new file mode 100644 index 0000000..d5e55e5 --- /dev/null +++ b/frontend/packages/sxchnge_auth/lib/src/services/verification.dart @@ -0,0 +1,44 @@ +import '../models/verification.dart'; + +abstract interface class VerificationService { + Future send({ + required String email, + required String locale, + }); + Future confirm({required String token}); +} + +/// Explicit preview tokens only. Never use this implementation in production. +class DummyVerificationService implements VerificationService { + const DummyVerificationService({ + this.delay = const Duration(milliseconds: 600), + this.sendFailure, + }); + final Duration delay; + final VerificationError? sendFailure; + + @override + Future send({ + required String email, + required String locale, + }) async { + await Future.delayed(delay); + if (sendFailure case final error?) throw VerificationException(error); + return const VerificationResult(isSimulated: true); + } + + @override + Future confirm({required String token}) async { + await Future.delayed(delay); + switch (token) { + case 'demo-valid': + return const VerificationResult(isSimulated: true); + case 'demo-expired': + throw const VerificationException(VerificationError.expired); + case 'demo-internal': + throw const VerificationException(VerificationError.internal); + default: + throw const VerificationException(VerificationError.invalid); + } + } +} diff --git a/frontend/packages/sxchnge_auth/lib/sxchnge_auth.dart b/frontend/packages/sxchnge_auth/lib/sxchnge_auth.dart new file mode 100644 index 0000000..d669ca0 --- /dev/null +++ b/frontend/packages/sxchnge_auth/lib/sxchnge_auth.dart @@ -0,0 +1,12 @@ +/// Platform-independent authentication state and injectable services. +library; + +export 'src/models/auth.dart'; +export 'src/providers/auth.dart'; +export 'src/providers/login.dart'; +export 'src/providers/signup.dart'; +export 'src/services/contracts.dart'; +export 'src/services/dummy.dart'; +export 'src/models/verification.dart'; +export 'src/providers/verification.dart'; +export 'src/services/verification.dart'; diff --git a/frontend/packages/sxchnge_auth/pubspec.yaml b/frontend/packages/sxchnge_auth/pubspec.yaml new file mode 100644 index 0000000..ec4dc61 --- /dev/null +++ b/frontend/packages/sxchnge_auth/pubspec.yaml @@ -0,0 +1,13 @@ +name: sxchnge_auth +description: Shared authentication providers and service contracts for SXCHNGE Flutter clients. +version: 0.1.0 +publish_to: none +environment: + sdk: '>=3.13.0 <4.0.0' + flutter: '>=3.47.0' +dependencies: + flutter: + sdk: flutter +dev_dependencies: + flutter_test: + sdk: flutter diff --git a/frontend/packages/sxchnge_auth/test/auth_test.dart b/frontend/packages/sxchnge_auth/test/auth_test.dart new file mode 100644 index 0000000..f8ea695 --- /dev/null +++ b/frontend/packages/sxchnge_auth/test/auth_test.dart @@ -0,0 +1,177 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:sxchnge_auth/sxchnge_auth.dart'; + +class ControlledService implements LoginService, SignupService { + final calls = >[]; + LoginRequest? loginInput; + SignupRequest? signupInput; + Future next() { + final call = Completer(); + calls.add(call); + return call.future; + } + + @override + Future login(LoginRequest request) { + loginInput = request; + return next(); + } + + @override + Future signup(SignupRequest request) { + signupInput = request; + return next(); + } +} + +void main() { + const result = AuthResult(userId: 'demo', isSimulated: true); + for (final signup in [false, true]) { + group(signup ? 'signup' : 'login', () { + late ControlledService service; + late AuthProvider provider; + Future submit() => signup + ? (provider as SignupProvider).signup( + name: ' Alex ', + email: ' a@example.com ', + password: ' secret ', + locale: 'ru', + ) + : (provider as LoginProvider).login( + email: ' a@example.com ', + password: ' secret ', + locale: 'ru', + ); + setUp(() { + service = ControlledService(); + provider = signup ? SignupProvider(service) : LoginProvider(service); + }); + test( + 'loading, duplicate suppression, result and normalized input', + () async { + final states = []; + provider.addListener(() => states.add(provider.state.status)); + final first = submit(); + await submit(); + expect(service.calls, hasLength(1)); + expect(provider.state.isLoading, isTrue); + if (signup) { + expect(service.signupInput!.name, 'Alex'); + expect(service.signupInput!.email, 'a@example.com'); + expect(service.signupInput!.password, ' secret '); + expect(service.signupInput!.locale, 'ru'); + } else { + expect(service.loginInput!.email, 'a@example.com'); + expect(service.loginInput!.password, ' secret '); + expect(service.loginInput!.locale, 'ru'); + } + service.calls.single.complete(result); + await first; + expect(states, [AuthStatus.loading, AuthStatus.success]); + expect(provider.state.result, same(result)); + provider.dispose(); + }, + ); + test('typed failure and retry clear previous error', () async { + final first = submit(); + service.calls.last.completeError( + const AuthException(AuthError.unavailable), + ); + await first; + expect(provider.state.error, AuthError.unavailable); + final retry = submit(); + expect(provider.state.error, isNull); + service.calls.last.complete(result); + await retry; + expect(provider.state.status, AuthStatus.success); + provider.dispose(); + }); + test('unknown exceptions are sanitized', () async { + final first = submit(); + service.calls.last.completeError(StateError('secret password')); + await first; + expect(provider.state.error, AuthError.unexpected); + expect(provider.state.result, isNull); + provider.dispose(); + }); + test('reset rejects stale completion during a new operation', () async { + final first = submit(); + provider.reset(); + expect(provider.state.status, AuthStatus.idle); + final second = submit(); + service.calls.first.complete(result); + await first; + expect(provider.state.status, AuthStatus.loading); + service.calls.last.complete(result); + await second; + expect(provider.state.status, AuthStatus.success); + provider.dispose(); + }); + test('dispose ignores late failures and further submissions', () async { + var notifications = 0; + provider.addListener(() => notifications++); + final pending = submit(); + provider.dispose(); + service.calls.last.completeError( + const AuthException(AuthError.unavailable), + ); + await pending; + await submit(); + expect(notifications, 1); + expect(service.calls, hasLength(1)); + }); + }); + } + test('dummy services simulate success and configured errors', () async { + const login = LoginRequest( + email: 'a@example.com', + password: 'secret', + locale: 'en', + ); + const signup = SignupRequest( + name: 'Alex', + email: 'a@example.com', + password: 'secret', + locale: 'ru', + ); + expect( + (await const DummyLoginService(delay: Duration.zero).login(login)) + .isSimulated, + isTrue, + ); + expect( + (await const DummySignupService(delay: Duration.zero).signup(signup)) + .isSimulated, + isTrue, + ); + await expectLater( + const DummyLoginService( + delay: Duration.zero, + failure: AuthError.invalidCredentials, + ).login(login), + throwsA( + isA().having( + (e) => e.code, + 'code', + AuthError.invalidCredentials, + ), + ), + ); + await expectLater( + const DummySignupService( + delay: Duration.zero, + failure: AuthError.emailInUse, + ).signup(signup), + throwsA( + isA().having( + (e) => e.code, + 'code', + AuthError.emailInUse, + ), + ), + ); + }); +} diff --git a/frontend/packages/sxchnge_auth/test/verification_test.dart b/frontend/packages/sxchnge_auth/test/verification_test.dart new file mode 100644 index 0000000..354a639 --- /dev/null +++ b/frontend/packages/sxchnge_auth/test/verification_test.dart @@ -0,0 +1,101 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:sxchnge_auth/sxchnge_auth.dart'; + +class PendingService implements VerificationService { + final pending = Completer(); + int calls = 0; + String? email; + String? locale; + @override + Future send({ + required String email, + required String locale, + }) { + calls++; + this.email = email; + this.locale = locale; + return pending.future; + } + + @override + Future confirm({required String token}) { + calls++; + return pending.future; + } +} + +void main() { + for (final scenario in { + 'demo-valid': VerificationStatus.verified, + 'demo-expired': VerificationStatus.expired, + 'bad': VerificationStatus.invalid, + '': VerificationStatus.invalid, + 'demo-internal': VerificationStatus.error, + }.entries) { + test('confirmation maps ${scenario.key} to ${scenario.value}', () async { + final provider = VerificationProvider( + const DummyVerificationService(delay: Duration.zero), + ); + addTearDown(provider.dispose); + await provider.confirm(scenario.key); + expect(provider.state.status, scenario.value); + expect( + provider.state.isSimulated, + scenario.value == VerificationStatus.verified, + ); + }); + } + test( + 'send trims email, forwards locale and suppresses duplicate requests', + () async { + final service = PendingService(); + final provider = VerificationProvider(service); + addTearDown(provider.dispose); + final first = provider.send(email: ' alex@example.com ', locale: 'ru'); + expect(provider.state.status, VerificationStatus.sending); + await provider.send(email: 'other@example.com', locale: 'en'); + expect(service.calls, 1); + expect(service.email, 'alex@example.com'); + expect(service.locale, 'ru'); + service.pending.complete(const VerificationResult(isSimulated: true)); + await first; + expect(provider.state.status, VerificationStatus.sent); + expect(provider.state.isSimulated, isTrue); + }, + ); + test('reset discards late result', () async { + final service = PendingService(); + final provider = VerificationProvider(service); + addTearDown(provider.dispose); + final operation = provider.confirm('token'); + provider.reset(); + service.pending.complete(const VerificationResult(isSimulated: true)); + await operation; + expect(provider.state.status, VerificationStatus.idle); + }); + test('dispose discards late exception', () async { + final service = PendingService(); + final provider = VerificationProvider(service); + final operation = provider.confirm('token'); + provider.dispose(); + service.pending.completeError(StateError('private error')); + await operation; + }); + test( + 'send errors are internal errors even for token exception types', + () async { + final provider = VerificationProvider( + const DummyVerificationService( + delay: Duration.zero, + sendFailure: VerificationError.expired, + ), + ); + addTearDown(provider.dispose); + await provider.send(email: 'alex@example.com', locale: 'en'); + expect(provider.state.status, VerificationStatus.error); + }, + ); +} -- 2.54.0