import 'package:dio/dio.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:marianum_mobile/api/demo/demo_mode.dart'; import 'package:marianum_mobile/api/marianumconnect/queries/auth_guardian/auth_guardian_request.dart'; import 'package:marianum_mobile/api/marianumconnect/queries/auth_guardian/auth_guardian_verify.dart'; import 'package:marianum_mobile/api/marianumconnect/queries/auth_guardian/guardian_login_exception.dart'; import 'package:marianum_mobile/api/marianumconnect/queries/auth_login/auth_login_response.dart'; import 'package:marianum_mobile/auth_link/device_binding.dart'; import 'package:marianum_mobile/auth_link/guardian_login_link.dart'; import 'package:marianum_mobile/auth_link/pending_guardian_request.dart'; import 'package:marianum_mobile/session/session.dart'; import 'package:marianum_mobile/view/login/guardian_login_controller.dart'; final _now = DateTime.utc(2026, 9, 19, 12); class _FakeStore implements PendingGuardianRequestStore { PendingGuardianRequest? stored; @override Future read() async => stored; @override Future write(PendingGuardianRequest request) async => stored = request; @override Future clear() async => stored = null; } class _FakeRequest implements AuthGuardianRequest { String? lastChallenge; int calls = 0; @override Future run({ required String email, required String deviceChallenge, required String tokenName, }) async { calls++; lastChallenge = deviceChallenge; return AuthGuardianRequestResponse( requestId: 'req-$calls', expiresAt: _now.add(const Duration(minutes: 15)), resendAvailableAt: _now.add(const Duration(seconds: 60)), codeLength: 6, ); } @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } class _FakeVerify implements AuthGuardianVerify { Object? failWith; Map? lastCall; @override Future run({ required String requestId, required String deviceVerifier, required String tokenName, String? code, String? linkToken, }) async { lastCall = { 'requestId': requestId, 'deviceVerifier': deviceVerifier, 'code': code, 'linkToken': linkToken, }; final failure = failWith; if (failure != null) throw failure; return AuthLoginResponse( token: 't', tokenId: 'id', expiresAt: null, user: AuthLoginUser( id: 'u', username: 'e@x.de', firstName: '', lastName: '', userType: 'PARENT', className: null, ), ); } @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } GuardianLoginException _error(GuardianLoginError error) => GuardianLoginException(error, userMessage: error.name); void main() { late _FakeStore store; late _FakeRequest request; late _FakeVerify verify; late List signedIn; late DateTime now; GuardianLoginController controller() => GuardianLoginController( request: request, verify: verify, store: store, signIn: (s) async => signedIn.add(s), tokenName: () async => 'test', now: () => now, ); setUp(() { store = _FakeStore(); request = _FakeRequest(); verify = _FakeVerify(); signedIn = []; now = _now; }); test( 'requesting a code persists the request and moves to the code step', () async { final c = controller(); expect(await c.requestCode(' E@X.de '), isFalse); expect(c.step, GuardianLoginStep.enterCode); expect(store.stored?.email, 'e@x.de'); expect( request.lastChallenge, DeviceBinding.challengeFor(store.stored!.deviceSecret), ); }, ); test( 'a valid code signs in with the device secret and clears the request', () async { final c = controller(); await c.requestCode('e@x.de'); final secret = store.stored!.deviceSecret; expect(await c.submitCode('123 456'), isTrue); expect(verify.lastCall?['code'], '123456'); expect(verify.lastCall?['deviceVerifier'], secret); expect(store.stored, isNull); expect(signedIn.single, isA()); expect((signedIn.single as GuardianSession).isDemo, isFalse); }, ); test('a wrong code keeps the request for another try', () async { final c = controller(); await c.requestCode('e@x.de'); verify.failWith = _error(GuardianLoginError.invalidCode); expect(await c.submitCode('000000'), isFalse); expect(c.step, GuardianLoginStep.enterCode); expect(c.errorMessage, isNotNull); expect(store.stored, isNotNull); }); test('an expired request falls back to the e-mail step', () async { final c = controller(); await c.requestCode('e@x.de'); verify.failWith = _error(GuardianLoginError.requestExpired); await c.submitCode('123456'); expect(c.step, GuardianLoginStep.enterEmail); expect(store.stored, isNull); }); test( 'a link for another request is rejected without a server call', () async { final c = controller(); await c.requestCode('e@x.de'); final ok = await c.submitLink( const GuardianLoginLink(requestId: 'other', linkToken: 'lt'), ); expect(ok, isFalse); expect(verify.lastCall, isNull); expect(c.errorMessage, contains('anderen Gerät')); }, ); test('a matching link signs in', () async { final c = controller(); await c.requestCode('e@x.de'); final ok = await c.submitLink( GuardianLoginLink(requestId: store.stored!.requestId, linkToken: 'lt'), ); expect(ok, isTrue); expect(verify.lastCall?['linkToken'], 'lt'); }); test('restore resumes an open request and drops an expired one', () async { await controller().requestCode('e@x.de'); final resumed = controller(); await resumed.restore(); expect(resumed.step, GuardianLoginStep.enterCode); now = _now.add(const Duration(hours: 1)); final late = controller(); await late.restore(); expect(late.step, GuardianLoginStep.enterEmail); expect(store.stored, isNull); }); test('resend is only possible after the cooldown', () async { final c = controller(); await c.requestCode('e@x.de'); expect(c.canResend(), isFalse); await c.resend(); expect(request.calls, 1); now = _now.add(const Duration(seconds: 61)); expect(c.canResend(), isTrue); await c.resend(); expect(request.calls, 2); }); test('the demo address signs in locally', () async { final c = controller(); expect(await c.requestCode(DemoMode.guardianEmail), isTrue); expect(request.calls, 0); expect(signedIn.single.isDemo, isTrue); }); group('GuardianLoginException.fromDio', () { DioException failure(int status, Object? body) => DioException( requestOptions: RequestOptions(), type: DioExceptionType.badResponse, response: Response( requestOptions: RequestOptions(), statusCode: status, data: body, ), ); test('reads the JSON error body', () { final e = GuardianLoginException.fromDio( failure(401, {'error': 'invalid_code', 'attemptsLeft': 2}), ) as GuardianLoginException; expect(e.error, GuardianLoginError.invalidCode); expect(e.attemptsLeft, 2); expect(e.userMessage, contains('Noch 2 Versuche')); }); test('reads the plain-text error of the generic server handler', () { final e = GuardianLoginException.fromDio( failure(403, 'Fehler: device_mismatch'), ) as GuardianLoginException; expect(e.error, GuardianLoginError.deviceMismatch); }); test('falls back to the status code', () { final e = GuardianLoginException.fromDio(failure(429, null)) as GuardianLoginException; expect(e.error, GuardianLoginError.rateLimited); }); test('names an address the school has no guardian access for', () { final e = GuardianLoginException.fromDio( failure(404, {'error': 'email_not_registered'}), ) as GuardianLoginException; expect(e.error, GuardianLoginError.emailNotRegistered); expect(e.userMessage, contains('Sekretariat')); }); test('a bare 404 means the server lacks guardian login, not an ' 'unknown address', () { for (final body in [null, 'Not Found', 'Endpoint not found']) { final e = GuardianLoginException.fromDio(failure(404, body)) as GuardianLoginException; expect(e.error, GuardianLoginError.unsupportedServer, reason: '$body'); } final methodMissing = GuardianLoginException.fromDio(failure(405, null)) as GuardianLoginException; expect(methodMissing.error, GuardianLoginError.unsupportedServer); }); test('names a disabled guardian account', () { final e = GuardianLoginException.fromDio( failure(403, {'error': 'account_disabled'}), ) as GuardianLoginException; expect(e.error, GuardianLoginError.accountDisabled); }); test('server errors keep the generic mapping', () { expect( GuardianLoginException.fromDio(failure(500, 'boom')), isNot(isA()), ); }); }); }