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,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