Connect sign-up to Sendico Facts intake (#46)
This commit is contained in:
1 parent
30f0455122
commit
a584c8189a
40 files changed
+1616
-35
No files matched your search
@@ -22,6 +22,7 @@ class AuthFeedback extends StatelessWidget {
|
||||
AuthError.accountNotVerified => l.authVerifyEmail,
|
||||
AuthError.invalidCredentials => l.authInvalidCredentials,
|
||||
AuthError.emailInUse => l.authEmailInUse,
|
||||
AuthError.signupNeedsAssistance => l.authSignupNeedsAssistance,
|
||||
AuthError.unavailable => l.authUnavailable,
|
||||
AuthError.unexpected => l.authUnexpected,
|
||||
AuthError.factorLocked => l.factorLocked,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"@@locale": "en",
|
||||
"authSignupNeedsAssistance": "We could not finish setting up your account. Please contact support.",
|
||||
"appTitle": "SXCHNGE · Your next move",
|
||||
"nextChapter": "YOUR NEXT CHAPTER",
|
||||
"loginHeading": "Hey, welcome\nback.",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"@@locale": "ru",
|
||||
"authSignupNeedsAssistance": "Не удалось завершить создание аккаунта. Обратитесь в службу поддержки.",
|
||||
"appTitle": "SXCHNGE · Ваш следующий шаг",
|
||||
"nextChapter": "НОВАЯ ГЛАВА",
|
||||
"loginHeading": "Рады видеть\nвас снова.",
|
||||
|
||||
@@ -122,8 +122,10 @@ class _VerificationPageState extends State<VerificationPage> {
|
||||
(_provider.service as SimulationInfo).isSimulated) ||
|
||||
state.isSimulated;
|
||||
final sent = status == VerificationStatus.sent;
|
||||
final needsAssistance = state.error == VerificationError.needsAssistance;
|
||||
final retryConfirmation =
|
||||
status == VerificationStatus.error &&
|
||||
!needsAssistance &&
|
||||
!_lastWasSend &&
|
||||
widget.token != null;
|
||||
final title = switch (status) {
|
||||
@@ -139,14 +141,16 @@ class _VerificationPageState extends State<VerificationPage> {
|
||||
? l.verificationChecking
|
||||
: l.verificationRequest,
|
||||
};
|
||||
final body = switch (status) {
|
||||
VerificationStatus.sent => l.verificationSent,
|
||||
VerificationStatus.verified => l.verificationVerifiedBody,
|
||||
VerificationStatus.expired => l.verificationExpiredBody,
|
||||
VerificationStatus.invalid => l.verificationInvalidBody,
|
||||
VerificationStatus.error => l.verificationInternalBody,
|
||||
_ => null,
|
||||
};
|
||||
final body = needsAssistance
|
||||
? l.authSignupNeedsAssistance
|
||||
: switch (status) {
|
||||
VerificationStatus.sent => l.verificationSent,
|
||||
VerificationStatus.verified => l.verificationVerifiedBody,
|
||||
VerificationStatus.expired => l.verificationExpiredBody,
|
||||
VerificationStatus.invalid => l.verificationInvalidBody,
|
||||
VerificationStatus.error => l.verificationInternalBody,
|
||||
_ => null,
|
||||
};
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
|
||||
@@ -6,6 +6,8 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||
|
||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||
|
||||
import 'package:sxchnge_app/main.dart';
|
||||
@@ -41,6 +43,84 @@ class UnverifiedBackend extends BackendAuthServices {
|
||||
}
|
||||
|
||||
void main() {
|
||||
for (final language in ['en', 'ru']) {
|
||||
testWidgets(
|
||||
'signup requiring assistance preserves input and records failure ($language)',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.platformDispatcher.textScaleFactorTestValue = 1.5;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||
final analytics = DummyAnalyticsService();
|
||||
final locale = LocaleProvider(initialLocale: Locale(language));
|
||||
await tester.pumpWidget(
|
||||
SxchngeApp(
|
||||
localeProvider: locale,
|
||||
analyticsService: analytics,
|
||||
signupService: const DummySignupService(
|
||||
delay: Duration.zero,
|
||||
failure: AuthError.signupNeedsAssistance,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final signupLabel = language == 'ru' ? 'Регистрация' : 'Sign up';
|
||||
await tester.ensureVisible(find.text(signupLabel));
|
||||
await tester.tap(find.text(signupLabel));
|
||||
await tester.pumpAndSettle();
|
||||
for (final entry in {
|
||||
'name': 'Ada Lovelace',
|
||||
'email': 'ada@example.com',
|
||||
'password': 'password preserved',
|
||||
'confirmation': 'password preserved',
|
||||
}.entries) {
|
||||
await tester.enterText(find.byKey(ValueKey(entry.key)), entry.value);
|
||||
}
|
||||
await tester.ensureVisible(find.byKey(const ValueKey('submit')));
|
||||
await tester.tap(find.byKey(const ValueKey('submit')));
|
||||
await tester.pumpAndSettle();
|
||||
final feedback = find.byKey(const ValueKey('auth-error'));
|
||||
await tester.ensureVisible(feedback);
|
||||
expect(
|
||||
find.text(
|
||||
language == 'ru'
|
||||
? 'Не удалось завершить создание аккаунта. Обратитесь в службу поддержки.'
|
||||
: 'We could not finish setting up your account. Please contact support.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byKey(const ValueKey('verification-sent')), findsNothing);
|
||||
expect(
|
||||
tester
|
||||
.widget<TextFormField>(find.byKey(const ValueKey('email')))
|
||||
.controller!
|
||||
.text,
|
||||
'ada@example.com',
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<TextFormField>(find.byKey(const ValueKey('password')))
|
||||
.controller!
|
||||
.text,
|
||||
'password preserved',
|
||||
);
|
||||
expect(
|
||||
analytics.events.where((event) => event.name == 'auth_signup_failed'),
|
||||
hasLength(1),
|
||||
);
|
||||
expect(
|
||||
analytics.events.where(
|
||||
(event) => event.name == 'auth_signup_succeeded',
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('backend mode gates workspace and recovers unverified login', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -42,6 +42,37 @@ Future<void> openVerification(
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('resend assistance retains email and does not claim delivery', (
|
||||
tester,
|
||||
) async {
|
||||
await openVerification(
|
||||
tester,
|
||||
'/verification-sent',
|
||||
service: const DummyVerificationService(
|
||||
delay: Duration.zero,
|
||||
sendFailure: VerificationError.needsAssistance,
|
||||
),
|
||||
);
|
||||
final email = find.byKey(const ValueKey('verification-email'));
|
||||
await tester.enterText(email, 'ada@example.com');
|
||||
await tester.ensureVisible(
|
||||
find.byKey(const ValueKey('verification-resend')),
|
||||
);
|
||||
await tester.tap(find.byKey(const ValueKey('verification-resend')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.text(
|
||||
'We could not finish setting up your account. Please contact support.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byKey(const ValueKey('verification-sent')), findsNothing);
|
||||
expect(
|
||||
tester.widget<TextFormField>(email).controller!.text,
|
||||
'ada@example.com',
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
for (final scenario in {
|
||||
'demo-valid': 'verified',
|
||||
'demo-expired': 'expired',
|
||||
|
||||
@@ -2,6 +2,7 @@ import '../dto/backend.dart';
|
||||
import '../models/auth.dart';
|
||||
import '../models/session.dart';
|
||||
import '../models/two_factor.dart';
|
||||
import '../models/verification.dart';
|
||||
|
||||
abstract final class BackendMapper {
|
||||
static AuthResult signup(BackendDTO dto) {
|
||||
@@ -33,11 +34,19 @@ abstract final class BackendMapper {
|
||||
static AuthException authFailure(int status, BackendDTO dto) =>
|
||||
AuthException(switch (dto.error) {
|
||||
'email_in_use' => AuthError.emailInUse,
|
||||
'signup_attention_required' => AuthError.signupNeedsAssistance,
|
||||
'account_not_verified' => AuthError.accountNotVerified,
|
||||
_ when status == 401 => AuthError.invalidCredentials,
|
||||
_ when status == 429 => AuthError.factorCooldown,
|
||||
_ => AuthError.unavailable,
|
||||
});
|
||||
static VerificationException verificationFailure(BackendDTO dto) =>
|
||||
VerificationException(switch (dto.error) {
|
||||
'expired' => VerificationError.expired,
|
||||
'invalid' => VerificationError.invalid,
|
||||
'signup_attention_required' => VerificationError.needsAssistance,
|
||||
_ => VerificationError.internal,
|
||||
});
|
||||
static FactorException factorFailure(int status, BackendDTO dto) =>
|
||||
FactorException(switch (dto.error) {
|
||||
'expired' => FactorError.expired,
|
||||
|
||||
@@ -41,6 +41,7 @@ enum AuthError {
|
||||
invalidCredentials,
|
||||
accountNotVerified,
|
||||
emailInUse,
|
||||
signupNeedsAssistance,
|
||||
unavailable,
|
||||
unexpected,
|
||||
factorLocked,
|
||||
|
||||
@@ -9,7 +9,7 @@ enum VerificationStatus {
|
||||
error,
|
||||
}
|
||||
|
||||
enum VerificationError { expired, invalid, internal }
|
||||
enum VerificationError { expired, invalid, internal, needsAssistance }
|
||||
|
||||
class VerificationException implements Exception {
|
||||
const VerificationException(this.code);
|
||||
@@ -22,8 +22,9 @@ class VerificationResult {
|
||||
}
|
||||
|
||||
class VerificationState {
|
||||
const VerificationState(this.status, {this.isSimulated = false});
|
||||
const VerificationState(this.status, {this.isSimulated = false, this.error});
|
||||
final VerificationStatus status;
|
||||
final VerificationError? error;
|
||||
final bool isSimulated;
|
||||
bool get isLoading =>
|
||||
status == VerificationStatus.sending ||
|
||||
|
||||
@@ -49,7 +49,9 @@ class VerificationProvider extends ChangeNotifier {
|
||||
VerificationError.expired => VerificationStatus.expired,
|
||||
VerificationError.invalid => VerificationStatus.invalid,
|
||||
VerificationError.internal => VerificationStatus.error,
|
||||
VerificationError.needsAssistance => VerificationStatus.error,
|
||||
},
|
||||
error: code,
|
||||
);
|
||||
}
|
||||
notifyListeners();
|
||||
|
||||
@@ -63,6 +63,7 @@ class BackendAuthServices
|
||||
String path,
|
||||
Map<String, Object?> body, {
|
||||
bool factor = false,
|
||||
bool verification = false,
|
||||
}) async {
|
||||
try {
|
||||
final uri = baseUri.replace(
|
||||
@@ -81,6 +82,7 @@ class BackendAuthServices
|
||||
.timeout(const Duration(seconds: 15));
|
||||
final dto = BackendDTO.decode(res.body);
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
if (verification) throw BackendMapper.verificationFailure(dto);
|
||||
throw factor
|
||||
? BackendMapper.factorFailure(res.statusCode, dto)
|
||||
: BackendMapper.authFailure(res.statusCode, dto);
|
||||
@@ -90,7 +92,11 @@ class BackendAuthServices
|
||||
rethrow;
|
||||
} on FactorException {
|
||||
rethrow;
|
||||
} on VerificationException {
|
||||
rethrow;
|
||||
} catch (_) {
|
||||
if (verification)
|
||||
throw const VerificationException(VerificationError.internal);
|
||||
if (factor) throw const FactorException(FactorError.unavailable);
|
||||
throw const AuthException(AuthError.unavailable);
|
||||
}
|
||||
@@ -148,16 +154,17 @@ class BackendAuthServices
|
||||
await _post('verification/send', {
|
||||
'email': normalized,
|
||||
'idempotencyKey': key,
|
||||
}, factor: true);
|
||||
}, verification: true);
|
||||
_retryKeys.remove('activation:$normalized');
|
||||
return VerificationMapper.fromDTO(
|
||||
const VerificationDTO(simulated: false),
|
||||
);
|
||||
} on FactorException catch (e) {
|
||||
if (e.code == FactorError.expired || e.code == FactorError.invalid) {
|
||||
} on VerificationException catch (e) {
|
||||
if (e.code == VerificationError.expired ||
|
||||
e.code == VerificationError.invalid) {
|
||||
_retryKeys.remove('activation:$normalized');
|
||||
}
|
||||
throw const VerificationException(VerificationError.internal);
|
||||
rethrow;
|
||||
} catch (_) {
|
||||
throw const VerificationException(VerificationError.internal);
|
||||
}
|
||||
@@ -165,18 +172,8 @@ class BackendAuthServices
|
||||
|
||||
@override
|
||||
Future<VerificationResult> confirm({required String token}) async {
|
||||
try {
|
||||
await _post('verification/confirm', {'token': token}, factor: true);
|
||||
return VerificationMapper.fromDTO(
|
||||
const VerificationDTO(simulated: false),
|
||||
);
|
||||
} on FactorException catch (e) {
|
||||
throw VerificationException(switch (e.code) {
|
||||
FactorError.expired => VerificationError.expired,
|
||||
FactorError.invalid => VerificationError.invalid,
|
||||
_ => VerificationError.internal,
|
||||
});
|
||||
}
|
||||
await _post('verification/confirm', {'token': token}, verification: true);
|
||||
return VerificationMapper.fromDTO(const VerificationDTO(simulated: false));
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -60,6 +60,7 @@ class InstrumentedAuthServices
|
||||
AuthError.invalidCredentials => JourneyFailure.invalidCredentials,
|
||||
AuthError.emailInUse => JourneyFailure.emailInUse,
|
||||
AuthError.unavailable => JourneyFailure.unavailable,
|
||||
AuthError.signupNeedsAssistance => JourneyFailure.unavailable,
|
||||
AuthError.unexpected => JourneyFailure.unexpected,
|
||||
AuthError.factorLocked ||
|
||||
AuthError.factorCooldown => JourneyFailure.unavailable,
|
||||
@@ -69,6 +70,7 @@ class InstrumentedAuthServices
|
||||
VerificationError.expired => JourneyFailure.expired,
|
||||
VerificationError.invalid => JourneyFailure.invalid,
|
||||
VerificationError.internal => JourneyFailure.internal,
|
||||
VerificationError.needsAssistance => JourneyFailure.unavailable,
|
||||
},
|
||||
FactorException(code: FactorError.expired) => JourneyFailure.expired,
|
||||
FactorException() => JourneyFailure.unavailable,
|
||||
|
||||
@@ -10,6 +10,43 @@ import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||
void main() {
|
||||
http.Response response(Map<String, Object?> data, [int status = 202]) =>
|
||||
http.Response(jsonEncode({'data': data}), status);
|
||||
test(
|
||||
'signup distinguishes support recovery and keeps the retry key',
|
||||
() async {
|
||||
final keys = <String>[];
|
||||
var failed = true;
|
||||
final service = BackendAuthServices(
|
||||
baseUri: Uri.parse('https://api.example/api/v1'),
|
||||
clientId: 'web',
|
||||
client: MockClient((request) async {
|
||||
keys.add(jsonDecode(request.body)['idempotencyKey'] as String);
|
||||
return failed
|
||||
? response({'error': 'signup_attention_required'}, 503)
|
||||
: response({'userId': 'user'});
|
||||
}),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
const input = SignupRequest(
|
||||
name: 'Ada',
|
||||
email: 'ada@example.com',
|
||||
password: 'password preserved',
|
||||
locale: 'en',
|
||||
);
|
||||
await expectLater(
|
||||
service.signup(input),
|
||||
throwsA(
|
||||
isA<AuthException>().having(
|
||||
(error) => error.code,
|
||||
'code',
|
||||
AuthError.signupNeedsAssistance,
|
||||
),
|
||||
),
|
||||
);
|
||||
failed = false;
|
||||
expect((await service.signup(input)).isSimulated, isFalse);
|
||||
expect(keys[0], keys[1]);
|
||||
},
|
||||
);
|
||||
test('signup receipt avoids duplicate delivery and subsequent resend reaches API', () async {
|
||||
final paths = <String>[];
|
||||
final service = BackendAuthServices(
|
||||
@@ -148,6 +185,34 @@ void main() {
|
||||
),
|
||||
);
|
||||
});
|
||||
test(
|
||||
'verification recovery preserves assistance instead of requesting retry',
|
||||
() async {
|
||||
final service = BackendAuthServices(
|
||||
baseUri: Uri.parse('https://api.example/api/v1'),
|
||||
clientId: 'web',
|
||||
client: MockClient(
|
||||
(_) async => response({'error': 'signup_attention_required'}, 503),
|
||||
),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
final provider = VerificationProvider(service);
|
||||
addTearDown(provider.dispose);
|
||||
await provider.send(email: 'ada@example.com', locale: 'en');
|
||||
expect(provider.state.status, VerificationStatus.error);
|
||||
expect(provider.state.error, VerificationError.needsAssistance);
|
||||
await expectLater(
|
||||
service.confirm(token: 'old-token'),
|
||||
throwsA(
|
||||
isA<VerificationException>().having(
|
||||
(e) => e.code,
|
||||
'code',
|
||||
VerificationError.needsAssistance,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'resend preserves server challenge and retries the same delivery key',
|
||||
() async {
|
||||
|
||||
Reference in new issue
Block a user