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.
This commit is contained in:
1 parent
d9318053e8
commit
27424ed886
15 files changed
+708
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