Shuffle/app/test/features/auth/presentation/auth_router_test.dart
cschlaefke 54337ffc59 feat(auth): add auth domain, repository, validators and router redirects
Refs #1

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

248 lines
7.6 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shuffle/core/routing/app_router.dart';
import 'package:shuffle/features/auth/domain/auth_status.dart';
import 'package:shuffle/features/auth/domain/registration_input.dart';
import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart';
import 'package:shuffle/features/auth/presentation/providers/auth_redirect.dart';
import 'package:supabase_flutter/supabase_flutter.dart' hide AuthUser;
import '../../../helpers/fakes.dart';
import '../../../helpers/pump_app.dart';
import '../../../helpers/router_helpers.dart';
import '../data/auth_test_support.dart';
void main() {
late SupabaseMocks mocks;
late StreamController<AuthState> events;
late Session? session;
late int signedOutCalls;
setUp(() {
mocks = SupabaseMocks();
events = StreamController<AuthState>.broadcast(sync: true);
session = null;
signedOutCalls = 0;
when(() => mocks.auth.currentSession).thenAnswer((_) => session);
when(() => mocks.auth.onAuthStateChange).thenAnswer((_) => events.stream);
});
tearDown(() => events.close());
void emit(AuthChangeEvent event, {Session? withSession}) {
session = withSession;
events.add(AuthState(event, withSession));
}
Future<ProviderContainerAndRouter> start(WidgetTester tester) async {
final container = await pumpShuffleApp(
tester,
overrides: testOverrides(
supabase: mocks.client,
overrides: [
...authOverrides(),
privateRoutePathsProvider.overrideWithValue(const ['/private']),
signedOutHandlersProvider.overrideWithValue([
(ref) => signedOutCalls++,
]),
],
),
);
return (container: container, router: container.read(appRouterProvider));
}
group('KTO-01 Kriterium 5: authStatusProvider', () {
testWidgets('maps events and starts with the stored session', (
tester,
) async {
session = testSession();
final (:container, router: _) = await start(tester);
expect(container.read(authStatusProvider).value, isA<SignedIn>());
emit(AuthChangeEvent.signedOut);
await tester.pumpAndSettle();
expect(container.read(authStatusProvider).value, isA<SignedOut>());
emit(AuthChangeEvent.signedIn, withSession: testSession());
await tester.pumpAndSettle();
expect(container.read(authStatusProvider).value, isA<SignedIn>());
});
});
group('Sicherheit Kriterium 2: router redirects', () {
testWidgets('private route while signed out -> /login?from=', (
tester,
) async {
final (container: _, :router) = await start(tester);
router.go('/private?tab=1');
await tester.pumpAndSettle();
expect(currentPath(router), '/login');
expect(currentUri(router).queryParameters['from'], '/private?tab=1');
expect(find.byKey(const Key('auth_placeholder_login')), findsOneWidget);
});
testWidgets('signing in on /login returns to from', (tester) async {
final (container: _, :router) = await start(tester);
router.go('/private');
await tester.pumpAndSettle();
emit(AuthChangeEvent.signedIn, withSession: testSession());
await tester.pumpAndSettle();
expect(currentPath(router), '/private');
});
testWidgets('signed in: login and register lead to the start page', (
tester,
) async {
session = testSession();
final (container: _, :router) = await start(tester);
router.go('/login');
await tester.pumpAndSettle();
expect(currentPath(router), '/');
router.go('/register');
await tester.pumpAndSettle();
expect(currentPath(router), '/');
});
testWidgets('placeholder routes of spec 5.3 exist', (tester) async {
final (container: _, :router) = await start(tester);
for (final (path, key) in [
('/login', 'login'),
('/register', 'register'),
('/register/check-email', 'check_email'),
('/forgot-password', 'forgot_password'),
('/auth/confirm?token_hash=x&type=email', 'auth_confirm'),
]) {
router.go(path);
await tester.pumpAndSettle();
expect(
find.byKey(Key('auth_placeholder_$key')),
findsOneWidget,
reason: path,
);
}
});
});
group('KTO-01 Kriterium 8, 9: recovery guard and sign-out hook', () {
testWidgets('recovery forces /reset-password until the password changed', (
tester,
) async {
final (container: _, :router) = await start(tester);
emit(AuthChangeEvent.passwordRecovery, withSession: testSession());
await tester.pumpAndSettle();
expect(currentPath(router), '/reset-password');
expect(
find.byKey(const Key('auth_placeholder_reset_password')),
findsOneWidget,
);
router.go('/');
await tester.pumpAndSettle();
expect(currentPath(router), '/reset-password');
emit(AuthChangeEvent.tokenRefreshed, withSession: testSession());
await tester.pumpAndSettle();
expect(currentPath(router), '/reset-password');
emit(AuthChangeEvent.userUpdated, withSession: testSession());
await tester.pumpAndSettle();
expect(currentPath(router), '/');
});
testWidgets('aborting the recovery (signOut) leaves /reset-password', (
tester,
) async {
final (container: _, :router) = await start(tester);
emit(AuthChangeEvent.passwordRecovery, withSession: testSession());
await tester.pumpAndSettle();
emit(AuthChangeEvent.signedOut);
await tester.pumpAndSettle();
expect(currentPath(router), '/login');
});
testWidgets('onSignedOut runs on signed-in -> signed-out only', (
tester,
) async {
await start(tester);
expect(signedOutCalls, 0, reason: 'initial signed-out state');
emit(AuthChangeEvent.signedIn, withSession: testSession());
await tester.pumpAndSettle();
expect(signedOutCalls, 0);
emit(AuthChangeEvent.signedOut);
await tester.pumpAndSettle();
expect(signedOutCalls, 1);
emit(AuthChangeEvent.signedOut);
await tester.pumpAndSettle();
expect(signedOutCalls, 1, reason: 'no transition');
});
testWidgets('a failed refresh on a private route ends on /login', (
tester,
) async {
session = testSession();
final (container: _, :router) = await start(tester);
router.go('/private');
await tester.pumpAndSettle();
expect(currentPath(router), '/private');
emit(AuthChangeEvent.signedOut);
await tester.pumpAndSettle();
expect(currentPath(router), '/login');
expect(signedOutCalls, 1);
});
});
group('KTO-01 Kriterium 1: registrationInputFactory', () {
testWidgets('fills terms_version from AppConfig and the app language', (
tester,
) async {
final (:container, router: _) = await start(tester);
final input = container.read(registrationInputFactoryProvider)(
email: 'a@b.de',
password: 'pw',
displayName: 'Anna',
ageConfirmed: true,
termsAccepted: true,
);
expect(
input,
const RegistrationInput(
email: 'a@b.de',
password: 'pw',
displayName: 'Anna',
ageConfirmed: true,
termsAccepted: true,
termsVersion: 'test-1',
locale: 'en',
),
);
});
});
}
typedef ProviderContainerAndRouter = ({
ProviderContainer container,
GoRouter router,
});