diff --git a/lib/app_state.dart b/lib/app_state.dart index 0e8ae093..d0eca02b 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -13,6 +13,7 @@ import 'models/pending_upload.dart'; import 'models/suspected_location.dart'; import 'models/tile_provider.dart'; import 'models/search_result.dart'; +import 'services/nuclear_reset_service.dart'; import 'services/offline_area_service.dart'; import 'services/map_data_provider.dart'; import 'services/node_data_manager.dart'; @@ -58,6 +59,7 @@ class AppState extends ChangeNotifier { late final UploadQueueState _uploadQueueState; bool _isInitialized = false; + bool _didNuclearReset = false; // Positioning tutorial state LatLng? _tutorialStartPosition; // Track where the tutorial started @@ -94,6 +96,12 @@ class AppState extends ChangeNotifier { // Getters that delegate to individual state modules bool get isInitialized => _isInitialized; + /// True if a nuclear reset occurred during this launch. Check once then clear. + bool consumeDidNuclearReset() { + final val = _didNuclearReset; + _didNuclearReset = false; + return val; + } // Auth state bool get isLoggedIn => _authState.isLoggedIn; @@ -190,75 +198,103 @@ class AppState extends ChangeNotifier { } // ---------- Init ---------- + static const String _initFailureCountKey = 'init_failure_count'; + Future _init() async { - // Initialize all state modules - await _settingsState.init(); - - // Initialize changelog service - await ChangelogService().init(); - - // Attempt to fetch missing tile type preview tiles (fails silently) - _fetchMissingTilePreviews(); - - // Check if we should add default profiles (first launch OR no profiles of each type exist) - final prefs = await SharedPreferences.getInstance(); - const firstLaunchKey = 'profiles_defaults_initialized'; - final isFirstLaunch = !(prefs.getBool(firstLaunchKey) ?? false); - - // Load existing profiles to check each type independently - final existingOperatorProfiles = await OperatorProfileService().load(); - final existingNodeProfiles = await ProfileService().load(); - - final shouldAddOperatorDefaults = isFirstLaunch || existingOperatorProfiles.isEmpty; - final shouldAddNodeDefaults = isFirstLaunch || existingNodeProfiles.isEmpty; - - await _operatorProfileState.init(addDefaults: shouldAddOperatorDefaults); - await _profileState.init(addDefaults: shouldAddNodeDefaults); - - // Set up callback to clear stale sessions when profiles are deleted - _profileState.setProfileDeletedCallback(_onProfileDeleted); - - // Mark defaults as initialized if this was first launch - if (isFirstLaunch) { - await prefs.setBool(firstLaunchKey, true); - } - - await _suspectedLocationState.init(offlineMode: _settingsState.offlineMode); - await _uploadQueueState.init(); - await _authState.init(_settingsState.uploadMode); - - // Set up callback to repopulate pending nodes after cache clears - NodeProviderWithCache.instance.setOnCacheClearedCallback(() { - _uploadQueueState.repopulateCacheFromQueue(); - }); - - // Check for messages on app launch if user is already logged in - if (isLoggedIn) { - checkMessages(); + try { + // Nuclear reset check: if init has failed >= 2 times, wipe everything. + // SharedPreferences.getInstance() returns a singleton — the same in-memory + // object is returned every time, so after clearEverything() wipes it we can + // re-read the (now-zeroed) failure count from the same reference. + var prefs = await SharedPreferences.getInstance(); + final failureCount = prefs.getInt(_initFailureCountKey) ?? 0; + if (failureCount >= 2) { + debugPrint('[AppState] Init failed $failureCount times — triggering nuclear reset'); + await NuclearResetService.clearEverything(); + _didNuclearReset = true; + // Re-acquire: clearEverything() calls prefs.clear(), so the cached + // singleton is already wiped. Re-read to confirm the zeroed state. + prefs = await SharedPreferences.getInstance(); + } + + // Increment failure count before running init (cleared on success). + final currentCount = prefs.getInt(_initFailureCountKey) ?? 0; + await prefs.setInt(_initFailureCountKey, currentCount + 1); + + // Settings must init first — other modules read its values + await _settingsState.init(); + + // Initialize changelog service + await ChangelogService().init(); + + // Fire-and-forget tile preview fetch (existing pattern) + _fetchMissingTilePreviews(); + + // Check if we should add default profiles (first launch OR no profiles of each type exist) + const firstLaunchKey = 'profiles_defaults_initialized'; + final isFirstLaunch = !(prefs.getBool(firstLaunchKey) ?? false); + + final existingOperatorProfiles = await OperatorProfileService().load(); + final existingNodeProfiles = await ProfileService().load(); + + final shouldAddOperatorDefaults = isFirstLaunch || existingOperatorProfiles.isEmpty; + final shouldAddNodeDefaults = isFirstLaunch || existingNodeProfiles.isEmpty; + + await _operatorProfileState.init(addDefaults: shouldAddOperatorDefaults); + await _profileState.init(addDefaults: shouldAddNodeDefaults); + + // Set up callback to clear stale sessions when profiles are deleted + _profileState.setProfileDeletedCallback(_onProfileDeleted); + + if (isFirstLaunch) { + await prefs.setBool(firstLaunchKey, true); + } + + // Local-only init for suspected locations (no network) + await _suspectedLocationState.initLocal(); + await _uploadQueueState.init(); + // Local-only auth init (no network) + await _authState.init(_settingsState.uploadMode); + + // Set up callback to repopulate pending nodes after cache clears + NodeProviderWithCache.instance.setOnCacheClearedCallback(() { + _uploadQueueState.repopulateCacheFromQueue(); + }); + + // Initialize OfflineAreaService to ensure offline areas are loaded + await OfflineAreaService().ensureInitialized(); + + // Preload offline nodes into cache for immediate display + await NodeDataManager().preloadOfflineNodes(); + + // Start uploader if conditions are met + _startUploader(); + + _isInitialized = true; + + // Clear failure count on success + await prefs.setInt(_initFailureCountKey, 0); + + // Post-init background tasks (non-blocking, fire-and-forget) + _suspectedLocationState.refreshIfNeeded( + offlineMode: _settingsState.offlineMode, + ); + _authState.refreshIfNeeded(); + if (isLoggedIn) checkMessages(); + _startMessageCheckTimer(); + Future.delayed(const Duration(milliseconds: 500), () { + DeepLinkService().checkInitialLink(); + }); + + notifyListeners(); + } catch (e, stackTrace) { + debugPrint('[AppState] Critical error during initialization: $e'); + debugPrint('[AppState] Stack trace: $stackTrace'); + // Set initialized to true to prevent stuck loading screen. + // Next launch may trigger nuclear reset if failure count >= 2. + _isInitialized = true; + notifyListeners(); } - - // Note: Re-auth check will be triggered from home screen after init - - // Initialize OfflineAreaService to ensure offline areas are loaded - await OfflineAreaService().ensureInitialized(); - - // Preload offline nodes into cache for immediate display - await NodeDataManager().preloadOfflineNodes(); - - // Start uploader if conditions are met - _startUploader(); - - _isInitialized = true; - - // Check for initial deep link after a small delay to let navigation settle - Future.delayed(const Duration(milliseconds: 500), () { - DeepLinkService().checkInitialLink(); - }); - - // Start periodic message checking - _startMessageCheckTimer(); - - notifyListeners(); } void _startMessageCheckTimer() { @@ -303,7 +339,13 @@ class AppState extends ChangeNotifier { } Future validateToken() async { - return await _authState.validateToken(); + try { + await _authState.refreshAuthState(); + return _authState.isLoggedIn; + } catch (e) { + debugPrint('AppState: Token validation error: $e'); + return false; + } } // ---------- Messages Methods ---------- diff --git a/lib/keys.dart b/lib/keys.dart index ed9a52d5..2e94c07a 100644 --- a/lib/keys.dart +++ b/lib/keys.dart @@ -4,13 +4,13 @@ String get kOsmProdClientId { const fromBuild = String.fromEnvironment('OSM_PROD_CLIENTID'); if (fromBuild.isNotEmpty) return fromBuild; - + throw Exception('OSM_PROD_CLIENTID not configured. Use --dart-define=OSM_PROD_CLIENTID=your_id'); } String get kOsmSandboxClientId { const fromBuild = String.fromEnvironment('OSM_SANDBOX_CLIENTID'); if (fromBuild.isNotEmpty) return fromBuild; - + throw Exception('OSM_SANDBOX_CLIENTID not configured. Use --dart-define=OSM_SANDBOX_CLIENTID=your_id'); -} \ No newline at end of file +} diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index 481be1c1..1021e4ce 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:developer'; import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; import 'package:oauth2_client/oauth2_client.dart'; import 'package:oauth2_client/oauth2_helper.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -14,13 +15,18 @@ import 'http_client.dart'; class AuthService { // Both client IDs from keys.dart static const _redirect = 'deflockapp://auth'; + static const Duration _timeout = Duration(seconds: 10); + /// Per-mode cached display name key, parallels [_tokenKey]. + String get _cachedDisplayNameKey => 'cached_display_name_${_mode.name}'; - late OAuth2Helper _helper; + OAuth2Helper? _helper; + final http.Client _client; String? _displayName; UploadMode _mode = UploadMode.production; - AuthService({UploadMode mode = UploadMode.production}) { - setUploadMode(mode); + AuthService({UploadMode mode = UploadMode.production, http.Client? client}) + : _client = client ?? UserAgentClient() { + _mode = mode; } String get _tokenKey { @@ -36,7 +42,12 @@ class AuthService { void setUploadMode(UploadMode mode) { _mode = mode; - final isSandbox = (mode == UploadMode.sandbox); + _helper = null; // invalidate so next access rebuilds with new mode + } + + OAuth2Helper get _oauthHelper { + if (_helper != null) return _helper!; + final isSandbox = (_mode == UploadMode.sandbox); final authBase = isSandbox ? 'https://master.apis.dev.openstreetmap.org' : 'https://www.openstreetmap.org'; @@ -52,8 +63,8 @@ class AuthService { clientId: clientId, scopes: ['read_prefs', 'write_api', 'consume_messages'], enablePKCE: true, - // tokenStorageKey: _tokenKey, // not supported by this package version ); + return _helper!; } Future isLoggedIn() async { @@ -84,9 +95,9 @@ class AuthService { return _displayName; } try { - final token = await _helper.getToken(); + final token = await _oauthHelper.getToken(); if (token?.accessToken == null) { - log('OAuth error: token null or missing accessToken'); + log('[AuthService] OAuth error: token null or missing accessToken'); return null; } final tokenMap = { @@ -99,13 +110,13 @@ class AuthService { _displayName = await _fetchUsername(token.accessToken!); return _displayName; } catch (e) { - debugPrint('AuthService: OAuth login failed: $e'); - log('OAuth login failed: $e'); + debugPrint('[AuthService] OAuth login failed: $e'); + log('[AuthService] OAuth login failed: $e'); rethrow; } } - // Restore login state from stored token (for app startup) + // Restore login state from stored token (for app startup — hits network) Future restoreLogin() async { if (_mode == UploadMode.simulate) { final prefs = await SharedPreferences.getInstance(); @@ -116,23 +127,52 @@ class AuthService { } return null; } - + // Get stored token directly from SharedPreferences final accessToken = await getAccessToken(); if (accessToken == null) { return null; } - + + // Try to fetch username from API; fall back to cached name on network + // errors but log out on auth errors (401/403 = token is invalid). try { _displayName = await _fetchUsername(accessToken); - return _displayName; - } catch (e) { - debugPrint('AuthService: Error restoring login with stored token: $e'); - log('Error restoring login with stored token: $e'); - // Token might be expired or invalid, clear it + } on _TokenRejectedException { + debugPrint('[AuthService] Token rejected, logging out'); await logout(); return null; } + if (_displayName == null) { + final prefs = await SharedPreferences.getInstance(); + _displayName = prefs.getString(_cachedDisplayNameKey) ?? ''; + debugPrint('[AuthService] Using cached display name: $_displayName'); + } + return _displayName; + } + + /// Restore login from local cache only (no network). For use during init. + Future restoreLoginLocal() async { + if (_mode == UploadMode.simulate) { + final prefs = await SharedPreferences.getInstance(); + final isLoggedIn = prefs.getBool('sim_user_logged_in') ?? false; + if (isLoggedIn) { + _displayName = 'Demo User'; + return _displayName; + } + return null; + } + + final accessToken = await getAccessToken(); + if (accessToken == null) { + return null; + } + + // We have a token, so restore from cached display name (no network) + final prefs = await SharedPreferences.getInstance(); + _displayName = prefs.getString(_cachedDisplayNameKey) ?? ''; + debugPrint('[AuthService] Restored login locally: $_displayName'); + return _displayName; } Future logout() async { @@ -144,13 +184,17 @@ class AuthService { } final prefs = await SharedPreferences.getInstance(); await prefs.remove(_tokenKey); - await _helper.removeAllTokens(); + await prefs.remove(_cachedDisplayNameKey); + // Only clear oauth2_client tokens if the helper was already initialized + // (i.e., an OAuth flow ran in this mode). If _helper is null, no tokens + // exist in the library's secure storage for the current mode. + await _helper?.removeAllTokens(); _displayName = null; } // Force a fresh login by clearing stored tokens Future forceLogin() async { - await _helper.removeAllTokens(); + await _oauthHelper.removeAllTokens(); _displayName = null; return await login(); } @@ -178,27 +222,43 @@ class AuthService { : 'https://api.openstreetmap.org'; } - final _client = UserAgentClient(); - + /// Fetch username from OSM API. + /// + /// Returns the display name on success, `null` on network/server errors. + /// Throws [_TokenRejectedException] on 401/403 so callers can log out. Future _fetchUsername(String accessToken) async { try { final resp = await _client.get( Uri.parse('$_apiHost/api/0.6/user/details.json'), headers: {'Authorization': 'Bearer $accessToken'}, - ); - + ).timeout(_timeout); + + if (resp.statusCode == 401 || resp.statusCode == 403) { + log('[AuthService] Token rejected (${resp.statusCode})'); + throw _TokenRejectedException(resp.statusCode); + } if (resp.statusCode != 200) { - log('fetchUsername response ${resp.statusCode}: ${resp.body}'); + log('[AuthService] fetchUsername response ${resp.statusCode}: ${resp.body}'); return null; } final userData = jsonDecode(resp.body); final displayName = userData['user']?['display_name']; + if (displayName != null) { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_cachedDisplayNameKey, displayName); + } return displayName; + } on _TokenRejectedException { + rethrow; } catch (e) { - debugPrint('AuthService: Error fetching username: $e'); - log('Error fetching username: $e'); + debugPrint('[AuthService] Error fetching username: $e'); + log('[AuthService] Error fetching username: $e'); return null; } } } +class _TokenRejectedException implements Exception { + final int statusCode; + _TokenRejectedException(this.statusCode); +} diff --git a/lib/services/nuclear_reset_service.dart b/lib/services/nuclear_reset_service.dart index 6c988756..d09ccbd8 100644 --- a/lib/services/nuclear_reset_service.dart +++ b/lib/services/nuclear_reset_service.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'version_service.dart'; @@ -8,8 +9,6 @@ import 'version_service.dart'; /// Nuclear reset service - clears ALL app data when migrations fail. /// This is the "big hammer" approach for when something goes seriously wrong. class NuclearResetService { - static final NuclearResetService _instance = NuclearResetService._(); - factory NuclearResetService() => _instance; NuclearResetService._(); /// Completely clear all app data - SharedPreferences, files, caches, everything. @@ -21,6 +20,9 @@ class NuclearResetService { // Clear ALL SharedPreferences await _clearSharedPreferences(); + // Clear flutter_secure_storage (OAuth tokens from oauth2_client) + await _clearSecureStorage(); + // Clear ALL files in app directories await _clearFileSystem(); @@ -42,6 +44,17 @@ class NuclearResetService { } } + /// Clear flutter_secure_storage (OAuth tokens stored by oauth2_client) + static Future _clearSecureStorage() async { + try { + const storage = FlutterSecureStorage(); + await storage.deleteAll(); + debugPrint('[NuclearReset] Cleared flutter_secure_storage'); + } catch (e) { + debugPrint('[NuclearReset] Failed to clear flutter_secure_storage: $e'); + } + } + /// Clear all files and directories in app storage static Future _clearFileSystem() async { try { diff --git a/lib/services/suspected_location_service.dart b/lib/services/suspected_location_service.dart index 2d8ee879..c41434d3 100644 --- a/lib/services/suspected_location_service.dart +++ b/lib/services/suspected_location_service.dart @@ -31,10 +31,10 @@ class SuspectedLocationService { /// Initialize the service - load from storage and check if refresh needed Future init({bool offlineMode = false}) async { await _loadFromStorage(); - + // Load cache data await _cache.loadFromStorage(); - + // Only auto-fetch if enabled, data is stale or missing, and we are not offline if (_isEnabled && (await _shouldRefresh()) && !offlineMode) { debugPrint('[SuspectedLocationService] Auto-refreshing CSV data on startup (older than $_maxAge or missing)'); @@ -45,6 +45,24 @@ class SuspectedLocationService { } } + /// Fast, local-only init (SharedPrefs + SQLite). No network. + Future initLocal() async { + await _loadFromStorage(); + await _cache.loadFromStorage(); + } + + /// Background refresh if data is stale. Fire-and-forget safe. + Future refreshIfNeeded({bool offlineMode = false}) async { + if (!_isEnabled || !(await _shouldRefresh())) return false; + if (offlineMode) { + final lastFetch = await _cache.lastFetchTime; + debugPrint('[SuspectedLocationService] Skipping background refresh due to offline mode - data is ${lastFetch != null ? 'outdated' : 'missing'}'); + return false; + } + debugPrint('[SuspectedLocationService] Background-refreshing CSV data (older than $_maxAge or missing)'); + return await _fetchData(); + } + /// Enable or disable suspected locations Future setEnabled(bool enabled) async { _isEnabled = enabled; diff --git a/lib/state/auth_state.dart b/lib/state/auth_state.dart index dcf163be..96cdea58 100644 --- a/lib/state/auth_state.dart +++ b/lib/state/auth_state.dart @@ -4,25 +4,46 @@ import '../services/auth_service.dart'; import 'settings_state.dart'; class AuthState extends ChangeNotifier { - final AuthService _auth = AuthService(); + final AuthService _auth; String? _username; + AuthState({AuthService? authService}) : _auth = authService ?? AuthService(); + // Getters bool get isLoggedIn => _username != null; String get username => _username ?? ''; AuthService get authService => _auth; - // Initialize auth state and check existing login + // Initialize auth state — local-only, no network (for fast init) Future init(UploadMode uploadMode) async { _auth.setUploadMode(uploadMode); - + try { if (await _auth.isLoggedIn()) { - _username = await _auth.restoreLogin(); + _username = await _auth.restoreLoginLocal(); } } catch (e) { debugPrint("AuthState: Error during auth initialization: $e"); } + notifyListeners(); + } + + /// Background token validation + display name refresh. Fire-and-forget safe. + Future refreshIfNeeded() async { + try { + if (await _auth.isLoggedIn()) { + final refreshed = await _auth.restoreLogin(); + if (refreshed != _username) { + _username = refreshed; + notifyListeners(); + } + } else if (_username != null) { + _username = null; + notifyListeners(); + } + } catch (e) { + debugPrint("AuthState: Error during background refresh: $e"); + } } Future login() async { @@ -65,28 +86,13 @@ class AuthState extends ChangeNotifier { notifyListeners(); } - Future validateToken() async { - try { - return await _auth.isLoggedIn(); - } catch (e) { - debugPrint("AuthState: Token validation error: $e"); - return false; - } - } - // Handle upload mode changes Future onUploadModeChanged(UploadMode mode) async { _auth.setUploadMode(mode); - - // Refresh user display for active mode, validating token + try { if (await _auth.isLoggedIn()) { - final isValid = await validateToken(); - if (isValid) { - _username = await _auth.restoreLogin(); - } else { - await logout(); // This clears _username also. - } + _username = await _auth.restoreLogin(); } else { _username = null; } @@ -100,4 +106,4 @@ class AuthState extends ChangeNotifier { Future getAccessToken() async { return await _auth.getAccessToken(); } -} \ No newline at end of file +} diff --git a/lib/state/suspected_location_state.dart b/lib/state/suspected_location_state.dart index 5bdaa7b5..0cd61bb9 100644 --- a/lib/state/suspected_location_state.dart +++ b/lib/state/suspected_location_state.dart @@ -4,8 +4,11 @@ import '../models/suspected_location.dart'; import '../services/suspected_location_service.dart'; class SuspectedLocationState extends ChangeNotifier { - final SuspectedLocationService _service = SuspectedLocationService(); - + final SuspectedLocationService _service; + + SuspectedLocationState({SuspectedLocationService? service}) + : _service = service ?? SuspectedLocationService(); + SuspectedLocation? _selectedLocation; bool _isLoading = false; double? _downloadProgress; // 0.0 to 1.0, null when not downloading @@ -61,6 +64,26 @@ class SuspectedLocationState extends ChangeNotifier { notifyListeners(); } + /// Fast, local-only init (SharedPrefs + SQLite). No network. + Future initLocal() async { + try { + await _service.initLocal(); + } catch (e) { + debugPrint('[SuspectedLocationState] initLocal failed: $e'); + } + notifyListeners(); + } + + /// Background refresh if data is stale. Fire-and-forget safe. + Future refreshIfNeeded({bool offlineMode = false}) async { + try { + final didRefresh = await _service.refreshIfNeeded(offlineMode: offlineMode); + if (didRefresh) notifyListeners(); + } catch (e) { + debugPrint('[SuspectedLocationState] Background refresh failed: $e'); + } + } + /// Enable or disable suspected locations Future setEnabled(bool enabled) async { await _service.setEnabled(enabled); diff --git a/test/services/auth_service_test.dart b/test/services/auth_service_test.dart new file mode 100644 index 00000000..066ef55f --- /dev/null +++ b/test/services/auth_service_test.dart @@ -0,0 +1,397 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:deflockapp/app_state.dart' show UploadMode; +import 'package:deflockapp/services/auth_service.dart'; + +class MockHttpClient extends Mock implements http.Client {} + +void main() { + late MockHttpClient mockClient; + late AuthService service; + + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + registerFallbackValue(Uri.parse('https://example.com')); + + // Mock FlutterSecureStorage platform channel so OAuth2Helper.removeAllTokens() + // doesn't throw MissingPluginException in tests. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'), + (MethodCall methodCall) async => null, + ); + }); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + mockClient = MockHttpClient(); + }); + + AuthService createService({UploadMode mode = UploadMode.production}) { + return AuthService(mode: mode, client: mockClient); + } + + group('restoreLogin', () { + test('returns username when token exists and fetch succeeds', () async { + SharedPreferences.setMockInitialValues({ + 'osm_token_prod': jsonEncode({'accessToken': 'valid-token'}), + }); + service = createService(); + + when(() => mockClient.get(any(), headers: any(named: 'headers'))) + .thenAnswer((_) async => http.Response( + jsonEncode({ + 'user': {'display_name': 'TestUser'} + }), + 200, + )); + + final result = await service.restoreLogin(); + + expect(result, equals('TestUser')); + expect(service.displayName, equals('TestUser')); + }); + + test('caches display name on successful fetch', () async { + SharedPreferences.setMockInitialValues({ + 'osm_token_prod': jsonEncode({'accessToken': 'valid-token'}), + }); + service = createService(); + + when(() => mockClient.get(any(), headers: any(named: 'headers'))) + .thenAnswer((_) async => http.Response( + jsonEncode({ + 'user': {'display_name': 'CachedUser'} + }), + 200, + )); + + await service.restoreLogin(); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('cached_display_name_production'), equals('CachedUser')); + }); + + test('falls back to cached name on HTTP error', () async { + SharedPreferences.setMockInitialValues({ + 'osm_token_prod': jsonEncode({'accessToken': 'valid-token'}), + 'cached_display_name_production': 'PreviousUser', + }); + service = createService(); + + when(() => mockClient.get(any(), headers: any(named: 'headers'))) + .thenAnswer((_) async => http.Response('Server Error', 500)); + + final result = await service.restoreLogin(); + + expect(result, equals('PreviousUser')); + expect(service.displayName, equals('PreviousUser')); + }); + + test('falls back to cached name on timeout', () async { + SharedPreferences.setMockInitialValues({ + 'osm_token_prod': jsonEncode({'accessToken': 'valid-token'}), + 'cached_display_name_production': 'TimeoutUser', + }); + service = createService(); + + when(() => mockClient.get(any(), headers: any(named: 'headers'))) + .thenThrow(http.ClientException('Connection timed out')); + + final result = await service.restoreLogin(); + + expect(result, equals('TimeoutUser')); + expect(service.displayName, equals('TimeoutUser')); + }); + + test('returns empty string when fetch fails and no cached name', () async { + SharedPreferences.setMockInitialValues({ + 'osm_token_prod': jsonEncode({'accessToken': 'valid-token'}), + }); + service = createService(); + + when(() => mockClient.get(any(), headers: any(named: 'headers'))) + .thenAnswer((_) async => http.Response('Server Error', 500)); + + final result = await service.restoreLogin(); + + expect(result, equals('')); + expect(service.displayName, equals('')); + }); + + test('logs out on 401 (expired token) instead of falling back to cache', + () async { + SharedPreferences.setMockInitialValues({ + 'osm_token_prod': jsonEncode({'accessToken': 'expired-token'}), + 'cached_display_name_production': 'StaleUser', + }); + service = createService(); + + when(() => mockClient.get(any(), headers: any(named: 'headers'))) + .thenAnswer((_) async => http.Response('Unauthorized', 401)); + + final result = await service.restoreLogin(); + + expect(result, isNull); + expect(service.displayName, isNull); + // Token should be cleared + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('osm_token_prod'), isNull); + }); + + test('logs out on 403 (forbidden) instead of falling back to cache', + () async { + SharedPreferences.setMockInitialValues({ + 'osm_token_prod': jsonEncode({'accessToken': 'bad-token'}), + 'cached_display_name_production': 'StaleUser', + }); + service = createService(); + + when(() => mockClient.get(any(), headers: any(named: 'headers'))) + .thenAnswer((_) async => http.Response('Forbidden', 403)); + + final result = await service.restoreLogin(); + + expect(result, isNull); + expect(service.displayName, isNull); + }); + + test('caches display name per mode (sandbox isolation)', () async { + SharedPreferences.setMockInitialValues({ + 'osm_token_sandbox': jsonEncode({'accessToken': 'sandbox-token'}), + }); + service = createService(mode: UploadMode.sandbox); + + when(() => mockClient.get(any(), headers: any(named: 'headers'))) + .thenAnswer((_) async => http.Response( + jsonEncode({ + 'user': {'display_name': 'SandboxUser'} + }), + 200, + )); + + await service.restoreLogin(); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('cached_display_name_sandbox'), + equals('SandboxUser')); + // Production cache should be untouched + expect(prefs.getString('cached_display_name_production'), isNull); + }); + + test('returns null when no token stored', () async { + SharedPreferences.setMockInitialValues({}); + service = createService(); + + final result = await service.restoreLogin(); + + expect(result, isNull); + verifyNever(() => mockClient.get(any(), headers: any(named: 'headers'))); + }); + + test('returns Demo User in simulate mode when logged in', () async { + SharedPreferences.setMockInitialValues({ + 'sim_user_logged_in': true, + }); + service = createService(mode: UploadMode.simulate); + + final result = await service.restoreLogin(); + + expect(result, equals('Demo User')); + verifyNever(() => mockClient.get(any(), headers: any(named: 'headers'))); + }); + + test('returns null in simulate mode when not logged in', () async { + SharedPreferences.setMockInitialValues({}); + service = createService(mode: UploadMode.simulate); + + final result = await service.restoreLogin(); + + expect(result, isNull); + }); + }); + + group('restoreLoginLocal', () { + test('returns cached display name when token exists', () async { + SharedPreferences.setMockInitialValues({ + 'osm_token_prod': jsonEncode({'accessToken': 'valid-token'}), + 'cached_display_name_production': 'LocalUser', + }); + service = createService(); + + final result = await service.restoreLoginLocal(); + + expect(result, equals('LocalUser')); + expect(service.displayName, equals('LocalUser')); + // Should NOT make any HTTP calls + verifyNever(() => mockClient.get(any(), headers: any(named: 'headers'))); + }); + + test('returns empty string when token exists but no cached name', () async { + SharedPreferences.setMockInitialValues({ + 'osm_token_prod': jsonEncode({'accessToken': 'valid-token'}), + }); + service = createService(); + + final result = await service.restoreLoginLocal(); + + expect(result, equals('')); + expect(service.displayName, equals('')); + verifyNever(() => mockClient.get(any(), headers: any(named: 'headers'))); + }); + + test('returns null when no token stored', () async { + SharedPreferences.setMockInitialValues({}); + service = createService(); + + final result = await service.restoreLoginLocal(); + + expect(result, isNull); + }); + + test('returns Demo User in simulate mode', () async { + SharedPreferences.setMockInitialValues({ + 'sim_user_logged_in': true, + }); + service = createService(mode: UploadMode.simulate); + + final result = await service.restoreLoginLocal(); + + expect(result, equals('Demo User')); + verifyNever(() => mockClient.get(any(), headers: any(named: 'headers'))); + }); + + test('uses correct key per mode (sandbox)', () async { + SharedPreferences.setMockInitialValues({ + 'osm_token_sandbox': jsonEncode({'accessToken': 'sandbox-token'}), + 'cached_display_name_sandbox': 'SandboxLocal', + }); + service = createService(mode: UploadMode.sandbox); + + final result = await service.restoreLoginLocal(); + + expect(result, equals('SandboxLocal')); + }); + }); + + group('isLoggedIn', () { + test('returns true when valid token exists', () async { + SharedPreferences.setMockInitialValues({ + 'osm_token_prod': jsonEncode({'accessToken': 'valid-token'}), + }); + service = createService(); + + expect(await service.isLoggedIn(), isTrue); + }); + + test('returns false when no token stored', () async { + SharedPreferences.setMockInitialValues({}); + service = createService(); + + expect(await service.isLoggedIn(), isFalse); + }); + + test('returns false for malformed JSON token', () async { + SharedPreferences.setMockInitialValues({ + 'osm_token_prod': 'not-valid-json', + }); + service = createService(); + + expect(await service.isLoggedIn(), isFalse); + }); + + test('sandbox mode uses correct key', () async { + SharedPreferences.setMockInitialValues({ + 'osm_token_sandbox': jsonEncode({'accessToken': 'sandbox-token'}), + }); + service = createService(mode: UploadMode.sandbox); + + expect(await service.isLoggedIn(), isTrue); + }); + + test('returns true in simulate mode when sim_user_logged_in', () async { + SharedPreferences.setMockInitialValues({ + 'sim_user_logged_in': true, + }); + service = createService(mode: UploadMode.simulate); + + expect(await service.isLoggedIn(), isTrue); + }); + }); + + group('getAccessToken', () { + test('returns stored token', () async { + SharedPreferences.setMockInitialValues({ + 'osm_token_prod': jsonEncode({'accessToken': 'my-token'}), + }); + service = createService(); + + expect(await service.getAccessToken(), equals('my-token')); + }); + + test('returns sim-user-token in simulate mode', () async { + SharedPreferences.setMockInitialValues({}); + service = createService(mode: UploadMode.simulate); + + expect(await service.getAccessToken(), equals('sim-user-token')); + }); + + test('returns null when no token stored', () async { + SharedPreferences.setMockInitialValues({}); + service = createService(); + + expect(await service.getAccessToken(), isNull); + }); + }); + + group('logout', () { + test('clears token and cached display name', () async { + SharedPreferences.setMockInitialValues({ + 'osm_token_prod': jsonEncode({'accessToken': 'token'}), + 'cached_display_name_production': 'SomeUser', + }); + service = createService(); + + // First restore to set _displayName + when(() => mockClient.get(any(), headers: any(named: 'headers'))) + .thenAnswer((_) async => http.Response( + jsonEncode({ + 'user': {'display_name': 'SomeUser'} + }), + 200, + )); + await service.restoreLogin(); + expect(service.displayName, equals('SomeUser')); + + await service.logout(); + + expect(service.displayName, isNull); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('osm_token_prod'), isNull); + expect(prefs.getString('cached_display_name_production'), isNull); + }); + + test('clears sim_user_logged_in in simulate mode', () async { + SharedPreferences.setMockInitialValues({ + 'sim_user_logged_in': true, + }); + service = createService(mode: UploadMode.simulate); + + await service.restoreLogin(); + expect(service.displayName, equals('Demo User')); + + await service.logout(); + + expect(service.displayName, isNull); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getBool('sim_user_logged_in'), isNull); + }); + }); +} diff --git a/test/state/auth_state_test.dart b/test/state/auth_state_test.dart new file mode 100644 index 00000000..922b0135 --- /dev/null +++ b/test/state/auth_state_test.dart @@ -0,0 +1,261 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:deflockapp/services/auth_service.dart'; +import 'package:deflockapp/state/auth_state.dart'; +import 'package:deflockapp/state/settings_state.dart'; + +class MockAuthService extends Mock implements AuthService {} + +void main() { + late MockAuthService mockAuth; + late AuthState state; + + setUpAll(() { + registerFallbackValue(UploadMode.production); + }); + + setUp(() { + mockAuth = MockAuthService(); + state = AuthState(authService: mockAuth); + }); + + group('init', () { + test('uses restoreLoginLocal (not restoreLogin)', () async { + when(() => mockAuth.setUploadMode(any())).thenReturn(null); + when(() => mockAuth.isLoggedIn()).thenAnswer((_) async => true); + when(() => mockAuth.restoreLoginLocal()) + .thenAnswer((_) async => 'LocalUser'); + + await state.init(UploadMode.production); + + verify(() => mockAuth.restoreLoginLocal()).called(1); + verifyNever(() => mockAuth.restoreLogin()); + expect(state.isLoggedIn, isTrue); + expect(state.username, equals('LocalUser')); + }); + + test('considers user logged in when token exists but no cached display name', () async { + when(() => mockAuth.setUploadMode(any())).thenReturn(null); + when(() => mockAuth.isLoggedIn()).thenAnswer((_) async => true); + when(() => mockAuth.restoreLoginLocal()).thenAnswer((_) async => ''); + + await state.init(UploadMode.production); + + expect(state.isLoggedIn, isTrue); + expect(state.username, equals('')); + }); + + test('not logged in when no stored session', () async { + when(() => mockAuth.setUploadMode(any())).thenReturn(null); + when(() => mockAuth.isLoggedIn()).thenAnswer((_) async => false); + + await state.init(UploadMode.production); + + expect(state.isLoggedIn, isFalse); + }); + + test('handles exception during init gracefully', () async { + when(() => mockAuth.setUploadMode(any())).thenReturn(null); + when(() => mockAuth.isLoggedIn()).thenThrow(Exception('storage error')); + + await state.init(UploadMode.production); + + expect(state.isLoggedIn, isFalse); + }); + }); + + group('refreshIfNeeded', () { + test('updates username from restoreLogin', () async { + when(() => mockAuth.isLoggedIn()).thenAnswer((_) async => true); + when(() => mockAuth.restoreLogin()) + .thenAnswer((_) async => 'RefreshedUser'); + + var notified = false; + state.addListener(() => notified = true); + + await state.refreshIfNeeded(); + + expect(state.username, equals('RefreshedUser')); + expect(notified, isTrue); + }); + + test('does nothing when not logged in and username already null', () async { + when(() => mockAuth.isLoggedIn()).thenAnswer((_) async => false); + + var notified = false; + state.addListener(() => notified = true); + + await state.refreshIfNeeded(); + + verifyNever(() => mockAuth.restoreLogin()); + expect(state.isLoggedIn, isFalse); + expect(notified, isFalse); // No notification needed when nothing changed + }); + + test('clears username when token expired between init and refresh', () async { + // Set up logged-in state via init + when(() => mockAuth.setUploadMode(any())).thenReturn(null); + when(() => mockAuth.isLoggedIn()).thenAnswer((_) async => true); + when(() => mockAuth.restoreLoginLocal()) + .thenAnswer((_) async => 'User'); + await state.init(UploadMode.production); + expect(state.isLoggedIn, isTrue); + + // Token expired — isLoggedIn now returns false + when(() => mockAuth.isLoggedIn()).thenAnswer((_) async => false); + + await state.refreshIfNeeded(); + + expect(state.isLoggedIn, isFalse); + expect(state.username, equals('')); + }); + + test('skips notification when username unchanged', () async { + // First set up logged-in state via init + when(() => mockAuth.setUploadMode(any())).thenReturn(null); + when(() => mockAuth.isLoggedIn()).thenAnswer((_) async => true); + when(() => mockAuth.restoreLoginLocal()) + .thenAnswer((_) async => 'StableUser'); + await state.init(UploadMode.production); + expect(state.username, equals('StableUser')); + + // Now refresh returns same username + when(() => mockAuth.restoreLogin()) + .thenAnswer((_) async => 'StableUser'); + + var notified = false; + state.addListener(() => notified = true); + + await state.refreshIfNeeded(); + + expect(state.username, equals('StableUser')); + expect(notified, isFalse); + }); + + test('catches errors gracefully', () async { + when(() => mockAuth.isLoggedIn()).thenThrow(Exception('network error')); + + await state.refreshIfNeeded(); + + expect(state.isLoggedIn, isFalse); + }); + }); + + group('login', () { + test('sets username on success', () async { + when(() => mockAuth.login()).thenAnswer((_) async => 'NewUser'); + + var notified = false; + state.addListener(() => notified = true); + + await state.login(); + + expect(state.isLoggedIn, isTrue); + expect(state.username, equals('NewUser')); + expect(notified, isTrue); + }); + + test('clears username on failure', () async { + when(() => mockAuth.login()).thenThrow(Exception('network error')); + + await state.login(); + + expect(state.isLoggedIn, isFalse); + }); + }); + + group('logout', () { + test('clears state and notifies', () async { + // Set up logged in state first + when(() => mockAuth.login()).thenAnswer((_) async => 'User'); + await state.login(); + expect(state.isLoggedIn, isTrue); + + when(() => mockAuth.logout()).thenAnswer((_) async {}); + + var notified = false; + state.addListener(() => notified = true); + + await state.logout(); + + expect(state.isLoggedIn, isFalse); + expect(notified, isTrue); + }); + }); + + group('forceLogin', () { + test('sets username on success', () async { + when(() => mockAuth.forceLogin()).thenAnswer((_) async => 'ForcedUser'); + + var notified = false; + state.addListener(() => notified = true); + + await state.forceLogin(); + + expect(state.isLoggedIn, isTrue); + expect(state.username, equals('ForcedUser')); + expect(notified, isTrue); + }); + + test('clears username on failure', () async { + when(() => mockAuth.forceLogin()).thenThrow(Exception('OAuth error')); + + await state.forceLogin(); + + expect(state.isLoggedIn, isFalse); + expect(state.username, equals('')); + }); + + test('notifies listeners even on failure', () async { + when(() => mockAuth.forceLogin()).thenThrow(Exception('OAuth error')); + + var notified = false; + state.addListener(() => notified = true); + + await state.forceLogin(); + + expect(notified, isTrue); + }); + }); + + group('onUploadModeChanged', () { + test('refreshes auth for new mode', () async { + when(() => mockAuth.setUploadMode(any())).thenReturn(null); + when(() => mockAuth.isLoggedIn()).thenAnswer((_) async => true); + when(() => mockAuth.restoreLogin()) + .thenAnswer((_) async => 'SandboxUser'); + + await state.onUploadModeChanged(UploadMode.sandbox); + + verify(() => mockAuth.setUploadMode(UploadMode.sandbox)).called(1); + expect(state.username, equals('SandboxUser')); + }); + + test('clears username when restoreLogin returns null for new mode', () async { + when(() => mockAuth.setUploadMode(any())).thenReturn(null); + when(() => mockAuth.isLoggedIn()).thenAnswer((_) async => true); + when(() => mockAuth.restoreLogin()).thenAnswer((_) async => null); + + await state.onUploadModeChanged(UploadMode.sandbox); + + expect(state.isLoggedIn, isFalse); + expect(state.username, equals('')); + }); + + test('clears username when not logged in for new mode', () async { + // First set up a logged-in state + when(() => mockAuth.login()).thenAnswer((_) async => 'User'); + await state.login(); + expect(state.isLoggedIn, isTrue); + + // Switch mode where user is not logged in + when(() => mockAuth.setUploadMode(any())).thenReturn(null); + when(() => mockAuth.isLoggedIn()).thenAnswer((_) async => false); + + await state.onUploadModeChanged(UploadMode.sandbox); + + expect(state.isLoggedIn, isFalse); + }); + }); +} diff --git a/test/state/suspected_location_state_test.dart b/test/state/suspected_location_state_test.dart new file mode 100644 index 00000000..ba24e4f1 --- /dev/null +++ b/test/state/suspected_location_state_test.dart @@ -0,0 +1,120 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:deflockapp/models/suspected_location.dart'; +import 'package:deflockapp/services/suspected_location_service.dart'; +import 'package:deflockapp/state/suspected_location_state.dart'; + +class MockSuspectedLocationService extends Mock + implements SuspectedLocationService {} + +void main() { + late MockSuspectedLocationService mockService; + late SuspectedLocationState state; + + setUp(() { + mockService = MockSuspectedLocationService(); + state = SuspectedLocationState(service: mockService); + }); + + group('initLocal', () { + test('calls service initLocal and notifies listeners', () async { + when(() => mockService.initLocal()).thenAnswer((_) async {}); + when(() => mockService.isEnabled).thenReturn(true); + + var notified = false; + state.addListener(() => notified = true); + + await state.initLocal(); + + verify(() => mockService.initLocal()).called(1); + expect(notified, isTrue); + expect(state.isEnabled, isTrue); + }); + + test('catches service errors gracefully', () async { + when(() => mockService.initLocal()) + .thenThrow(Exception('storage error')); + + await state.initLocal(); + + // Should not throw — error is caught and logged + expect(state.isLoading, isFalse); + }); + }); + + group('refreshIfNeeded', () { + test('notifies listeners after successful refresh', () async { + when(() => mockService.refreshIfNeeded(offlineMode: false)) + .thenAnswer((_) async => true); + + var notified = false; + state.addListener(() => notified = true); + + await state.refreshIfNeeded(); + + expect(notified, isTrue); + }); + + test('skips notification when service reports no data change', () async { + when(() => mockService.refreshIfNeeded(offlineMode: false)) + .thenAnswer((_) async => false); + + var notified = false; + state.addListener(() => notified = true); + + await state.refreshIfNeeded(); + + expect(notified, isFalse); + }); + + test('catches service errors without crashing', () async { + when(() => mockService.refreshIfNeeded(offlineMode: false)) + .thenThrow(Exception('network error')); + + await state.refreshIfNeeded(); + + expect(state.isLoading, isFalse); + }); + }); + + group('selection', () { + test('select sets selected location and notifies', () { + final location = SuspectedLocation( + ticketNo: 'T-001', + centroid: const LatLng(38.9, -77.0), + bounds: const [], + allFields: const {}, + ); + + var notified = false; + state.addListener(() => notified = true); + + state.selectLocation(location); + + expect(state.selectedLocation, equals(location)); + expect(notified, isTrue); + }); + + test('clearSelection clears and notifies', () { + final location = SuspectedLocation( + ticketNo: 'T-002', + centroid: const LatLng(38.9, -77.0), + bounds: const [], + allFields: const {}, + ); + + state.selectLocation(location); + expect(state.selectedLocation, isNotNull); + + var notified = false; + state.addListener(() => notified = true); + + state.clearSelection(); + + expect(state.selectedLocation, isNull); + expect(notified, isTrue); + }); + }); +}