Add reusable authentication and email verification services #8
No files matched your search
@@ -0,0 +1,3 @@
|
||||
.dart_tool/
|
||||
build/
|
||||
pubspec.lock
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<void> run(Future<AuthResult> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<void> login({
|
||||
required String email,
|
||||
required String password,
|
||||
required String locale,
|
||||
}) => run(
|
||||
() => service.login(
|
||||
LoginRequest(email: email.trim(), password: password, locale: locale),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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<void> 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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<void> send({required String email, required String locale}) => _run(
|
||||
() => service.send(email: email.trim(), locale: locale),
|
||||
VerificationStatus.sending,
|
||||
VerificationStatus.sent,
|
||||
);
|
||||
Future<void> confirm(String token) => _run(
|
||||
() => token.trim().isEmpty
|
||||
? Future.error(const VerificationException(VerificationError.invalid))
|
||||
: service.confirm(token: token),
|
||||
VerificationStatus.checking,
|
||||
VerificationStatus.verified,
|
||||
);
|
||||
|
||||
Future<void> _run(
|
||||
Future<VerificationResult> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<AuthResult> login(LoginRequest request);
|
||||
}
|
||||
|
||||
abstract interface class SignupService {
|
||||
Future<AuthResult> signup(SignupRequest request);
|
||||
}
|
||||
@@ -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<AuthResult> login(LoginRequest request) async {
|
||||
await Future<void>.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<AuthResult> signup(SignupRequest request) async {
|
||||
await Future<void>.delayed(delay);
|
||||
if (failure case final error?) throw AuthException(error);
|
||||
return const AuthResult(userId: 'demo-user', isSimulated: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import '../models/verification.dart';
|
||||
|
||||
abstract interface class VerificationService {
|
||||
Future<VerificationResult> send({
|
||||
required String email,
|
||||
required String locale,
|
||||
});
|
||||
Future<VerificationResult> 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<VerificationResult> send({
|
||||
required String email,
|
||||
required String locale,
|
||||
}) async {
|
||||
await Future<void>.delayed(delay);
|
||||
if (sendFailure case final error?) throw VerificationException(error);
|
||||
return const VerificationResult(isSimulated: true);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<VerificationResult> confirm({required String token}) async {
|
||||
await Future<void>.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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
|
||||
@@ -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 = <Completer<AuthResult>>[];
|
||||
LoginRequest? loginInput;
|
||||
SignupRequest? signupInput;
|
||||
Future<AuthResult> next() {
|
||||
final call = Completer<AuthResult>();
|
||||
calls.add(call);
|
||||
return call.future;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AuthResult> login(LoginRequest request) {
|
||||
loginInput = request;
|
||||
return next();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AuthResult> 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<void> 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 = <AuthStatus>[];
|
||||
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<AuthException>().having(
|
||||
(e) => e.code,
|
||||
'code',
|
||||
AuthError.invalidCredentials,
|
||||
),
|
||||
),
|
||||
);
|
||||
await expectLater(
|
||||
const DummySignupService(
|
||||
delay: Duration.zero,
|
||||
failure: AuthError.emailInUse,
|
||||
).signup(signup),
|
||||
throwsA(
|
||||
isA<AuthException>().having(
|
||||
(e) => e.code,
|
||||
'code',
|
||||
AuthError.emailInUse,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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<VerificationResult>();
|
||||
int calls = 0;
|
||||
String? email;
|
||||
String? locale;
|
||||
@override
|
||||
Future<VerificationResult> send({
|
||||
required String email,
|
||||
required String locale,
|
||||
}) {
|
||||
calls++;
|
||||
this.email = email;
|
||||
this.locale = locale;
|
||||
return pending.future;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<VerificationResult> 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user