Connect sign-up to Sendico Facts intake (#46)

This commit is contained in:
Stephan D committed 2026-10-15 00:55:19 +02:00
1 parent 30f0455122
commit a584c8189a
40 files changed
+1616 -35

No files matched your search

@@ -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 {