Add unit tests for AppConfig and AuthController, and include mount checks in AuthController.

This commit is contained in:
oskar 2026-01-12 23:12:02 +01:00
parent 73597b9bca
commit bbd7a371dd
3 changed files with 260 additions and 0 deletions

View file

@ -21,11 +21,13 @@ class AuthController extends Notifier<AuthState> {
final config = ref.read(appConfigProvider); final config = ref.read(appConfigProvider);
final configError = config.validationError; final configError = config.validationError;
if (configError != null) { if (configError != null) {
if (!ref.mounted) return;
state = state.copyWith(isLoading: false, errorMessage: configError); state = state.copyWith(isLoading: false, errorMessage: configError);
return; return;
} }
if (config.useLocalAuth) { if (config.useLocalAuth) {
final localSession = await _localDataSource.readLocalSession(); final localSession = await _localDataSource.readLocalSession();
if (!ref.mounted) return;
state = AuthState( state = AuthState(
isLoading: false, isLoading: false,
token: localSession != null ? const AuthToken(accessToken: 'local') : null, token: localSession != null ? const AuthToken(accessToken: 'local') : null,
@ -33,6 +35,7 @@ class AuthController extends Notifier<AuthState> {
return; return;
} }
final token = await _repository.getSavedToken(); final token = await _repository.getSavedToken();
if (!ref.mounted) return;
state = AuthState(isLoading: false, token: token); state = AuthState(isLoading: false, token: token);
} }

View file

@ -0,0 +1,96 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:mosenioring/src/core/config/app_config.dart';
void main() {
group('AppConfig', () {
test('validationError returns null when API_BASE_URL and useLocalAuth are set', () {
const config = AppConfig(
apiBaseUrl: 'http://localhost:8080',
useLocalAuth: true,
localTenantId: '123',
localRoles: 'USER',
keycloakIssuer: '',
keycloakClientId: '',
keycloakRedirectUrl: '',
);
expect(config.validationError, isNull);
});
test('validationError returns error when API_BASE_URL is missing', () {
const config = AppConfig(
apiBaseUrl: ' ',
useLocalAuth: true,
localTenantId: '123',
localRoles: 'USER',
keycloakIssuer: '',
keycloakClientId: '',
keycloakRedirectUrl: '',
);
expect(config.validationError, contains('Missing API_BASE_URL'));
});
test('validationError returns error when Keycloak config is missing and not using local auth', () {
// Assuming we are on a platform that supports AppAuth in tests (usually not, so it might hit the platform check first)
// Actually, in terminal tests, defaultTargetPlatform might be android or ios depending on environment, but often it's not.
// Let's test the logic regardless of platform by checking the specific requirements.
const config = AppConfig(
apiBaseUrl: 'http://localhost:8080',
useLocalAuth: false,
localTenantId: '123',
localRoles: 'USER',
keycloakIssuer: '',
keycloakClientId: '',
keycloakRedirectUrl: '',
);
final error = config.validationError;
expect(error, isNotNull);
// It will either fail on platform support or on missing issuer.
});
test('keycloakDiscoveryUrl is correctly formed', () {
const config = AppConfig(
apiBaseUrl: 'http://localhost:8080',
useLocalAuth: false,
localTenantId: '123',
localRoles: 'USER',
keycloakIssuer: 'https://auth.example.com/realms/myrealm',
keycloakClientId: 'client',
keycloakRedirectUrl: 'app://callback',
);
expect(config.keycloakDiscoveryUrl, 'https://auth.example.com/realms/myrealm/.well-known/openid-configuration');
});
test('allowInsecureConnections is true for http issuer', () {
const config = AppConfig(
apiBaseUrl: 'http://localhost:8080',
useLocalAuth: false,
localTenantId: '123',
localRoles: 'USER',
keycloakIssuer: 'http://localhost:8081',
keycloakClientId: 'client',
keycloakRedirectUrl: 'app://callback',
);
expect(config.allowInsecureConnections, isTrue);
});
test('allowInsecureConnections is false for https issuer', () {
const config = AppConfig(
apiBaseUrl: 'http://localhost:8080',
useLocalAuth: false,
localTenantId: '123',
localRoles: 'USER',
keycloakIssuer: 'https://auth.example.com',
keycloakClientId: 'client',
keycloakRedirectUrl: 'app://callback',
);
expect(config.allowInsecureConnections, isFalse);
});
});
}

View file

@ -0,0 +1,161 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mosenioring/src/di/providers.dart';
import 'package:mosenioring/src/features/auth/domain/auth_repository.dart';
import 'package:mosenioring/src/features/auth/domain/models/auth_token.dart';
import 'package:mosenioring/src/features/auth/data/auth_local_data_source.dart';
import 'package:mosenioring/src/core/config/app_config.dart';
class MockAuthRepository implements AuthRepository {
AuthToken? token;
Object? loginError;
bool logoutCalled = false;
@override
Future<AuthToken?> getSavedToken() async => token;
@override
Future<AuthToken> login({required String email, required String password}) async {
if (loginError != null) throw loginError!;
return const AuthToken(accessToken: 'new_token');
}
@override
Future<void> logout() async {
logoutCalled = true;
}
@override
Future<void> persistToken(AuthToken token) async {
this.token = token;
}
}
class MockAuthLocalDataSource implements AuthLocalDataSource {
LocalAuthSession? session;
AuthToken? token;
@override
Future<void> clear() async {
session = null;
token = null;
}
@override
Future<LocalAuthSession?> readLocalSession() async => session;
@override
Future<AuthToken?> readToken() async => token;
@override
Future<void> saveLocalSession({required String email, required String roles, required String tenantId}) async {
session = LocalAuthSession(email: email, roles: roles, tenantId: tenantId);
}
@override
Future<void> saveToken(AuthToken token) async {
this.token = token;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
group('AuthController', () {
late MockAuthRepository mockRepository;
late AppConfig testConfig;
setUp(() {
mockRepository = MockAuthRepository();
testConfig = const AppConfig(
apiBaseUrl: 'https://api.example.com',
useLocalAuth: false,
localTenantId: '123',
localRoles: 'USER',
keycloakIssuer: 'https://keycloak.com',
keycloakClientId: 'client',
keycloakRedirectUrl: 'app://callback',
);
});
ProviderContainer createContainer({AppConfig? config}) {
final container = ProviderContainer(
overrides: [
authRepositoryProvider.overrideWithValue(mockRepository),
appConfigProvider.overrideWithValue(config ?? testConfig),
],
);
addTearDown(container.dispose);
return container;
}
test('initial state is loading then unauthenticated when no token', () async {
final container = createContainer();
// Wait for bootstrap microtask
await Future.microtask(() {});
final state = container.read(authControllerProvider);
expect(state.isLoading, isFalse);
expect(state.isAuthenticated, isFalse);
});
test('bootstrap loads saved token', () async {
mockRepository.token = const AuthToken(accessToken: 'saved_token');
final container = createContainer();
// Keep checking until state is updated or timeout
for (var i = 0; i < 10; i++) {
await Future.delayed(Duration.zero);
if (container.read(authControllerProvider).isAuthenticated) break;
}
final state = container.read(authControllerProvider);
expect(state.isAuthenticated, isTrue);
expect(state.token?.accessToken, 'saved_token');
});
test('login success updates state', () async {
final container = createContainer();
await Future.microtask(() {});
await container.read(authControllerProvider.notifier).login(
email: 'test@example.com',
password: 'password',
);
final state = container.read(authControllerProvider);
expect(state.isAuthenticated, isTrue);
expect(state.token?.accessToken, 'new_token');
expect(state.errorMessage, isNull);
});
test('login failure updates error message', () async {
final container = createContainer();
await Future.microtask(() {});
mockRepository.loginError = Exception('Invalid credentials');
await container.read(authControllerProvider.notifier).login(
email: 'test@example.com',
password: 'password',
);
final state = container.read(authControllerProvider);
expect(state.isAuthenticated, isFalse);
expect(state.errorMessage, 'Invalid credentials');
});
test('logout clears state', () async {
mockRepository.token = const AuthToken(accessToken: 'saved_token');
final container = createContainer();
await Future.microtask(() {});
await container.read(authControllerProvider.notifier).logout();
final state = container.read(authControllerProvider);
expect(state.isAuthenticated, isFalse);
expect(mockRepository.logoutCalled, isTrue);
});
});
}