Shuffle/app/test/features/auth/presentation/register/register_controller_test.dart
cschlaefke 3805c71225
Some checks failed
CI / flutter (pull_request) Failing after 44s
CI / web-build (pull_request) Successful in 51s
CI / supabase (pull_request) Successful in 3s
CI / supabase-tests (pull_request) Successful in 29s
CI / e2e (pull_request) Successful in 2m44s
fix(auth): address review findings for smtp-neutral handling
Refs #37

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 12:24:25 +01:00

464 lines
16 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shuffle/core/result/result.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:shuffle/features/auth/domain/registration_input.dart';
import 'package:shuffle/features/auth/domain/validation/common_passwords.dart';
import 'package:shuffle/features/auth/domain/validation/display_name_rule.dart';
import 'package:shuffle/features/auth/domain/validation/email_format.dart';
import 'package:shuffle/features/auth/domain/validation/password_policy.dart';
import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart';
import 'package:shuffle/features/auth/presentation/register/register_controller.dart';
import 'package:shuffle/features/auth/presentation/register/register_state.dart';
import '../../../../helpers/fakes.dart';
import '../auth_ui_support.dart';
void main() {
late MockAuthRepository repository;
late List<Uri> opened;
late bool openResult;
setUpAll(registerAuthFallbacks);
setUp(() {
repository = MockAuthRepository();
opened = [];
openResult = true;
when(() => repository.signUp(any())).thenAnswer((_) async => ok());
});
ProviderContainer makeContainer({
Locale locale = const Locale('de'),
Future<CommonPasswordList> Function()? commonPasswords,
}) {
final container = ProviderContainer.test(
overrides: authUiOverrides(
repository,
locale: locale,
urlOpener: (uri) async {
opened.add(uri);
return openResult;
},
commonPasswords: commonPasswords,
),
);
keepAlive(container, registerControllerProvider);
return container;
}
RegisterController notifier(ProviderContainer c) =>
c.read(registerControllerProvider.notifier);
RegisterState stateOf(ProviderContainer c) =>
c.read(registerControllerProvider);
void fillValid(
RegisterController controller, {
String email = ' anna@example.com ',
String password = 'geheim-und-lang',
String name = ' Anna ',
}) {
controller
..emailChanged(email)
..passwordChanged(password)
..displayNameChanged(name)
..ageConfirmedChanged(value: true)
..termsAcceptedChanged(value: true);
}
group('no personal data in logs', () {
test('toString masks email, password and display name', () {
final container = makeContainer();
fillValid(
container.read(registerControllerProvider.notifier),
email: 'anna@example.com',
name: 'Anna Geheim',
);
final text = container.read(registerControllerProvider).toString();
expect(text, isNot(contains('anna@example.com')));
expect(text, isNot(contains('geheim-und-lang')));
expect(text, isNot(contains('Anna Geheim')));
expect(text, contains('<redacted>'));
});
});
group('KTO-01 Kriterium 1: submit', () {
test('KTO-01 Kriterium 11 (Client): sends all data, terms version and locale to signUp', () async {
final container = makeContainer(locale: const Locale('en'));
fillValid(notifier(container));
final result = await notifier(container).submit();
expect(result, isTrue);
final input =
verify(() => repository.signUp(captureAny())).captured.single
as RegistrationInput;
expect(input.email, 'anna@example.com');
expect(input.password, 'geheim-und-lang');
expect(input.displayName, 'Anna');
expect(input.ageConfirmed, isTrue);
expect(input.termsAccepted, isTrue);
expect(input.termsVersion, testAppConfig().termsVersion);
expect(input.locale, 'en');
expect(stateOf(container).isSubmitting, isFalse);
});
test('KTO-01 Kriterium 11 (Client): mail language follows the active UI language', () async {
final container = makeContainer();
fillValid(notifier(container));
await notifier(container).submit();
final input =
verify(() => repository.signUp(captureAny())).captured.single
as RegistrationInput;
expect(input.locale, 'de');
});
test('ignores a second submit while one is running', () async {
final completer = Completer<Result<void, AuthFailure>>();
when(() => repository.signUp(any())).thenAnswer((_) => completer.future);
final container = makeContainer();
fillValid(notifier(container));
final first = notifier(container).submit();
await Future<void>.delayed(Duration.zero);
expect(stateOf(container).isSubmitting, isTrue);
expect(await notifier(container).submit(), isFalse);
completer.complete(ok());
expect(await first, isTrue);
verify(() => repository.signUp(any())).called(1);
});
});
test(
'two triggers while the password list is still loading send only once',
() async {
final listLoaded = Completer<CommonPasswordList>();
final container = makeContainer(commonPasswords: () => listLoaded.future);
fillValid(notifier(container));
final first = notifier(container).submit();
final second = notifier(container).submit();
expect(stateOf(container).isSubmitting, isTrue);
expect(await second, isFalse);
listLoaded.complete(commonTestPasswords);
expect(await first, isTrue);
verify(() => repository.signUp(any())).called(1);
expect(stateOf(container).isSubmitting, isFalse);
},
);
test('an invalid form leaves the submitting state again', () async {
final container = makeContainer();
expect(await notifier(container).submit(), isFalse);
expect(stateOf(container).isSubmitting, isFalse);
});
group('KTO-01 Kriterium 2: minimum age', () {
test(
'without the age confirmation nothing is sent and the error shows',
() async {
final container = makeContainer();
fillValid(notifier(container));
notifier(container).ageConfirmedChanged(value: false);
expect(await notifier(container).submit(), isFalse);
verifyNever(() => repository.signUp(any()));
expect(stateOf(container).shownAgeError, isTrue);
expect(stateOf(container).focusField, RegisterField.ageConfirmation);
},
);
test('QUE-04 Kriterium 1: without consent nothing is sent', () async {
final container = makeContainer();
fillValid(notifier(container));
notifier(container).termsAcceptedChanged(value: false);
expect(await notifier(container).submit(), isFalse);
verifyNever(() => repository.signUp(any()));
expect(stateOf(container).shownTermsError, isTrue);
});
test('the error disappears when the box is ticked', () async {
final container = makeContainer();
await notifier(container).submit();
expect(stateOf(container).shownAgeError, isTrue);
notifier(container).ageConfirmedChanged(value: true);
expect(stateOf(container).shownAgeError, isFalse);
});
});
group('KTO-01 Kriterium 3: password', () {
test('is checked while typing: too short names the missing count', () {
final container = makeContainer();
notifier(container).passwordChanged('123456789');
expect(
stateOf(container).shownPasswordError,
const PasswordError.tooShort(1),
);
});
test('10 characters are enough', () {
final container = makeContainer();
notifier(container).passwordChanged('1234567890');
expect(stateOf(container).shownPasswordError, isNull);
});
test('a common password is rejected once the list is loaded', () async {
final container = makeContainer();
notifier(container).passwordChanged(' PassWort1234 ');
await notifier(container).loadCommonPasswords();
expect(stateOf(container).shownPasswordError, isA<PasswordCommon>());
expect(await notifier(container).submit(), isFalse);
verifyNever(() => repository.signUp(any()));
});
test('an empty password is shown as required after blur', () {
final container = makeContainer();
notifier(container).fieldBlurred(RegisterField.password);
expect(stateOf(container).shownPasswordRequired, isTrue);
});
test(
'weak_password from the server is mapped to the password field',
() async {
when(() => repository.signUp(any())).thenAnswer(
(_) async => failed(
const AuthFailure.weakPassword([WeakPasswordReason.length]),
),
);
final container = makeContainer();
fillValid(notifier(container));
expect(await notifier(container).submit(), isFalse);
expect(stateOf(container).serverPasswordReasons, [
WeakPasswordReason.length,
]);
expect(stateOf(container).bannerFailure, isNull);
expect(stateOf(container).focusField, RegisterField.password);
notifier(container).passwordChanged('anderes-passwort');
expect(stateOf(container).serverPasswordReasons, isNull);
},
);
test('Zustände Kriterium 3: if the list cannot be loaded, only the length rule runs', () async {
final container = makeContainer(
commonPasswords: () => Future.error(StateError('asset missing')),
);
fillValid(notifier(container), password: 'passwort1234');
expect(await notifier(container).submit(), isTrue);
});
});
group('KTO-01 Kriterium 4: no hint whether the account exists', () {
test('emailSendLimited counts as success (F12)', () async {
when(
() => repository.signUp(any()),
).thenAnswer((_) async => failed(const AuthFailure.emailSendLimited()));
final container = makeContainer();
fillValid(notifier(container));
expect(await notifier(container).submit(), isTrue);
expect(stateOf(container).bannerFailure, isNull);
});
test('plain success leads to the same result', () async {
final container = makeContainer();
fillValid(notifier(container));
expect(await notifier(container).submit(), isTrue);
expect(stateOf(container).bannerFailure, isNull);
});
test('over_request_rate_limit stays a visible rate-limit banner', () async {
when(() => repository.signUp(any()))
.thenAnswer((_) async => failed(const AuthFailure.rateLimited()));
final container = makeContainer();
fillValid(notifier(container));
expect(await notifier(container).submit(), isFalse);
expect(stateOf(container).bannerFailure, const AuthFailure.rateLimited());
});
});
group('KTO-01 Kriterium 10: display name', () {
Future<DisplayNameError?> errorFor(String name) async {
final container = makeContainer();
notifier(container)
..displayNameChanged(name)
..fieldBlurred(RegisterField.displayName);
return stateOf(container).shownDisplayNameError;
}
test('empty and whitespace only are rejected', () async {
expect(await errorFor(''), DisplayNameError.empty);
expect(await errorFor(' '), DisplayNameError.empty);
});
test('30 code points are valid, 31 are too long', () async {
expect(await errorFor('a' * 30), isNull);
expect(await errorFor('a' * 31), DisplayNameError.tooLong);
});
test('counts code points, not UTF-16 units', () async {
expect(await errorFor('😀' * 30), isNull);
expect(await errorFor('😀' * 31), DisplayNameError.tooLong);
});
test('the counter uses the trimmed code point length', () {
final container = makeContainer();
notifier(container).displayNameChanged(' 😀😀 ');
expect(stateOf(container).displayNameLength, 2);
});
test('U+200B anywhere is invalid', () async {
expect(await errorFor('A​B'), DisplayNameError.invalidCharacter);
expect(await errorFor('​'), DisplayNameError.invalidCharacter);
});
test('an invalid name blocks the submit', () async {
final container = makeContainer();
fillValid(notifier(container), name: ' ');
expect(await notifier(container).submit(), isFalse);
verifyNever(() => repository.signUp(any()));
expect(stateOf(container).focusField, RegisterField.displayName);
});
});
group('KTO-01 Kriterium 1: email', () {
test(
'an invalid address is shown after blur and blocks the submit',
() async {
final container = makeContainer();
notifier(container)
..emailChanged('anna')
..fieldBlurred(RegisterField.email);
expect(stateOf(container).shownEmailError, EmailError.invalid);
expect(await notifier(container).submit(), isFalse);
},
);
test('invalid email from the server goes to the email field', () async {
when(() => repository.signUp(any()))
.thenAnswer((_) async => failed(const AuthFailure.invalidEmail()));
final container = makeContainer();
fillValid(notifier(container));
await notifier(container).submit();
expect(stateOf(container).serverEmailInvalid, isTrue);
expect(stateOf(container).focusField, RegisterField.email);
});
});
group('F13: SMTP outage looks like success', () {
test('unexpected (HTTP 5xx) continues like a 200', () async {
when(() => repository.signUp(any()))
.thenAnswer((_) async => failed(const AuthFailure.unexpected()));
final container = makeContainer();
fillValid(notifier(container));
expect(await notifier(container).submit(), isTrue);
expect(stateOf(container).bannerFailure, isNull);
});
});
group('Zustände Kriterium 2, 3: failures keep the input', () {
for (final failure in [
const AuthFailure.network(),
const AuthFailure.registrationRejected(
reason: RegistrationRejectionReason.displayName,
),
]) {
test('${failure.runtimeType} becomes a banner', () async {
when(() => repository.signUp(any()))
.thenAnswer((_) async => failed(failure));
final container = makeContainer();
fillValid(notifier(container));
expect(await notifier(container).submit(), isFalse);
final state = stateOf(container);
expect(state.bannerFailure, failure);
expect(state.isSubmitting, isFalse);
expect(state.password, 'geheim-und-lang');
expect(state.displayName, ' Anna ');
expect(state.ageConfirmed, isTrue);
});
}
test('a new attempt clears the banner', () async {
when(() => repository.signUp(any()))
.thenAnswer((_) async => failed(const AuthFailure.network()));
final container = makeContainer();
fillValid(notifier(container));
await notifier(container).submit();
when(() => repository.signUp(any())).thenAnswer((_) async => ok());
expect(await notifier(container).submit(), isTrue);
expect(stateOf(container).bannerFailure, isNull);
});
});
group('QUE-04 Kriterium 1: legal texts', () {
test('open the URLs from the app config', () async {
final container = makeContainer();
expect(
await notifier(container).openLegalDocument(LegalDocument.privacy),
isTrue,
);
await notifier(container).openLegalDocument(LegalDocument.terms);
expect(opened, [
Uri.parse(testAppConfig().legalPrivacyUrl),
Uri.parse(testAppConfig().legalTermsUrl),
]);
});
test('report a failure to open', () async {
openResult = false;
final container = makeContainer();
expect(
await notifier(container).openLegalDocument(LegalDocument.terms),
isFalse,
);
});
});
test('factory provider reads terms version and locale', () {
final container = makeContainer();
final input = container.read(registrationInputFactoryProvider)(
email: 'a@b.de',
password: 'x',
displayName: 'n',
ageConfirmed: true,
termsAccepted: true,
);
expect(input.termsVersion, 'test-1');
expect(input.locale, 'de');
});
}