diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index 238ecc5..0c506cc 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -34,8 +34,10 @@ class AppConfig { } /// Get the API base URL + @Deprecated('FDK uses WebSocket-only communication. ' + 'Use EnvironmentConfig.webSocketUrl instead.') static String get apiBaseUrl { - // This is deprecated - use EnvironmentConfig.apiBaseUrl instead + // ignore: deprecated_member_use_from_same_package return EnvironmentConfig.apiBaseUrl; } diff --git a/lib/core/config/environment.dart b/lib/core/config/environment.dart index 325e3db..b368443 100644 --- a/lib/core/config/environment.dart +++ b/lib/core/config/environment.dart @@ -8,6 +8,7 @@ class EnvironmentConfig { static void setEnvironment(Environment env) { _environment = env; + warnIfCompileTimeCredentials(); // Only log in debug mode to avoid memory issues if (kDebugMode) { debugPrint( @@ -27,7 +28,10 @@ class EnvironmentConfig { static bool get isStaging => _environment == Environment.staging; static bool get isProduction => _environment == Environment.production; - /// API Configuration + /// REST API base URL - currently unused as the app uses WebSocket-only + /// communication. Retained for potential future REST endpoint needs. + @Deprecated('FDK uses WebSocket-only communication. ' + 'Use webSocketUrl instead for all data operations.') static String get apiBaseUrl { switch (_environment) { case Environment.development: @@ -124,6 +128,11 @@ class EnvironmentConfig { /// API Credentials /// + /// SECURITY NOTE: Values provided via --dart-define are compiled as string + /// constants into the binary and can be extracted from APK/IPA files. + /// For production builds, use runtime credential injection (QR code scanning + /// or manual entry) instead of compile-time constants. + /// /// For staging/production, credentials MUST be provided via environment variables: /// - STAGING_API_LOGIN / API_USERNAME /// - STAGING_API_KEY or STAGING_TOKEN / API_KEY or WS_TOKEN @@ -210,6 +219,24 @@ class EnvironmentConfig { } } + /// Checks if compile-time credentials are present and logs a security + /// warning if they are being used in a release build. + /// Call this during app initialization to alert developers. + static void warnIfCompileTimeCredentials() { + const apiKey = String.fromEnvironment('API_KEY', defaultValue: ''); + const wsToken = String.fromEnvironment('WS_TOKEN', defaultValue: ''); + const apiUsername = String.fromEnvironment('API_USERNAME', defaultValue: ''); + + if (!kDebugMode && (apiKey.isNotEmpty || wsToken.isNotEmpty || apiUsername.isNotEmpty)) { + debugPrint( + 'SECURITY WARNING: Compile-time credentials detected in a non-debug build. ' + 'Values passed via --dart-define are embedded as string constants in the binary ' + 'and can be extracted from APK/IPA files. For production, use runtime credential ' + 'injection (QR code scanning or manual entry) instead.', + ); + } + } + /// Sentry DSN static String get sentryDsn { if (isDevelopment) { diff --git a/lib/core/security/certificate_validator.dart b/lib/core/security/certificate_validator.dart index 9d783de..65bdbf7 100644 --- a/lib/core/security/certificate_validator.dart +++ b/lib/core/security/certificate_validator.dart @@ -64,12 +64,27 @@ class CertificateValidator { return false; } - // Accept valid certificates (matches ATT-FE-Tool behavior) - LoggerService.debug( - 'Accepting non-self-signed certificate for $host:$port', + // This callback is only invoked when the platform's default validation + // has already FAILED. Accepting here would bypass chain-of-trust checks + // (wrong hostname, untrusted CA, revoked cert) and enable MITM attacks. + if (kDebugMode) { + LoggerService.warning( + 'DEBUG MODE: Accepting platform-rejected certificate for $host:$port. ' + 'This certificate failed platform validation (possible untrusted CA, ' + 'hostname mismatch, or revocation). Only accepted because debug mode is active.', + tag: 'CertificateValidator', + ); + return true; // Accept in debug only for local development + } + + LoggerService.error( + 'CERTIFICATE REJECTED: $host:$port. ' + 'The certificate failed platform TLS validation (untrusted CA, ' + 'hostname mismatch, or revoked). Rejecting to prevent potential ' + 'man-in-the-middle attacks.', tag: 'CertificateValidator', ); - return true; + return false; // REJECT in production - do not bypass platform validation } catch (e, stack) { LoggerService.error( 'Certificate validation error for $host:$port', diff --git a/lib/core/services/websocket_channel_factory_web.dart b/lib/core/services/websocket_channel_factory_web.dart index cc7a301..6d37b51 100644 --- a/lib/core/services/websocket_channel_factory_web.dart +++ b/lib/core/services/websocket_channel_factory_web.dart @@ -1,8 +1,22 @@ +import 'package:rgnets_fdk/core/services/logger_service.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; +/// Creates a WebSocket channel for the web platform. +/// +/// Note: Browser WebSocket APIs do not support custom headers. +/// The [headers] parameter (including Authorization) is ignored on web. +/// Authentication must rely on query parameters (e.g., api_key) instead. WebSocketChannel createWebSocketChannel( Uri uri, { Map? headers, }) { + if (headers != null && headers.isNotEmpty) { + LoggerService.warning( + 'WebSocket headers are not supported on web platform. ' + 'Authorization header will not be sent. ' + 'Ensure api_key query parameter is included for authentication.', + tag: 'WebSocketChannelFactory', + ); + } return WebSocketChannel.connect(uri); } diff --git a/lib/core/services/websocket_data_sync_service.dart b/lib/core/services/websocket_data_sync_service.dart index 84649bd..58cccd4 100644 --- a/lib/core/services/websocket_data_sync_service.dart +++ b/lib/core/services/websocket_data_sync_service.dart @@ -12,6 +12,12 @@ import 'package:rgnets_fdk/features/devices/data/models/room_model.dart'; import 'package:rgnets_fdk/features/onboarding/data/models/onboarding_status_payload.dart'; import 'package:rgnets_fdk/features/rooms/data/datasources/room_local_data_source.dart'; +// ARCHITECTURE NOTE (M1): This service imports from features/ layer, violating +// Clean Architecture (core should not depend on features). This is a known +// pattern across the codebase (16 core files import from features). Moving this +// service alone would create inconsistency. A holistic architecture refactoring +// using dependency inversion (abstract interfaces in core, implementations in +// features) is the recommended approach for a future dedicated sprint. class WebSocketDataSyncService { WebSocketDataSyncService({ required WebSocketService socketService, @@ -102,7 +108,8 @@ class WebSocketDataSyncService { _wlanLocalDataSource.dispose(); } - Future syncInitialData({ + /// Returns true if sync completed successfully, false if it timed out. + Future syncInitialData({ Duration timeout = const Duration(seconds: 45), }) async { await start(); @@ -115,15 +122,19 @@ class WebSocketDataSyncService { _initialSyncCompleter = Completer(); _requestSnapshots(); + var timedOut = false; await _initialSyncCompleter!.future.timeout( timeout, onTimeout: () { - _logger.w('WebSocketDataSync: Initial sync timed out'); - return; + _logger.w( + 'WebSocketDataSync: Initial sync timed out after ${timeout.inSeconds}s. ' + 'Pending: $_pendingSnapshots', + ); + timedOut = true; }, ); - // Flush all typed caches to storage + // Flush all typed caches to storage (may be partial on timeout) await _flushAllDeviceCaches(); // Wait for any pending room cache operations @@ -133,10 +144,14 @@ class WebSocketDataSyncService { const Duration(seconds: 30), onTimeout: () { _logger.w('WebSocketDataSync: Room cache timed out'); + timedOut = true; }, ); } - _logger.i('WebSocketDataSync: Cache operations completed'); + _logger.i( + 'WebSocketDataSync: Cache operations completed${timedOut ? ' (partial - timed out)' : ''}', + ); + return !timedOut; } /// Flush all typed device caches to storage @@ -277,22 +292,14 @@ class WebSocketDataSyncService { List>? _extractSnapshotItems(SocketMessage message) { final payload = message.payload; - if (payload['results'] is List) { - return (payload['results'] as List) - .whereType>() - .toList(); - } + // Backend (RxgWebsocketCrudService) uses 'data' key for response arrays if (payload['data'] is List) { return (payload['data'] as List) .whereType>() .toList(); } - if (payload['items'] is List) { - return (payload['items'] as List) - .whereType>() - .toList(); - } - if (payload['results'] is List) { + // Fallback for legacy or alternative response formats + if (payload['results'] is List) { return (payload['results'] as List) .whereType>() .toList(); @@ -370,7 +377,9 @@ class WebSocketDataSyncService { } } // Always cache to clear stale data when snapshot is empty - unawaited(_apLocalDataSource.cacheDevices(models)); + unawaited(_apLocalDataSource.cacheDevices(models).catchError((Object e) { + _logger.e('WebSocketDataSync: Failed to cache APs: $e'); + })); _logger.d('WebSocketDataSync: Cached ${models.length} APs'); if (models.isNotEmpty) { _emitDevicesCached(models.length); @@ -389,7 +398,9 @@ class WebSocketDataSyncService { } } // Always cache to clear stale data when snapshot is empty - unawaited(_ontLocalDataSource.cacheDevices(models)); + unawaited(_ontLocalDataSource.cacheDevices(models).catchError((Object e) { + _logger.e('WebSocketDataSync: Failed to cache ONTs: $e'); + })); _logger.d('WebSocketDataSync: Cached ${models.length} ONTs'); if (models.isNotEmpty) { _emitDevicesCached(models.length); @@ -408,7 +419,9 @@ class WebSocketDataSyncService { } } // Always cache to clear stale data when snapshot is empty - unawaited(_switchLocalDataSource.cacheDevices(models)); + unawaited(_switchLocalDataSource.cacheDevices(models).catchError((Object e) { + _logger.e('WebSocketDataSync: Failed to cache Switches: $e'); + })); _logger.d('WebSocketDataSync: Cached ${models.length} Switches'); if (models.isNotEmpty) { _emitDevicesCached(models.length); @@ -427,7 +440,9 @@ class WebSocketDataSyncService { } } // Always cache to clear stale data when snapshot is empty - unawaited(_wlanLocalDataSource.cacheDevices(models)); + unawaited(_wlanLocalDataSource.cacheDevices(models).catchError((Object e) { + _logger.e('WebSocketDataSync: Failed to cache WLANs: $e'); + })); _logger.d('WebSocketDataSync: Cached ${models.length} WLANs'); if (models.isNotEmpty) { _emitDevicesCached(models.length); @@ -460,8 +475,7 @@ class WebSocketDataSyncService { _roomSnapshots ..clear() ..['rooms.summary'] = models; - _pendingRoomCache = _cacheRooms(models); - unawaited(_pendingRoomCache); + _chainRoomCache(models); return; } @@ -471,11 +485,20 @@ class WebSocketDataSyncService { for (final entry in _roomResources) { combined.addAll(_roomSnapshots[entry] ?? const []); } - _pendingRoomCache = _cacheRooms(combined); - unawaited(_pendingRoomCache); + _chainRoomCache(combined); } } + /// Chains a new room cache operation onto any pending one, preventing + /// the race condition where _pendingRoomCache gets overwritten while + /// syncInitialData is awaiting the previous future. + void _chainRoomCache(List rooms) { + final previous = _pendingRoomCache ?? Future.value(); + _pendingRoomCache = previous.then((_) => _cacheRooms(rooms)).catchError((Object e) { + _logger.e('WebSocketDataSync: Failed to cache rooms: $e'); + }); + } + Future _cacheRooms(List rooms) async { _logger.i('WebSocketDataSync: Caching ${rooms.length} rooms'); await _roomLocalDataSource.cacheRooms(rooms); @@ -607,25 +630,22 @@ class WebSocketDataSyncService { } String _determineStatus(Map device) { + // Backend uses 'online' boolean field (AccessPoint.online) final onlineFlag = device['online'] as bool?; - final activeFlag = device['active'] as bool?; - if (onlineFlag != null) { return onlineFlag ? 'online' : 'offline'; } - if (device['status']?.toString().toLowerCase() == 'online') { - return 'online'; - } - if (device['status']?.toString().toLowerCase() == 'offline') { - return 'offline'; - } - if (activeFlag != null) { - return activeFlag ? 'online' : 'offline'; + + // Fallback: check string 'status' field + final statusStr = device['status']?.toString().toLowerCase(); + if (statusStr == 'online' || statusStr == 'offline') { + return statusStr!; } - if (device['last_seen'] != null || device['updated_at'] != null) { + // Derive status from last_seen_at/last_seen timestamp (produces 'warning') + if (device['last_seen_at'] != null || device['last_seen'] != null || device['updated_at'] != null) { try { - final lastSeenStr = (device['last_seen'] ?? device['updated_at']) + final lastSeenStr = (device['last_seen_at'] ?? device['last_seen'] ?? device['updated_at']) .toString(); final lastSeen = DateTime.parse(lastSeenStr); final now = DateTime.now(); @@ -692,17 +712,10 @@ class WebSocketDataSyncService { } List? _extractImages(Map deviceMap) { - final imageKeys = [ + // Backend uses 'images' key (has_many_base64_attached :images) + const imageKeys = [ 'images', - 'image', - 'image_url', - 'imageUrl', - 'photos', - 'photo', - 'photo_url', - 'photoUrl', - 'device_images', - 'device_image', + 'image', // Fallback for singular image field ]; for (final key in imageKeys) { diff --git a/lib/core/services/websocket_service.dart b/lib/core/services/websocket_service.dart index 301d0ab..95df431 100644 --- a/lib/core/services/websocket_service.dart +++ b/lib/core/services/websocket_service.dart @@ -5,6 +5,7 @@ import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:logger/logger.dart'; import 'package:rgnets_fdk/core/services/websocket_channel_factory.dart'; +import 'package:uuid/uuid.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; /// Connection states emitted by [WebSocketService]. @@ -110,6 +111,7 @@ class WebSocketService { int _reconnectAttempts = 0; int _consecutiveReconnectFailures = 0; bool _manuallyClosed = false; + bool _isReconnecting = false; /// Maximum consecutive reconnect failures before emitting auth failure signal. static const int _maxReconnectBeforeAuthCheck = 3; @@ -143,9 +145,11 @@ class WebSocketService { } /// Disconnects from the socket and prevents automatic reconnection. + /// All pending requests are immediately failed with a descriptive error. Future disconnect({int? code, String? reason}) async { _manuallyClosed = true; _reconnectAttempts = 0; + _failPendingRequests('WebSocket disconnected'); await _closeChannel(code: code, reason: reason); _updateState(SocketConnectionState.disconnected); } @@ -172,9 +176,11 @@ class WebSocketService { }); } + static const _uuid = Uuid(); + /// Generates a unique request ID. String _generateRequestId() { - return '${DateTime.now().millisecondsSinceEpoch}-${Random().nextInt(100000)}'; + return '${DateTime.now().millisecondsSinceEpoch}-${_uuid.v4()}'; } /// Sends a request and waits for a response with matching request_id. @@ -257,7 +263,7 @@ class WebSocketService { : SocketConnectionState.connecting, ); try { - _logger.i('WebSocketService: Connecting to ${params.uri}'); + _logger.i('WebSocketService: Connecting to ${_sanitizeUri(params.uri)}'); final channel = _channelFactory(params.uri, headers: params.headers); _channel = channel; @@ -383,43 +389,52 @@ class WebSocketService { } Future _scheduleReconnect() async { - if (_currentParams == null) { - _logger.w('WebSocketService: No connection params for reconnect'); + if (_isReconnecting) { + _logger.d('WebSocketService: Reconnect already in progress, skipping'); return; } - _reconnectAttempts += 1; - final delay = _computeBackoffDelay(_reconnectAttempts); - _logger.i('WebSocketService: Reconnecting in ${delay.inMilliseconds}ms'); - _updateState(SocketConnectionState.reconnecting); - await Future.delayed(delay); - if (_manuallyClosed) { - _logger.d('WebSocketService: Reconnect aborted (manually closed)'); + if (_currentParams == null) { + _logger.w('WebSocketService: No connection params for reconnect'); return; } + _isReconnecting = true; + try { + _reconnectAttempts += 1; + final delay = _computeBackoffDelay(_reconnectAttempts); + _logger.i('WebSocketService: Reconnecting in ${delay.inMilliseconds}ms'); + _updateState(SocketConnectionState.reconnecting); + await Future.delayed(delay); + if (_manuallyClosed) { + _logger.d('WebSocketService: Reconnect aborted (manually closed)'); + return; + } - // Store state before reconnect attempt - final wasConnected = _state == SocketConnectionState.connected; - - await _open(_currentParams!); + // Store state before reconnect attempt + final wasConnected = _state == SocketConnectionState.connected; - // Track reconnection success/failure - if (_state == SocketConnectionState.connected) { - // Reconnect succeeded - reset failure counter - _consecutiveReconnectFailures = 0; - } else if (!wasConnected) { - // Reconnect failed - _consecutiveReconnectFailures++; - _logger.w( - 'WebSocketService: Reconnect failure #$_consecutiveReconnectFailures', - ); + await _open(_currentParams!); - if (_consecutiveReconnectFailures >= _maxReconnectBeforeAuthCheck) { + // Track reconnection success/failure + if (_state == SocketConnectionState.connected) { + // Reconnect succeeded - reset failure counter + _consecutiveReconnectFailures = 0; + } else if (!wasConnected) { + // Reconnect failed + _consecutiveReconnectFailures++; _logger.w( - 'WebSocketService: Multiple reconnect failures ($_consecutiveReconnectFailures), ' - 'may indicate auth issue', + 'WebSocketService: Reconnect failure #$_consecutiveReconnectFailures', ); - _authFailureController.add(_consecutiveReconnectFailures); + + if (_consecutiveReconnectFailures >= _maxReconnectBeforeAuthCheck) { + _logger.w( + 'WebSocketService: Multiple reconnect failures ($_consecutiveReconnectFailures), ' + 'may indicate auth issue', + ); + _authFailureController.add(_consecutiveReconnectFailures); + } } + } finally { + _isReconnecting = false; } } @@ -458,22 +473,30 @@ class WebSocketService { }); } - _heartbeatWatchdog = Timer.periodic(const Duration(seconds: 5), (_) { - if (_lastHeartbeat == null) { - return; - } - final diff = DateTime.now().difference(_lastHeartbeat!); - if (diff > _config.heartbeatTimeout) { - _logger.w( - 'WebSocketService: Heartbeat timeout after ${diff.inSeconds}s, closing connection', - ); - unawaited( - _handleError( - TimeoutException('Heartbeat timeout (${diff.inSeconds}s)'), - ), - ); - } - }); + // Only run the heartbeat watchdog when client pings are enabled. + // Without client pings, there is no guaranteed periodic traffic, so the + // watchdog would trigger spurious reconnects on idle connections. + // ActionCable servers typically send their own pings (~3s), but if client + // pings are disabled we cannot guarantee the server will keep the + // connection alive within the timeout window. + if (_config.sendClientPing) { + _heartbeatWatchdog = Timer.periodic(const Duration(seconds: 5), (_) { + if (_lastHeartbeat == null) { + return; + } + final diff = DateTime.now().difference(_lastHeartbeat!); + if (diff > _config.heartbeatTimeout) { + _logger.w( + 'WebSocketService: Heartbeat timeout after ${diff.inSeconds}s, closing connection', + ); + unawaited( + _handleError( + TimeoutException('Heartbeat timeout (${diff.inSeconds}s)'), + ), + ); + } + }); + } } Future _closeChannel({int? code, String? reason}) async { @@ -487,6 +510,19 @@ class WebSocketService { _channel = null; } + /// Strips sensitive query parameters from a URI before logging. + static String _sanitizeUri(Uri uri) { + const sensitiveKeys = {'api_key', 'token', 'secret', 'password', 'key'}; + if (uri.queryParameters.isEmpty) return uri.toString(); + final sanitized = Map.from(uri.queryParameters); + for (final key in sanitized.keys.toList()) { + if (sensitiveKeys.contains(key.toLowerCase())) { + sanitized[key] = '[REDACTED]'; + } + } + return uri.replace(queryParameters: sanitized).toString(); + } + void _updateState(SocketConnectionState newState) { if (_state == newState) { return; @@ -495,19 +531,20 @@ class WebSocketService { _stateController.add(newState); } - /// Releases resources. Call when the service is no longer needed. - Future dispose() async { - // Cancel all pending requests + /// Fails all pending requests with the given reason. + void _failPendingRequests(String reason) { for (final pending in _pendingRequests.values) { pending.cancel(); if (!pending.completer.isCompleted) { - pending.completer.completeError( - StateError('WebSocket service disposed'), - ); + pending.completer.completeError(StateError(reason)); } } _pendingRequests.clear(); + } + /// Releases resources. Call when the service is no longer needed. + Future dispose() async { + _failPendingRequests('WebSocket service disposed'); await disconnect(); await _stateController.close(); await _messageController.close(); diff --git a/lib/features/auth/data/services/action_cable_auth_service.dart b/lib/features/auth/data/services/action_cable_auth_service.dart new file mode 100644 index 0000000..704aed4 --- /dev/null +++ b/lib/features/auth/data/services/action_cable_auth_service.dart @@ -0,0 +1,206 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:logger/logger.dart'; +import 'package:rgnets_fdk/core/config/environment.dart'; +import 'package:rgnets_fdk/core/services/websocket_service.dart'; + +/// Result of an ActionCable authentication handshake. +class ActionCableAuthResult { + const ActionCableAuthResult.success() + : success = true, + message = ''; + const ActionCableAuthResult.failure(this.message) : success = false; + + final bool success; + final String message; +} + +/// Handles the ActionCable WebSocket handshake protocol. +/// +/// This service is responsible for: +/// - Building the ActionCable URI with authentication query parameters +/// - Connecting to the WebSocket server +/// - Subscribing to the RxgChannel +/// - Waiting for subscription confirmation or rejection +/// +/// It does NOT manage authentication state (credentials, sessions, etc.). +/// That responsibility remains with the AuthNotifier. +class ActionCableAuthService { + ActionCableAuthService({ + required WebSocketService webSocketService, + required Logger logger, + }) : _service = webSocketService, + _logger = logger; + + final WebSocketService _service; + final Logger _logger; + + /// Performs the ActionCable handshake: connect, subscribe, await confirmation. + /// + /// Returns [ActionCableAuthResult.success] if the subscription is confirmed, + /// or [ActionCableAuthResult.failure] with a reason string otherwise. + Future performHandshake({ + required String fqdn, + required String token, + required Uri baseUri, + Duration connectionTimeout = const Duration(seconds: 10), + Duration subscriptionTimeout = const Duration(seconds: 15), + }) async { + final uri = buildActionCableUri( + baseUri: baseUri, + fqdn: fqdn, + token: token, + ); + final headers = buildAuthHeaders(token); + + if (_service.isConnected) { + await _service.disconnect(code: 4000, reason: 'Re-authenticating'); + } + + final identifier = jsonEncode(const {'channel': 'RxgChannel'}); + final subscriptionPayload = { + 'command': 'subscribe', + 'identifier': identifier, + }; + + final completer = Completer(); + + final subscription = _service.messages.listen((message) { + _logger.d( + 'ActionCableAuth: message received: ' + 'type=${message.type}, payload=${message.payload}', + ); + if (message.type == 'confirm_subscription' && + _identifierMatches(message, identifier)) { + _logger.i('ActionCableAuth: Subscription confirmed'); + if (!completer.isCompleted) { + completer.complete(const ActionCableAuthResult.success()); + } + } else if (message.type == 'reject_subscription' && + _identifierMatches(message, identifier)) { + _logger.e('ActionCableAuth: Subscription REJECTED by server'); + if (!completer.isCompleted) { + completer.complete( + const ActionCableAuthResult.failure( + 'Subscription rejected by server', + ), + ); + } + } else if (message.type == 'disconnect') { + final reason = message.payload['reason'] as String? ?? + message.payload['message'] as String?; + _logger.e('ActionCableAuth: Server sent disconnect: $reason'); + if (!completer.isCompleted) { + completer.complete( + ActionCableAuthResult.failure( + reason ?? 'Connection closed by server', + ), + ); + } + } + }); + + final stateSubscription = _service.connectionState.listen((connState) { + _logger.d('ActionCableAuth: connection state changed: $connState'); + if (connState == SocketConnectionState.disconnected && + !completer.isCompleted) { + _logger.e( + 'ActionCableAuth: Connection closed before subscription confirmed', + ); + completer.complete( + const ActionCableAuthResult.failure( + 'Connection closed before subscription confirmed', + ), + ); + } + }); + + _logger + ..i('ActionCableAuth: Initiating WebSocket handshake') + ..d('ActionCableAuth: WebSocket URI: $uri') + ..d('ActionCableAuth: Subscription identifier: $identifier'); + + try { + _logger.d('ActionCableAuth: Calling service.connect()...'); + await _service + .connect(WebSocketConnectionParams(uri: uri, headers: headers)) + .timeout( + connectionTimeout, + onTimeout: () { + _logger.e( + 'ActionCableAuth: Connection TIMED OUT ' + 'after ${connectionTimeout.inSeconds} seconds', + ); + throw TimeoutException('Connection to server timed out'); + }, + ); + + _logger.d('ActionCableAuth: Connected, sending subscription...'); + _service.send(subscriptionPayload); + _logger.d( + 'ActionCableAuth: Subscription sent, ' + 'waiting for confirmation (${subscriptionTimeout.inSeconds}s timeout)...', + ); + + final result = await completer.future.timeout( + subscriptionTimeout, + onTimeout: () { + _logger.e( + 'ActionCableAuth: Handshake TIMED OUT ' + 'after ${subscriptionTimeout.inSeconds} seconds', + ); + return const ActionCableAuthResult.failure( + 'WebSocket handshake timed out', + ); + }, + ); + + return result; + } finally { + await subscription.cancel(); + await stateSubscription.cancel(); + } + } + + /// Builds the ActionCable WebSocket URI with authentication query parameters. + static Uri buildActionCableUri({ + required Uri baseUri, + required String fqdn, + required String token, + }) { + final useBaseUri = EnvironmentConfig.isDevelopment; + final uri = useBaseUri + ? baseUri + : Uri( + scheme: 'wss', + host: fqdn, + path: '/cable', + ); + + final queryParameters = Map.from(uri.queryParameters); + if (token.isNotEmpty) { + queryParameters['api_key'] = token; + } + + return uri.replace( + queryParameters: queryParameters.isEmpty ? null : queryParameters, + ); + } + + /// Builds authorization headers for the WebSocket connection. + static Map buildAuthHeaders(String token) { + if (token.isEmpty) { + return const {}; + } + return {'Authorization': 'Bearer $token'}; + } + + static bool _identifierMatches(SocketMessage message, String identifier) { + final headerIdentifier = message.headers?['identifier']; + if (headerIdentifier is String && headerIdentifier.isNotEmpty) { + return headerIdentifier == identifier; + } + return false; + } +} diff --git a/lib/features/auth/presentation/providers/auth_notifier.dart b/lib/features/auth/presentation/providers/auth_notifier.dart index 6c0b201..85d3215 100644 --- a/lib/features/auth/presentation/providers/auth_notifier.dart +++ b/lib/features/auth/presentation/providers/auth_notifier.dart @@ -1,20 +1,18 @@ import 'dart:async'; -import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:logger/logger.dart'; -import 'package:rgnets_fdk/core/config/environment.dart'; import 'package:rgnets_fdk/core/models/api_key_revocation_event.dart'; import 'package:rgnets_fdk/core/providers/core_providers.dart'; import 'package:rgnets_fdk/core/providers/repository_providers.dart'; import 'package:rgnets_fdk/core/providers/websocket_providers.dart'; import 'package:rgnets_fdk/core/providers/websocket_sync_providers.dart'; import 'package:rgnets_fdk/core/services/cache_manager.dart'; -import 'package:rgnets_fdk/core/services/websocket_service.dart'; import 'package:rgnets_fdk/features/auth/data/models/auth_attempt.dart'; import 'package:rgnets_fdk/features/auth/data/models/user_model.dart'; +import 'package:rgnets_fdk/features/auth/data/services/action_cable_auth_service.dart'; import 'package:rgnets_fdk/features/auth/domain/entities/auth_status.dart'; import 'package:rgnets_fdk/features/auth/domain/entities/user.dart'; import 'package:rgnets_fdk/features/auth/domain/usecases/authenticate_user.dart'; @@ -34,6 +32,13 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'auth_notifier.g.dart'; +/// Provides the [ActionCableAuthService] for WebSocket handshake protocol. +final actionCableAuthServiceProvider = Provider((ref) { + final service = ref.watch(webSocketServiceProvider); + final logger = ref.read(loggerProvider); + return ActionCableAuthService(webSocketService: service, logger: logger); +}); + // Modern Riverpod 2.0+ best practice: Use @Riverpod (capitalized) for classes // Use AsyncNotifier to match existing usage patterns @Riverpod(keepAlive: true) // Keep alive to maintain auth state @@ -147,15 +152,11 @@ class Auth extends _$Auth { } try { - // Get auth signature from secure storage - final signature = await storage.getAuthSignature(); final resolvedUser = await _performWebSocketHandshake( fqdn: fqdn, login: username, token: token, siteName: storage.siteName ?? fqdn, - issuedAt: storage.authIssuedAt, - signature: signature, ).timeout( const Duration(seconds: 15), onTimeout: () { @@ -251,8 +252,6 @@ class Auth extends _$Auth { login: login, token: token, siteName: siteName, - issuedAt: issuedAt, - signature: signature, ).timeout( const Duration(seconds: 20), onTimeout: () { @@ -497,114 +496,25 @@ class Auth extends _$Auth { required String login, required String token, String? siteName, - DateTime? issuedAt, - String? signature, }) async { - var failureHandled = false; final config = ref.read(webSocketConfigProvider); final storage = ref.read(storageServiceProvider); final localDataSource = ref.read(authLocalDataSourceProvider); final service = ref.read(webSocketServiceProvider); + final handshakeService = ref.read(actionCableAuthServiceProvider); _logger.i('AUTH_NOTIFIER: WebSocket service hashCode: ${service.hashCode}'); - final resolvedSite = - (siteName ?? storage.siteName ?? fqdn).trim(); - final uri = _buildActionCableUri( - baseUri: config.baseUri, - fqdn: fqdn, - token: token, - ); - final headers = _buildAuthHeaders(token); - - if (service.isConnected) { - await service.disconnect(code: 4000, reason: 'Re-authenticating'); - } - - final identifier = jsonEncode(const {'channel': 'RxgChannel'}); - final subscriptionPayload = { - 'command': 'subscribe', - 'identifier': identifier, - }; - - final completer = Completer<_ActionCableAuthResult>(); - final subscription = service.messages.listen((message) { - _logger.d('AUTH_NOTIFIER: 📩 WebSocket message received: type=${message.type}, payload=${message.payload}'); - if (message.type == 'confirm_subscription' && - _identifierMatches(message, identifier)) { - _logger.i('AUTH_NOTIFIER: ✅ Subscription confirmed!'); - if (!completer.isCompleted) { - completer.complete(const _ActionCableAuthResult.success()); - } - } else if (message.type == 'reject_subscription' && - _identifierMatches(message, identifier)) { - _logger.e('AUTH_NOTIFIER: ❌ Subscription REJECTED by server'); - if (!completer.isCompleted) { - completer.complete( - const _ActionCableAuthResult.failure( - 'Subscription rejected by server', - ), - ); - } - } else if (message.type == 'disconnect') { - final reason = - message.payload['reason'] as String? ?? - message.payload['message'] as String?; - _logger.e('AUTH_NOTIFIER: ❌ Server sent disconnect: $reason'); - if (!completer.isCompleted) { - completer.complete( - _ActionCableAuthResult.failure( - reason ?? 'Connection closed by server', - ), - ); - } - } - }); - - final stateSubscription = service.connectionState.listen((connState) { - _logger.d('AUTH_NOTIFIER: 🔌 WebSocket connection state changed: $connState'); - if (connState == SocketConnectionState.disconnected && - !completer.isCompleted) { - _logger.e('AUTH_NOTIFIER: ❌ Connection closed before subscription confirmed'); - completer.complete( - const _ActionCableAuthResult.failure( - 'Connection closed before subscription confirmed', - ), - ); - } - }); - - _logger - ..i('AUTH_NOTIFIER: Initiating WebSocket handshake') - ..d('AUTH_NOTIFIER: WebSocket URI: $uri') - ..d('AUTH_NOTIFIER: Subscription identifier: $identifier'); + final resolvedSite = (siteName ?? storage.siteName ?? fqdn).trim(); + var failureHandled = false; try { - _logger.d('AUTH_NOTIFIER: Calling service.connect()...'); - await service.connect( - WebSocketConnectionParams(uri: uri, headers: headers), - ).timeout( - const Duration(seconds: 10), - onTimeout: () { - _logger.e('AUTH_NOTIFIER: ⏱️ Connection TIMED OUT after 10 seconds'); - throw TimeoutException('Connection to server timed out'); - }, - ); - _logger.d('AUTH_NOTIFIER: WebSocket connected, sending subscription...'); - service.send(subscriptionPayload); - _logger.d('AUTH_NOTIFIER: Subscription sent, waiting for confirmation (15s timeout)...'); - - final result = await completer.future.timeout( - const Duration(seconds: 15), - onTimeout: () { - _logger.e('AUTH_NOTIFIER: ⏱️ WebSocket handshake TIMED OUT after 15 seconds'); - return const _ActionCableAuthResult.failure( - 'WebSocket handshake timed out', - ); - }, + final result = await handshakeService.performHandshake( + fqdn: fqdn, + token: token, + baseUri: config.baseUri, ); if (!result.success) { - final errorMessage = result.message; await storage.setAuthenticated(value: false); await localDataSource.clearSession(); await service.disconnect(code: 4401, reason: 'auth.error'); @@ -613,10 +523,10 @@ class Auth extends _$Auth { login: login, siteName: resolvedSite, success: false, - message: errorMessage, + message: result.message, ); failureHandled = true; - throw Exception(errorMessage); + throw Exception(result.message); } await localDataSource.clearSession(); @@ -653,9 +563,6 @@ class Auth extends _$Auth { ); } rethrow; - } finally { - await subscription.cancel(); - await stateSubscription.cancel(); } } @@ -767,51 +674,3 @@ final authSignOutCleanupProvider = Provider((ref) { }); }); -Uri _buildActionCableUri({ - required Uri baseUri, - required String fqdn, - required String token, -}) { - final useBaseUri = EnvironmentConfig.isDevelopment; - final uri = useBaseUri - ? baseUri - : Uri( - scheme: 'wss', - host: fqdn, - path: '/cable', - ); - - final queryParameters = Map.from(uri.queryParameters); - if (token.isNotEmpty) { - queryParameters['api_key'] = token; - } - - return uri.replace( - queryParameters: queryParameters.isEmpty ? null : queryParameters, - ); -} - -Map _buildAuthHeaders(String token) { - if (token.isEmpty) { - return const {}; - } - return {'Authorization': 'Bearer $token'}; -} - -bool _identifierMatches(SocketMessage message, String identifier) { - final headerIdentifier = message.headers?['identifier']; - if (headerIdentifier is String && headerIdentifier.isNotEmpty) { - return headerIdentifier == identifier; - } - return false; -} - -class _ActionCableAuthResult { - const _ActionCableAuthResult.success() - : success = true, - message = ''; - const _ActionCableAuthResult.failure(this.message) : success = false; - - final bool success; - final String message; -} diff --git a/pubspec.lock b/pubspec.lock index f708ca5..a70f914 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -859,10 +859,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1456,10 +1456,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" timing: dependency: transitive description: diff --git a/test/core/services/websocket_data_sync_service_test.dart b/test/core/services/websocket_data_sync_service_test.dart new file mode 100644 index 0000000..dee3538 --- /dev/null +++ b/test/core/services/websocket_data_sync_service_test.dart @@ -0,0 +1,487 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:logger/logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rgnets_fdk/core/services/cache_manager.dart'; +import 'package:rgnets_fdk/core/services/storage_service.dart'; +import 'package:rgnets_fdk/core/services/websocket_data_sync_service.dart'; +import 'package:rgnets_fdk/core/services/websocket_service.dart'; +import 'package:rgnets_fdk/features/devices/data/datasources/typed_device_local_data_source.dart'; +import 'package:rgnets_fdk/features/devices/data/models/device_model_sealed.dart'; +import 'package:rgnets_fdk/features/devices/data/models/room_model.dart'; +import 'package:rgnets_fdk/features/rooms/data/datasources/room_local_data_source.dart'; + +class MockWebSocketService extends Mock implements WebSocketService {} + +class MockAPLocalDataSource extends Mock implements APLocalDataSource {} + +class MockONTLocalDataSource extends Mock implements ONTLocalDataSource {} + +class MockSwitchLocalDataSource extends Mock implements SwitchLocalDataSource {} + +class MockWLANLocalDataSource extends Mock implements WLANLocalDataSource {} + +class MockRoomLocalDataSource extends Mock implements RoomLocalDataSource {} + +class MockCacheManager extends Mock implements CacheManager {} + +class MockStorageService extends Mock implements StorageService {} + + +void main() { + late MockWebSocketService mockWebSocketService; + late MockAPLocalDataSource mockAPDataSource; + late MockONTLocalDataSource mockONTDataSource; + late MockSwitchLocalDataSource mockSwitchDataSource; + late MockWLANLocalDataSource mockWLANDataSource; + late MockRoomLocalDataSource mockRoomDataSource; + late MockCacheManager mockCacheManager; + late MockStorageService mockStorageService; + late StreamController messageController; + late StreamController stateController; + late WebSocketDataSyncService service; + + setUpAll(() { + // Register fallback values BEFORE any setUp that uses any() + registerFallbackValue([]); + registerFallbackValue([]); + registerFallbackValue(Duration.zero); + registerFallbackValue(const SocketMessage(type: '', payload: {})); + }); + + setUp(() { + mockWebSocketService = MockWebSocketService(); + mockAPDataSource = MockAPLocalDataSource(); + mockONTDataSource = MockONTLocalDataSource(); + mockSwitchDataSource = MockSwitchLocalDataSource(); + mockWLANDataSource = MockWLANLocalDataSource(); + mockRoomDataSource = MockRoomLocalDataSource(); + mockCacheManager = MockCacheManager(); + mockStorageService = MockStorageService(); + messageController = StreamController.broadcast(); + stateController = StreamController.broadcast(); + + when(() => mockWebSocketService.messages) + .thenAnswer((_) => messageController.stream); + when(() => mockWebSocketService.connectionState) + .thenAnswer((_) => stateController.stream); + when(() => mockWebSocketService.isConnected).thenReturn(true); + when(() => mockWebSocketService.send(any())).thenReturn(null); + when(() => mockWebSocketService.requestActionCable( + action: any(named: 'action'), + resourceType: any(named: 'resourceType'), + timeout: any(named: 'timeout'), + )).thenAnswer((_) async => const SocketMessage( + type: 'response', + payload: {'data': []}, + )); + + // Storage mock defaults + when(() => mockStorageService.token).thenReturn('test-token'); + + service = WebSocketDataSyncService( + socketService: mockWebSocketService, + apLocalDataSource: mockAPDataSource, + ontLocalDataSource: mockONTDataSource, + switchLocalDataSource: mockSwitchDataSource, + wlanLocalDataSource: mockWLANDataSource, + roomLocalDataSource: mockRoomDataSource, + cacheManager: mockCacheManager, + storageService: mockStorageService, + logger: Logger(level: Level.off), + ); + }); + + tearDown(() async { + service.stop(); + await messageController.close(); + await stateController.close(); + }); + + group('WebSocketDataSyncService', () { + group('events stream', () { + test('emits events when created', () { + expect(service.events, isNotNull); + }); + }); + + group('start and stop', () { + test('start subscribes to websocket messages', () { + service.start(); + + verify(() => mockWebSocketService.messages).called(greaterThan(0)); + }); + + test('stop cancels subscriptions', () { + service.start(); + service.stop(); + + // Should not throw when stop is called again + service.stop(); + }); + }); + + group('message handling with data key', () { + test('processes device snapshot from data key', () async { + // Stub cache methods + when(() => mockAPDataSource.cacheDevices(any())) + .thenAnswer((_) async {}); + + service.start(); + + // Simulate a snapshot message with 'data' key (backend format) + messageController.add(SocketMessage( + type: 'message', + payload: { + 'action': 'snapshot', + 'resource_type': 'access_points', + 'data': [ + { + 'id': 1, + 'name': 'AP-100', + 'type': 'AccessPoint', + 'online': true, + 'mac_address': 'AA:BB:CC:DD:EE:FF', + }, + { + 'id': 2, + 'name': 'AP-200', + 'type': 'AccessPoint', + 'online': false, + 'mac_address': '11:22:33:44:55:66', + }, + ], + }, + headers: {'identifier': '{"channel":"RxgChannel"}'}, + )); + + // Wait for async processing + await Future.delayed(const Duration(milliseconds: 50)); + + verify(() => mockAPDataSource.cacheDevices(any())).called(1); + }); + + test('processes device snapshot from results key (fallback)', () async { + when(() => mockAPDataSource.cacheDevices(any())) + .thenAnswer((_) async {}); + + service.start(); + + messageController.add(SocketMessage( + type: 'message', + payload: { + 'action': 'snapshot', + 'resource_type': 'access_points', + 'results': [ + { + 'id': 1, + 'name': 'AP-100', + 'type': 'AccessPoint', + 'online': true, + }, + ], + }, + headers: {'identifier': '{"channel":"RxgChannel"}'}, + )); + + await Future.delayed(const Duration(milliseconds: 50)); + + verify(() => mockAPDataSource.cacheDevices(any())).called(1); + }); + }); + + group('device status determination', () { + test('online boolean true maps to online status', () async { + when(() => mockAPDataSource.cacheDevices(any())) + .thenAnswer((_) async {}); + + service.start(); + + messageController.add(SocketMessage( + type: 'message', + payload: { + 'action': 'snapshot', + 'resource_type': 'access_points', + 'data': [ + {'id': 1, 'name': 'AP-1', 'type': 'AccessPoint', 'online': true}, + ], + }, + headers: {'identifier': '{"channel":"RxgChannel"}'}, + )); + + await Future.delayed(const Duration(milliseconds: 50)); + + final captured = verify( + () => mockAPDataSource.cacheDevices(captureAny()), + ).captured.first as List; + + expect(captured.first.status, 'online'); + }); + + test('online boolean false maps to offline status', () async { + when(() => mockAPDataSource.cacheDevices(any())) + .thenAnswer((_) async {}); + + service.start(); + + messageController.add(SocketMessage( + type: 'message', + payload: { + 'action': 'snapshot', + 'resource_type': 'access_points', + 'data': [ + {'id': 1, 'name': 'AP-1', 'type': 'AccessPoint', 'online': false}, + ], + }, + headers: {'identifier': '{"channel":"RxgChannel"}'}, + )); + + await Future.delayed(const Duration(milliseconds: 50)); + + final captured = verify( + () => mockAPDataSource.cacheDevices(captureAny()), + ).captured.first as List; + + expect(captured.first.status, 'offline'); + }); + + test('string status online is preserved', () async { + when(() => mockAPDataSource.cacheDevices(any())) + .thenAnswer((_) async {}); + + service.start(); + + messageController.add(SocketMessage( + type: 'message', + payload: { + 'action': 'snapshot', + 'resource_type': 'access_points', + 'data': [ + {'id': 1, 'name': 'AP-1', 'type': 'AccessPoint', 'status': 'online'}, + ], + }, + headers: {'identifier': '{"channel":"RxgChannel"}'}, + )); + + await Future.delayed(const Duration(milliseconds: 50)); + + final captured = verify( + () => mockAPDataSource.cacheDevices(captureAny()), + ).captured.first as List; + + expect(captured.first.status, 'online'); + }); + + test('no status info defaults to unknown', () async { + when(() => mockAPDataSource.cacheDevices(any())) + .thenAnswer((_) async {}); + + service.start(); + + messageController.add(SocketMessage( + type: 'message', + payload: { + 'action': 'snapshot', + 'resource_type': 'access_points', + 'data': [ + {'id': 1, 'name': 'AP-1', 'type': 'AccessPoint'}, + ], + }, + headers: {'identifier': '{"channel":"RxgChannel"}'}, + )); + + await Future.delayed(const Duration(milliseconds: 50)); + + final captured = verify( + () => mockAPDataSource.cacheDevices(captureAny()), + ).captured.first as List; + + expect(captured.first.status, 'unknown'); + }); + }); + + group('image extraction', () { + test('extracts images from images list', () async { + when(() => mockAPDataSource.cacheDevices(any())) + .thenAnswer((_) async {}); + + service.start(); + + messageController.add(SocketMessage( + type: 'message', + payload: { + 'action': 'snapshot', + 'resource_type': 'access_points', + 'data': [ + { + 'id': 1, + 'name': 'AP-1', + 'type': 'AccessPoint', + 'online': true, + 'images': ['https://example.com/img1.jpg', 'https://example.com/img2.jpg'], + }, + ], + }, + headers: {'identifier': '{"channel":"RxgChannel"}'}, + )); + + await Future.delayed(const Duration(milliseconds: 50)); + + final captured = verify( + () => mockAPDataSource.cacheDevices(captureAny()), + ).captured.first as List; + + expect(captured.first.images, hasLength(2)); + expect(captured.first.images?.first, 'https://example.com/img1.jpg'); + }); + + test('extracts images from image objects with url key', () async { + when(() => mockAPDataSource.cacheDevices(any())) + .thenAnswer((_) async {}); + + service.start(); + + messageController.add(SocketMessage( + type: 'message', + payload: { + 'action': 'snapshot', + 'resource_type': 'access_points', + 'data': [ + { + 'id': 1, + 'name': 'AP-1', + 'type': 'AccessPoint', + 'online': true, + 'images': [ + {'url': 'https://example.com/img1.jpg'}, + {'url': 'https://example.com/img2.jpg'}, + ], + }, + ], + }, + headers: {'identifier': '{"channel":"RxgChannel"}'}, + )); + + await Future.delayed(const Duration(milliseconds: 50)); + + final captured = verify( + () => mockAPDataSource.cacheDevices(captureAny()), + ).captured.first as List; + + expect(captured.first.images, hasLength(2)); + }); + + test('returns null when no images present', () async { + when(() => mockAPDataSource.cacheDevices(any())) + .thenAnswer((_) async {}); + + service.start(); + + messageController.add(SocketMessage( + type: 'message', + payload: { + 'action': 'snapshot', + 'resource_type': 'access_points', + 'data': [ + {'id': 1, 'name': 'AP-1', 'type': 'AccessPoint', 'online': true}, + ], + }, + headers: {'identifier': '{"channel":"RxgChannel"}'}, + )); + + await Future.delayed(const Duration(milliseconds: 50)); + + final captured = verify( + () => mockAPDataSource.cacheDevices(captureAny()), + ).captured.first as List; + + expect(captured.first.images, isNull); + }); + }); + + group('room snapshot handling', () { + test('caches rooms from snapshot', () async { + when(() => mockRoomDataSource.cacheRooms(any())) + .thenAnswer((_) async {}); + + service.start(); + + messageController.add(SocketMessage( + type: 'message', + payload: { + 'action': 'snapshot', + 'resource_type': 'pms_rooms', + 'data': [ + {'id': 1, 'room': 'Room 101', 'floor': '1st'}, + {'id': 2, 'room': 'Room 202', 'floor': '2nd'}, + ], + }, + headers: {'identifier': '{"channel":"RxgChannel"}'}, + )); + + await Future.delayed(const Duration(milliseconds: 50)); + + verify(() => mockRoomDataSource.cacheRooms(any())).called(1); + }); + }); + + group('syncInitialData', () { + test('returns true on successful sync', () async { + when(() => mockAPDataSource.cacheDevices(any())) + .thenAnswer((_) async {}); + when(() => mockONTDataSource.cacheDevices(any())) + .thenAnswer((_) async {}); + when(() => mockSwitchDataSource.cacheDevices(any())) + .thenAnswer((_) async {}); + when(() => mockWLANDataSource.cacheDevices(any())) + .thenAnswer((_) async {}); + when(() => mockRoomDataSource.cacheRooms(any())) + .thenAnswer((_) async {}); + + // Stub flushNow() for all device datasources + when(() => mockAPDataSource.flushNow()) + .thenAnswer((_) async {}); + when(() => mockONTDataSource.flushNow()) + .thenAnswer((_) async {}); + when(() => mockSwitchDataSource.flushNow()) + .thenAnswer((_) async {}); + when(() => mockWLANDataSource.flushNow()) + .thenAnswer((_) async {}); + + // Stub storage for persisting ID-to-type index + when(() => mockStorageService.setString(any(), any())) + .thenAnswer((_) async => true); + + // Start sync (non-blocking) and then send snapshot responses + final syncFuture = service.syncInitialData( + timeout: const Duration(seconds: 5), + ); + + // Simulate snapshot responses for all 5 resource types + await Future.delayed(const Duration(milliseconds: 10)); + for (final resourceType in [ + 'access_points', + 'media_converters', + 'switch_devices', + 'wlan_devices', + 'pms_rooms', + ]) { + messageController.add(SocketMessage( + type: 'message', + payload: { + 'action': 'snapshot', + 'resource_type': resourceType, + 'data': >[], + }, + headers: {'identifier': '{"channel":"RxgChannel"}'}, + )); + } + + final result = await syncFuture; + expect(result, isTrue); + }); + }); + }); +} diff --git a/test/core/services/websocket_service_test.dart b/test/core/services/websocket_service_test.dart new file mode 100644 index 0000000..1ea1090 --- /dev/null +++ b/test/core/services/websocket_service_test.dart @@ -0,0 +1,604 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:logger/logger.dart'; +import 'package:rgnets_fdk/core/services/websocket_service.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +/// Creates a controllable fake WebSocket channel pair. +/// +/// Returns the [WebSocketChannel] to inject into [WebSocketService] and a +/// [StreamController] for simulating server messages. +({WebSocketChannel channel, StreamController controller, List sent}) _createFakeChannel() { + final controller = StreamController.broadcast(); + final sent = []; + + final channel = _FakeChannel( + stream: controller.stream, + onSend: sent.add, + ); + + return (channel: channel, controller: controller, sent: sent); +} + +/// Minimal WebSocketChannel fake that avoids implementing the full +/// StreamChannelMixin interface by extending IOWebSocketChannel-style construction. +class _FakeChannel implements WebSocketChannel { + _FakeChannel({ + required Stream stream, + required this.onSend, + }) : _stream = stream; + + final Stream _stream; + final void Function(dynamic) onSend; + int? _closeCode; + String? _closeReason; + + @override + Stream get stream => _stream; + + @override + WebSocketSink get sink => _FakeSink(onSend: onSend, onClose: (code, reason) { + _closeCode = code; + _closeReason = reason; + }); + + @override + int? get closeCode => _closeCode; + + @override + String? get closeReason => _closeReason; + + @override + String? get protocol => null; + + @override + Future get ready => Future.value(); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeSink implements WebSocketSink { + _FakeSink({required this.onSend, required this.onClose}); + + final void Function(dynamic) onSend; + final void Function(int?, String?) onClose; + final _doneCompleter = Completer(); + + @override + void add(dynamic data) => onSend(data); + + @override + void addError(Object error, [StackTrace? stackTrace]) {} + + @override + Future addStream(Stream stream) async { + await for (final data in stream) { + add(data); + } + } + + @override + Future close([int? closeCode, String? closeReason]) async { + onClose(closeCode, closeReason); + if (!_doneCompleter.isCompleted) _doneCompleter.complete(); + } + + @override + Future get done => _doneCompleter.future; +} + +WebSocketConfig _testConfig({ + bool autoReconnect = false, + bool sendClientPing = false, +}) { + return WebSocketConfig( + baseUri: Uri.parse('ws://localhost:9443/ws'), + autoReconnect: autoReconnect, + sendClientPing: sendClientPing, + initialReconnectDelay: const Duration(milliseconds: 10), + maxReconnectDelay: const Duration(milliseconds: 100), + heartbeatInterval: const Duration(seconds: 30), + heartbeatTimeout: const Duration(seconds: 45), + ); +} + +void main() { + late Logger logger; + + setUp(() { + logger = Logger(level: Level.off); + }); + + group('WebSocketService', () { + group('initial state', () { + test('starts in disconnected state', () { + final fake = _createFakeChannel(); + final svc = WebSocketService( + config: _testConfig(), + logger: logger, + channelFactory: (uri, {headers}) => fake.channel, + ); + + expect(svc.currentState, SocketConnectionState.disconnected); + expect(svc.isConnected, isFalse); + expect(svc.lastMessage, isNull); + + svc.dispose(); + fake.controller.close(); + }); + }); + + group('connect', () { + test('transitions to connected state', () async { + final fake = _createFakeChannel(); + final svc = WebSocketService( + config: _testConfig(), + logger: logger, + channelFactory: (uri, {headers}) => fake.channel, + ); + + await svc.connect( + WebSocketConnectionParams(uri: Uri.parse('ws://localhost/ws')), + ); + + expect(svc.isConnected, isTrue); + expect(svc.currentState, SocketConnectionState.connected); + + await svc.dispose(); + await fake.controller.close(); + }); + + test('sends handshake message if provided', () async { + final fake = _createFakeChannel(); + final svc = WebSocketService( + config: _testConfig(), + logger: logger, + channelFactory: (uri, {headers}) => fake.channel, + ); + + await svc.connect( + WebSocketConnectionParams( + uri: Uri.parse('ws://localhost/ws'), + handshakeMessage: {'type': 'hello'}, + ), + ); + + expect(fake.sent, hasLength(1)); + final decoded = + jsonDecode(fake.sent.first as String) as Map; + expect(decoded['type'], 'hello'); + + await svc.dispose(); + await fake.controller.close(); + }); + }); + + group('disconnect', () { + test('transitions to disconnected state', () async { + final fake = _createFakeChannel(); + final svc = WebSocketService( + config: _testConfig(), + logger: logger, + channelFactory: (uri, {headers}) => fake.channel, + ); + + await svc.connect( + WebSocketConnectionParams(uri: Uri.parse('ws://localhost/ws')), + ); + expect(svc.isConnected, isTrue); + + await svc.disconnect(code: 1000, reason: 'test'); + + expect(svc.isConnected, isFalse); + expect(svc.currentState, SocketConnectionState.disconnected); + + await svc.dispose(); + await fake.controller.close(); + }); + }); + + group('send', () { + test('sends JSON-encoded message', () async { + final fake = _createFakeChannel(); + final svc = WebSocketService( + config: _testConfig(), + logger: logger, + channelFactory: (uri, {headers}) => fake.channel, + ); + + await svc.connect( + WebSocketConnectionParams(uri: Uri.parse('ws://localhost/ws')), + ); + + svc.send({'command': 'subscribe', 'identifier': '{"channel":"Test"}'}); + + expect(fake.sent, hasLength(1)); + final decoded = + jsonDecode(fake.sent.first as String) as Map; + expect(decoded['command'], 'subscribe'); + + await svc.dispose(); + await fake.controller.close(); + }); + + test('throws StateError when not connected', () { + final fake = _createFakeChannel(); + final svc = WebSocketService( + config: _testConfig(), + logger: logger, + channelFactory: (uri, {headers}) => fake.channel, + ); + + expect( + () => svc.send({'type': 'test'}), + throwsA(isA()), + ); + + svc.dispose(); + fake.controller.close(); + }); + }); + + group('message parsing', () { + test('parses ActionCable confirm_subscription', () async { + final fake = _createFakeChannel(); + final svc = WebSocketService( + config: _testConfig(), + logger: logger, + channelFactory: (uri, {headers}) => fake.channel, + ); + + await svc.connect( + WebSocketConnectionParams(uri: Uri.parse('ws://localhost/ws')), + ); + + final messages = []; + svc.messages.listen(messages.add); + + fake.controller.add(jsonEncode({ + 'type': 'confirm_subscription', + 'identifier': '{"channel":"RxgChannel"}', + })); + + await Future.delayed(Duration.zero); + + expect(messages, hasLength(1)); + expect(messages.first.type, 'confirm_subscription'); + expect( + messages.first.headers?['identifier'], '{"channel":"RxgChannel"}'); + + await svc.dispose(); + await fake.controller.close(); + }); + + test('parses message with payload', () async { + final fake = _createFakeChannel(); + final svc = WebSocketService( + config: _testConfig(), + logger: logger, + channelFactory: (uri, {headers}) => fake.channel, + ); + + await svc.connect( + WebSocketConnectionParams(uri: Uri.parse('ws://localhost/ws')), + ); + + final messages = []; + svc.messages.listen(messages.add); + + fake.controller.add(jsonEncode({ + 'type': 'data.snapshot', + 'payload': {'items': [1, 2, 3]}, + })); + + await Future.delayed(Duration.zero); + + expect(messages, hasLength(1)); + expect(messages.first.type, 'data.snapshot'); + expect(messages.first.payload['items'], [1, 2, 3]); + + await svc.dispose(); + await fake.controller.close(); + }); + + test('parses ActionCable message with nested message field', () async { + final fake = _createFakeChannel(); + final svc = WebSocketService( + config: _testConfig(), + logger: logger, + channelFactory: (uri, {headers}) => fake.channel, + ); + + await svc.connect( + WebSocketConnectionParams(uri: Uri.parse('ws://localhost/ws')), + ); + + final messages = []; + svc.messages.listen(messages.add); + + fake.controller.add(jsonEncode({ + 'identifier': '{"channel":"RxgChannel"}', + 'message': {'action': 'snapshot', 'data': [1, 2]}, + })); + + await Future.delayed(Duration.zero); + + expect(messages, hasLength(1)); + expect(messages.first.type, 'snapshot'); + expect(messages.first.payload['data'], [1, 2]); + + await svc.dispose(); + await fake.controller.close(); + }); + + test('defaults type to "message" when no type found', () async { + final fake = _createFakeChannel(); + final svc = WebSocketService( + config: _testConfig(), + logger: logger, + channelFactory: (uri, {headers}) => fake.channel, + ); + + await svc.connect( + WebSocketConnectionParams(uri: Uri.parse('ws://localhost/ws')), + ); + + final messages = []; + svc.messages.listen(messages.add); + + fake.controller.add(jsonEncode({'data': 'raw'})); + + await Future.delayed(Duration.zero); + + expect(messages, hasLength(1)); + expect(messages.first.type, 'message'); + + await svc.dispose(); + await fake.controller.close(); + }); + + test('updates lastMessage', () async { + final fake = _createFakeChannel(); + final svc = WebSocketService( + config: _testConfig(), + logger: logger, + channelFactory: (uri, {headers}) => fake.channel, + ); + + await svc.connect( + WebSocketConnectionParams(uri: Uri.parse('ws://localhost/ws')), + ); + + fake.controller.add(jsonEncode({'type': 'ping'})); + await Future.delayed(Duration.zero); + + expect(svc.lastMessage, isNotNull); + expect(svc.lastMessage!.type, 'ping'); + + await svc.dispose(); + await fake.controller.close(); + }); + }); + + group('pending requests', () { + test('request correlates response by request_id', () async { + final fake = _createFakeChannel(); + final svc = WebSocketService( + config: _testConfig(), + logger: logger, + channelFactory: (uri, {headers}) => fake.channel, + ); + + await svc.connect( + WebSocketConnectionParams(uri: Uri.parse('ws://localhost/ws')), + ); + + final responseFuture = svc.request( + {'command': 'message', 'request_id': 'test-123'}, + timeout: const Duration(seconds: 5), + ); + + // Simulate server response with matching request_id + fake.controller.add(jsonEncode({ + 'type': 'response', + 'request_id': 'test-123', + 'payload': {'status': 'ok'}, + })); + + final response = await responseFuture; + expect(response.type, 'response'); + expect(response.payload['status'], 'ok'); + + await svc.dispose(); + await fake.controller.close(); + }); + + test('request times out with TimeoutException', () async { + final fake = _createFakeChannel(); + final svc = WebSocketService( + config: _testConfig(), + logger: logger, + channelFactory: (uri, {headers}) => fake.channel, + ); + + await svc.connect( + WebSocketConnectionParams(uri: Uri.parse('ws://localhost/ws')), + ); + + expect( + svc.request( + {'command': 'message', 'request_id': 'timeout-test'}, + timeout: const Duration(milliseconds: 50), + ), + throwsA(isA()), + ); + + // Wait for timeout to fire + await Future.delayed(const Duration(milliseconds: 100)); + + await svc.dispose(); + await fake.controller.close(); + }); + + test('disconnect fails pending requests', () async { + final fake = _createFakeChannel(); + final svc = WebSocketService( + config: _testConfig(), + logger: logger, + channelFactory: (uri, {headers}) => fake.channel, + ); + + await svc.connect( + WebSocketConnectionParams(uri: Uri.parse('ws://localhost/ws')), + ); + + // Start request but don't await - capture the future + Object? caughtError; + final responseFuture = svc.request( + {'command': 'message', 'request_id': 'disconnect-test'}, + timeout: const Duration(seconds: 5), + ).catchError((Object e) { + caughtError = e; + return const SocketMessage(type: 'error', payload: {}); + }); + + await svc.disconnect(); + await responseFuture; + + expect(caughtError, isA()); + + await svc.dispose(); + await fake.controller.close(); + }); + }); + + group('connection state stream', () { + test('emits connected then disconnected', () async { + final fake = _createFakeChannel(); + final svc = WebSocketService( + config: _testConfig(), + logger: logger, + channelFactory: (uri, {headers}) => fake.channel, + ); + + // Subscribe before connecting to capture all states + final states = []; + svc.connectionState.listen(states.add); + + await svc.connect( + WebSocketConnectionParams(uri: Uri.parse('ws://localhost/ws')), + ); + + // Give stream time to emit + await Future.delayed(Duration.zero); + + expect(states, contains(SocketConnectionState.connecting)); + expect(states, contains(SocketConnectionState.connected)); + + await svc.disconnect(); + await Future.delayed(Duration.zero); + + expect(states, contains(SocketConnectionState.disconnected)); + + await svc.dispose(); + await fake.controller.close(); + }); + + test('does not emit duplicate states', () async { + final fake = _createFakeChannel(); + final svc = WebSocketService( + config: _testConfig(), + logger: logger, + channelFactory: (uri, {headers}) => fake.channel, + ); + + final states = []; + svc.connectionState.listen(states.add); + + await svc.connect( + WebSocketConnectionParams(uri: Uri.parse('ws://localhost/ws')), + ); + await svc.disconnect(); + await Future.delayed(Duration.zero); + final countBefore = states.length; + + await svc.disconnect(); // second disconnect should not emit + await Future.delayed(Duration.zero); + + expect(states.length, countBefore); + + await svc.dispose(); + await fake.controller.close(); + }); + }); + + group('sendType', () { + test('composes envelope with type and payload', () async { + final fake = _createFakeChannel(); + final svc = WebSocketService( + config: _testConfig(), + logger: logger, + channelFactory: (uri, {headers}) => fake.channel, + ); + + await svc.connect( + WebSocketConnectionParams(uri: Uri.parse('ws://localhost/ws')), + ); + + svc.sendType('system.ping', payload: {'ts': '2024'}); + + expect(fake.sent, hasLength(1)); + final decoded = + jsonDecode(fake.sent.first as String) as Map; + expect(decoded['type'], 'system.ping'); + expect(decoded['payload']['ts'], '2024'); + + await svc.dispose(); + await fake.controller.close(); + }); + }); + + group('WebSocketConfig', () { + test('has correct defaults', () { + final config = WebSocketConfig( + baseUri: Uri.parse('ws://test'), + ); + expect(config.autoReconnect, isTrue); + expect(config.sendClientPing, isTrue); + expect(config.initialReconnectDelay, const Duration(seconds: 1)); + expect(config.maxReconnectDelay, const Duration(seconds: 32)); + }); + }); + + group('WebSocketConnectionParams', () { + test('stores uri and optional fields', () { + final params = WebSocketConnectionParams( + uri: Uri.parse('ws://test'), + headers: {'Authorization': 'Bearer token'}, + handshakeMessage: {'type': 'hello'}, + ); + expect(params.uri.toString(), 'ws://test'); + expect(params.headers?['Authorization'], 'Bearer token'); + expect(params.handshakeMessage?['type'], 'hello'); + }); + }); + + group('SocketMessage', () { + test('stores all fields', () { + const msg = SocketMessage( + type: 'test', + payload: {'key': 'value'}, + headers: {'id': 'abc'}, + raw: {'type': 'test', 'key': 'value'}, + ); + expect(msg.type, 'test'); + expect(msg.payload['key'], 'value'); + expect(msg.headers?['id'], 'abc'); + expect(msg.raw?['type'], 'test'); + }); + }); + }); +} diff --git a/test/features/auth/presentation/providers/auth_notifier_credential_recovery_test.dart b/test/features/auth/presentation/providers/auth_notifier_credential_recovery_test.dart index cec8b0b..a562c22 100644 --- a/test/features/auth/presentation/providers/auth_notifier_credential_recovery_test.dart +++ b/test/features/auth/presentation/providers/auth_notifier_credential_recovery_test.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fpdart/fpdart.dart'; @@ -13,7 +11,6 @@ import 'package:rgnets_fdk/features/auth/domain/entities/auth_status.dart'; import 'package:rgnets_fdk/features/auth/domain/entities/user.dart'; import 'package:rgnets_fdk/features/auth/domain/repositories/auth_repository.dart'; import 'package:rgnets_fdk/features/auth/presentation/providers/auth_notifier.dart'; -import 'package:shared_preferences/shared_preferences.dart'; // Mocks class MockAuthRepository extends Mock implements AuthRepository {} @@ -34,9 +31,19 @@ void main() { mockStorageService = MockStorageService(); mockLogger = MockLogger(); - // Setup default mock behavior - when(() => mockStorageService.migrateLegacyCredentialsIfNeeded()) + // Setup default mock behavior for StorageService + when(() => mockStorageService.migrateToSecureStorageIfNeeded()) .thenAnswer((_) async {}); + when(() => mockStorageService.getToken()) + .thenAnswer((_) async => null); + when(() => mockStorageService.getAuthSignature()) + .thenAnswer((_) async => null); + when(() => mockStorageService.token).thenReturn(null); + when(() => mockStorageService.siteUrl).thenReturn(null); + when(() => mockStorageService.username).thenReturn(null); + when(() => mockStorageService.siteName).thenReturn(null); + when(() => mockStorageService.authIssuedAt).thenReturn(null); + when(() => mockStorageService.authSignature).thenReturn(null); when(() => mockLogger.i(any())).thenReturn(null); when(() => mockLogger.d(any())).thenReturn(null); when(() => mockLogger.w(any())).thenReturn(null); @@ -54,6 +61,8 @@ void main() { // Credentials exist in storage when(() => mockStorageService.token).thenReturn('valid-token'); + when(() => mockStorageService.getToken()) + .thenAnswer((_) async => 'valid-token'); when(() => mockStorageService.siteUrl) .thenReturn('https://test.rgnets.com'); when(() => mockStorageService.username).thenReturn('testuser'); @@ -61,16 +70,12 @@ void main() { when(() => mockStorageService.authIssuedAt).thenReturn(null); when(() => mockStorageService.authSignature).thenReturn(null); - // Setup SharedPreferences for the container - SharedPreferences.setMockInitialValues({}); - final prefs = await SharedPreferences.getInstance(); - // Create provider container with overrides final container = ProviderContainer( overrides: [ authRepositoryProvider.overrideWithValue(mockAuthRepository), storageServiceProvider - .overrideWithValue(StorageService(prefs)), + .overrideWithValue(mockStorageService), loggerProvider.overrideWithValue(mockLogger), ], ); @@ -107,13 +112,10 @@ void main() { when(() => mockStorageService.siteUrl).thenReturn(null); when(() => mockStorageService.username).thenReturn(null); - SharedPreferences.setMockInitialValues({}); - final prefs = await SharedPreferences.getInstance(); - final container = ProviderContainer( overrides: [ authRepositoryProvider.overrideWithValue(mockAuthRepository), - storageServiceProvider.overrideWithValue(StorageService(prefs)), + storageServiceProvider.overrideWithValue(mockStorageService), loggerProvider.overrideWithValue(mockLogger), ], ); @@ -136,13 +138,10 @@ void main() { when(() => mockStorageService.siteUrl).thenReturn(''); when(() => mockStorageService.username).thenReturn(''); - SharedPreferences.setMockInitialValues({}); - final prefs = await SharedPreferences.getInstance(); - final container = ProviderContainer( overrides: [ authRepositoryProvider.overrideWithValue(mockAuthRepository), - storageServiceProvider.overrideWithValue(StorageService(prefs)), + storageServiceProvider.overrideWithValue(mockStorageService), loggerProvider.overrideWithValue(mockLogger), ], ); @@ -171,13 +170,10 @@ void main() { // recovery to ensure WebSocket is connected. Without credentials in // storage, this will return unauthenticated. In production, credentials // should always exist when user model exists. - SharedPreferences.setMockInitialValues({}); - final prefs = await SharedPreferences.getInstance(); - final container = ProviderContainer( overrides: [ authRepositoryProvider.overrideWithValue(mockAuthRepository), - storageServiceProvider.overrideWithValue(StorageService(prefs)), + storageServiceProvider.overrideWithValue(mockStorageService), loggerProvider.overrideWithValue(mockLogger), ], ); @@ -206,6 +202,8 @@ void main() { // But credentials exist when(() => mockStorageService.token).thenReturn('valid-token'); + when(() => mockStorageService.getToken()) + .thenAnswer((_) async => 'valid-token'); when(() => mockStorageService.siteUrl) .thenReturn('https://test.rgnets.com'); when(() => mockStorageService.username).thenReturn('testuser'); @@ -213,13 +211,10 @@ void main() { when(() => mockStorageService.authIssuedAt).thenReturn(null); when(() => mockStorageService.authSignature).thenReturn(null); - SharedPreferences.setMockInitialValues({}); - final prefs = await SharedPreferences.getInstance(); - final container = ProviderContainer( overrides: [ authRepositoryProvider.overrideWithValue(mockAuthRepository), - storageServiceProvider.overrideWithValue(StorageService(prefs)), + storageServiceProvider.overrideWithValue(mockStorageService), loggerProvider.overrideWithValue(mockLogger), ], ); diff --git a/test/features/initialization/presentation/providers/initialization_provider_test.dart b/test/features/initialization/presentation/providers/initialization_provider_test.dart index e3529c4..75b5b9e 100644 --- a/test/features/initialization/presentation/providers/initialization_provider_test.dart +++ b/test/features/initialization/presentation/providers/initialization_provider_test.dart @@ -33,7 +33,7 @@ void main() { () => mockDataSyncService.syncInitialData( timeout: any(named: 'timeout'), ), - ).thenAnswer((_) async {}); + ).thenAnswer((_) async => true); when(() => mockDataSyncService.events).thenAnswer( (_) => eventsController.stream, ); @@ -185,6 +185,7 @@ void main() { ).thenAnswer((_) async { callCount++; await Future.delayed(const Duration(milliseconds: 100)); + return true; }); final notifier = @@ -237,6 +238,7 @@ void main() { ), ).thenAnswer((_) async { await Future.delayed(const Duration(milliseconds: 100)); + return true; }); // Start initialization and wait for it in the test @@ -352,9 +354,10 @@ void main() { () => mockDataSyncService.syncInitialData( timeout: any(named: 'timeout'), ), - ).thenAnswer( - (_) async => Future.delayed(const Duration(milliseconds: 50)), - ); + ).thenAnswer((_) async { + await Future.delayed(const Duration(milliseconds: 50)); + return true; + }); await container .read(initializationNotifierProvider.notifier) @@ -395,9 +398,10 @@ void main() { () => mockDataSyncService.syncInitialData( timeout: any(named: 'timeout'), ), - ).thenAnswer( - (_) async => Future.delayed(const Duration(milliseconds: 800)), - ); + ).thenAnswer((_) async { + await Future.delayed(const Duration(milliseconds: 800)); + return true; + }); final notifier = container.read(initializationNotifierProvider.notifier); @@ -425,9 +429,10 @@ void main() { () => mockDataSyncService.syncInitialData( timeout: any(named: 'timeout'), ), - ).thenAnswer( - (_) async => Future.delayed(const Duration(milliseconds: 600)), - ); + ).thenAnswer((_) async { + await Future.delayed(const Duration(milliseconds: 600)); + return true; + }); final notifier = container.read(initializationNotifierProvider.notifier);