Implements KTO-01 criteria 1-4, 10 and 11 (client part). Refs #1 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
247 lines
7.7 KiB
Dart
247 lines
7.7 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(
|
|
'routes of spec 5.3 exist (01a: real screens, rest placeholders)',
|
|
(tester) async {
|
|
final (container: _, :router) = await start(tester);
|
|
|
|
for (final (path, key) in [
|
|
('/login', 'auth_placeholder_login'),
|
|
('/register', 'register_screen'),
|
|
('/register/check-email', 'check_email_screen'),
|
|
('/forgot-password', 'auth_placeholder_forgot_password'),
|
|
('/auth/confirm?token_hash=x&type=email', 'email_link_screen'),
|
|
]) {
|
|
router.go(path);
|
|
await tester.pumpAndSettle();
|
|
expect(find.byKey(Key(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,
|
|
});
|