diff --git a/l10n.yaml b/l10n.yaml index 7e7966e5..baa8e2be 100644 --- a/l10n.yaml +++ b/l10n.yaml @@ -3,4 +3,4 @@ template-arb-file: intl_en.arb output-dir: lib/generated output-localization-file: l10n.dart output-class: S -synthetic-package: false +synthetic-package: false \ No newline at end of file diff --git a/lib/features/relays/relays_notifier.dart b/lib/features/relays/relays_notifier.dart index b082f81f..542b0bd4 100644 --- a/lib/features/relays/relays_notifier.dart +++ b/lib/features/relays/relays_notifier.dart @@ -1,7 +1,25 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:dart_nostr/nostr/model/ease.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/features/settings/settings_notifier.dart'; import 'relay.dart'; +class RelayValidationResult { + final bool success; + final String? normalizedUrl; + final String? error; + final bool isHealthy; + + RelayValidationResult({ + required this.success, + this.normalizedUrl, + this.error, + this.isHealthy = false, + }); +} + class RelaysNotifier extends StateNotifier> { final SettingsNotifier settings; @@ -34,8 +52,285 @@ class RelaysNotifier extends StateNotifier> { await _saveRelays(); } + /// Smart URL normalization - handles different input formats + String? normalizeRelayUrl(String input) { + input = input.trim().toLowerCase(); + + if (!isValidDomainFormat(input)) return null; + + if (input.startsWith('wss://')) { + return input; // Already properly formatted + } else if (input.startsWith('ws://') || input.startsWith('http')) { + return null; // Reject non-secure protocols + } else { + return 'wss://$input'; // Auto-add wss:// prefix + } + } + + /// Domain validation using RegExp + bool isValidDomainFormat(String input) { + // Remove protocol prefix if present + if (input.startsWith('wss://')) { + input = input.substring(6); + } else if (input.startsWith('ws://')) { + input = input.substring(5); + } else if (input.startsWith('http://')) { + input = input.substring(7); + } else if (input.startsWith('https://')) { + input = input.substring(8); + } + + // Reject IP addresses (basic check for numbers and dots only) + if (RegExp(r'^[\d.]+$').hasMatch(input)) { + return false; + } + + // Domain regex: valid domain format with at least one dot + final domainRegex = RegExp( + r'^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$'); + return domainRegex.hasMatch(input) && input.contains('.'); + } + + /// Test connectivity using proper Nostr protocol validation + /// Sends REQ message and waits for EVENT + EOSE responses + Future testRelayConnectivity(String url) async { + // First try full protocol test + bool protocolResult = await _testNostrProtocol(url); + if (protocolResult) { + return true; + } + + // If protocol test fails, try basic WebSocket connectivity as fallback + return await _testBasicWebSocketConnectivity(url); + } + + /// Full Nostr protocol test - preferred method + Future _testNostrProtocol(String url) async { + // Generate unique subscription ID for this test + final testSubId = 'relay_test_${DateTime.now().millisecondsSinceEpoch}'; + bool receivedEvent = false; + bool receivedEose = false; + bool isConnected = false; + + try { + // Create isolated instance for testing + final testNostr = Nostr(); + + // Setup listeners to track EVENT and EOSE responses + await testNostr.services.relays.init( + relaysUrl: [url], + connectionTimeout: const Duration(seconds: 5), + shouldReconnectToRelayOnNotice: false, + retryOnClose: false, + retryOnError: false, + onRelayListening: (relayUrl, receivedData, channel) { + // Track EVENT and EOSE responses + + // Check for EVENT message with our subscription ID + if (receivedData is NostrEvent && + receivedData.subscriptionId == testSubId) { + // Found an event for our subscription + receivedEvent = true; + } + // Check for EOSE message with our subscription ID + else if (receivedData is NostrRequestEoseCommand && + receivedData.subscriptionId == testSubId) { + // Found end of stored events for our subscription + receivedEose = true; + } + }, + onRelayConnectionDone: (relay, socket) { + if (relay == url) { + // Successfully connected to relay + isConnected = true; + } + }, + onRelayConnectionError: (relay, error, channel) { + // Connection failed - relay is not reachable + isConnected = false; + }, + ); + + // Wait for connection establishment (max 5 seconds) + int connectionWaitCount = 0; + while (!isConnected && connectionWaitCount < 50) { + await Future.delayed(const Duration(milliseconds: 100)); + connectionWaitCount++; + } + + if (!isConnected) { + // Failed to connect within timeout + await _cleanupTestConnection(testNostr); + return false; + } + + // Send REQ message to test relay response + final filter = NostrFilter(kinds: [1], limit: 1); + final request = NostrRequest( + subscriptionId: testSubId, + filters: [filter], + ); + + // Send the request + await testNostr.services.relays.startEventsSubscriptionAsync( + request: request, + timeout: const Duration(seconds: 3), + ); + + // Wait for EVENT or EOSE responses (max 8 seconds total) + int waitCount = 0; + while (!receivedEvent && !receivedEose && waitCount < 80) { + await Future.delayed(const Duration(milliseconds: 100)); + waitCount++; + } + + // Protocol test completed + + // Clean up connection + await _cleanupTestConnection(testNostr); + + // Relay is healthy if we received either EVENT or EOSE (or both) + return receivedEvent || receivedEose; + } catch (e) { + // Protocol test failed with error + try { + await _cleanupTestConnection(Nostr.instance); + } catch (_) { + // Ignore cleanup errors + } + return false; + } + } + + /// Basic WebSocket connectivity test as fallback + Future _testBasicWebSocketConnectivity(String url) async { + try { + // Simple WebSocket connection test + final uri = Uri.parse(url); + final socket = await WebSocket.connect( + uri.toString(), + headers: {'User-Agent': 'MostroMobile/1.0'}, + ).timeout(const Duration(seconds: 8)); + + // Send a basic REQ message to test if it's a Nostr relay + const testReq = '["REQ", "test_conn", {"kinds":[1], "limit":1}]'; + socket.add(testReq); + + // Wait for any response (max 5 seconds) + bool receivedResponse = false; + final subscription = socket.listen( + (message) { + // Received WebSocket message + // Any valid JSON response indicates a working relay + if (message.toString().startsWith('["')) { + receivedResponse = true; + } + }, + onError: (error) { + // WebSocket connection error + }, + ); + + // Wait for response + int waitCount = 0; + while (!receivedResponse && waitCount < 50) { + await Future.delayed(const Duration(milliseconds: 100)); + waitCount++; + } + + // WebSocket test completed + + // Cleanup + await subscription.cancel(); + await socket.close(); + + return receivedResponse; + } catch (e) { + // WebSocket test failed + return false; + } + } + + /// Helper method to clean up test connections + Future _cleanupTestConnection(Nostr nostrInstance) async { + try { + await nostrInstance.services.relays.disconnectFromRelays(); + } catch (_) { + // Ignore cleanup errors + } + } + + /// Smart relay addition with full validation + /// Only adds relays that pass BOTH format validation AND connectivity test + Future addRelayWithSmartValidation( + String input, { + required String errorOnlySecure, + required String errorNoHttp, + required String errorInvalidDomain, + required String errorAlreadyExists, + required String errorNotValid, + }) async { + // Step 1: Normalize URL + final normalizedUrl = normalizeRelayUrl(input); + if (normalizedUrl == null) { + if (input.trim().toLowerCase().startsWith('ws://')) { + return RelayValidationResult( + success: false, + error: errorOnlySecure, + ); + } else if (input.trim().toLowerCase().startsWith('http')) { + return RelayValidationResult( + success: false, + error: errorNoHttp, + ); + } else { + return RelayValidationResult( + success: false, + error: errorInvalidDomain, + ); + } + } + + // Step 2: Check for duplicates + if (state.any((relay) => relay.url == normalizedUrl)) { + return RelayValidationResult( + success: false, + error: errorAlreadyExists, + ); + } + + // Step 3: Test connectivity using dart_nostr - MUST PASS to proceed + final isHealthy = await testRelayConnectivity(normalizedUrl); + + // Step 4: Only add relay if it passes connectivity test + if (!isHealthy) { + return RelayValidationResult( + success: false, + error: errorNotValid, + ); + } + + // Step 5: Add relay only if it's healthy (responds to Nostr protocol) + final newRelay = Relay(url: normalizedUrl, isHealthy: true); + state = [...state, newRelay]; + await _saveRelays(); + + return RelayValidationResult( + success: true, + normalizedUrl: normalizedUrl, + isHealthy: true, + ); + } + Future refreshRelayHealth() async { - state = state.map((r) => r.copyWith(isHealthy: true)).toList(); + final updatedRelays = []; + + for (final relay in state) { + final isHealthy = await testRelayConnectivity(relay.url); + updatedRelays.add(relay.copyWith(isHealthy: isHealthy)); + } + + state = updatedRelays; await _saveRelays(); } } diff --git a/lib/features/relays/widgets/relay_selector.dart b/lib/features/relays/widgets/relay_selector.dart index 51ff470c..59486fc7 100644 --- a/lib/features/relays/widgets/relay_selector.dart +++ b/lib/features/relays/widgets/relay_selector.dart @@ -97,6 +97,8 @@ class RelaySelector extends ConsumerWidget { labelStyle: const TextStyle(color: AppTheme.textSecondary), border: InputBorder.none, contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + hintText: S.of(context)!.relayUrlHint, + hintStyle: const TextStyle(color: AppTheme.textSecondary), ), ), ), @@ -117,13 +119,121 @@ class RelaySelector extends ConsumerWidget { ), const SizedBox(width: 12), ElevatedButton( - onPressed: () { - final url = controller.text.trim(); - if (url.isNotEmpty) { - final newRelay = Relay(url: url, isHealthy: true); - ref.read(relaysProvider.notifier).addRelay(newRelay); + onPressed: () async { + final input = controller.text.trim(); + if (input.isEmpty) return; + + // Show loading state + showDialog( + context: dialogContext, + barrierDismissible: false, + builder: (context) => AlertDialog( + backgroundColor: AppTheme.backgroundCard, + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(color: AppTheme.activeColor), + const SizedBox(width: 16), + Text( + S.of(context)!.relayTestingMessage, + style: const TextStyle(color: AppTheme.textPrimary), + ), + ], + ), + ), + ); + + // Capture localized strings before async operation + final localizedStrings = ( + errorOnlySecure: S.of(context)!.relayErrorOnlySecure, + errorNoHttp: S.of(context)!.relayErrorNoHttp, + errorInvalidDomain: S.of(context)!.relayErrorInvalidDomain, + errorAlreadyExists: S.of(context)!.relayErrorAlreadyExists, + errorNotValid: S.of(context)!.relayErrorNotValid, + relayAddedSuccessfully: S.of(context)!.relayAddedSuccessfully, + relayAddedUnreachable: S.of(context)!.relayAddedUnreachable, + ); + + // Perform validation with localized error messages + final result = await ref.read(relaysProvider.notifier) + .addRelayWithSmartValidation( + input, + errorOnlySecure: localizedStrings.errorOnlySecure, + errorNoHttp: localizedStrings.errorNoHttp, + errorInvalidDomain: localizedStrings.errorInvalidDomain, + errorAlreadyExists: localizedStrings.errorAlreadyExists, + errorNotValid: localizedStrings.errorNotValid, + ); + + // Close loading dialog + if (dialogContext.mounted) { Navigator.pop(dialogContext); } + + if (result.success) { + // Close add relay dialog + if (dialogContext.mounted) { + Navigator.pop(dialogContext); + } + + // Show success message with health status + final message = result.isHealthy + ? localizedStrings.relayAddedSuccessfully(result.normalizedUrl!) + : localizedStrings.relayAddedUnreachable(result.normalizedUrl!); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: result.isHealthy + ? Colors.green.shade700 + : Colors.orange.shade700, + ), + ); + } + } else { + // Show specific error dialog + if (dialogContext.mounted) { + showDialog( + context: dialogContext, + builder: (context) => AlertDialog( + backgroundColor: AppTheme.backgroundCard, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: Colors.white.withValues(alpha: 0.1)), + ), + title: Text( + S.of(context)!.invalidRelayTitle, + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + content: Text( + result.error!, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 14, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + S.of(context)!.ok, + style: const TextStyle( + color: AppTheme.activeColor, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } + } }, style: ElevatedButton.styleFrom( backgroundColor: AppTheme.activeColor, diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index b05f45c5..73a2757e 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -653,6 +653,35 @@ "@_comment_relay_dialogs": "Relay Dialog Text", "editRelay": "Edit Relay", "relayUrl": "Relay URL", + "relayErrorNotValid": "Not a valid Nostr relay - no response to protocol test", + "relayErrorOnlySecure": "Only secure websockets (wss://) are allowed", + "relayErrorNoHttp": "HTTP URLs are not supported. Use websocket URLs (wss://)", + "relayErrorInvalidDomain": "Invalid domain format. Use format like: relay.example.com", + "relayErrorAlreadyExists": "This relay is already in your list", + "relayErrorConnectionTimeout": "Relay unreachable - connection timeout", + "relayTestingMessage": "Testing relay...", + "relayAddedSuccessfully": "Relay added successfully: {url}", + "@relayAddedSuccessfully": { + "description": "Message shown when a relay is successfully added", + "placeholders": { + "url": { + "type": "String", + "description": "The relay URL that was added" + } + } + }, + "relayAddedUnreachable": "Relay added but appears unreachable: {url}", + "@relayAddedUnreachable": { + "description": "Message shown when a relay is added but not responding", + "placeholders": { + "url": { + "type": "String", + "description": "The relay URL that was added" + } + } + }, + "invalidRelayTitle": "Invalid Relay", + "relayUrlHint": "relay.example.com or wss://relay.example.com", "add": "Add", "save": "Save", diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 648ac989..26d20f2a 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -683,6 +683,35 @@ "@_comment_relay_dialogs": "Texto de Diálogos de Relay", "editRelay": "Editar Relay", "relayUrl": "URL del Relay", + "relayErrorNotValid": "No es un relay Nostr válido - sin respuesta al test de protocolo", + "relayErrorOnlySecure": "Solo se permiten websockets seguros (wss://)", + "relayErrorNoHttp": "Las URLs HTTP no son compatibles. Use URLs websocket (wss://)", + "relayErrorInvalidDomain": "Formato de dominio inválido. Use formato como: relay.example.com", + "relayErrorAlreadyExists": "Este relay ya está en tu lista", + "relayErrorConnectionTimeout": "Relay inalcanzable - tiempo de conexión agotado", + "relayTestingMessage": "Probando relay...", + "relayAddedSuccessfully": "Relay agregado exitosamente: {url}", + "@relayAddedSuccessfully": { + "description": "Mensaje mostrado cuando un relay se agrega exitosamente", + "placeholders": { + "url": { + "type": "String", + "description": "La URL del relay que fue agregado" + } + } + }, + "relayAddedUnreachable": "Relay agregado pero parece inalcanzable: {url}", + "@relayAddedUnreachable": { + "description": "Mensaje mostrado cuando un relay se agrega pero no responde", + "placeholders": { + "url": { + "type": "String", + "description": "La URL del relay que fue agregado" + } + } + }, + "invalidRelayTitle": "Relay Inválido", + "relayUrlHint": "relay.ejemplo.com o wss://relay.ejemplo.com", "add": "Agregar", "save": "Guardar", diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index e62d9dbf..7fdcbc1c 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -691,6 +691,35 @@ "@_comment_relay_dialogs": "Testo Dialoghi Relay", "editRelay": "Modifica Relay", "relayUrl": "URL del Relay", + "relayErrorNotValid": "Non è un relay Nostr valido - nessuna risposta al test del protocollo", + "relayErrorOnlySecure": "Sono consentiti solo websocket sicuri (wss://)", + "relayErrorNoHttp": "Gli URL HTTP non sono supportati. Usa URL websocket (wss://)", + "relayErrorInvalidDomain": "Formato dominio non valido. Usa formato come: relay.example.com", + "relayErrorAlreadyExists": "Questo relay è già nella tua lista", + "relayErrorConnectionTimeout": "Relay irraggiungibile - timeout connessione", + "relayTestingMessage": "Testando relay...", + "relayAddedSuccessfully": "Relay aggiunto con successo: {url}", + "@relayAddedSuccessfully": { + "description": "Messaggio mostrato quando un relay viene aggiunto con successo", + "placeholders": { + "url": { + "type": "String", + "description": "L'URL del relay che è stato aggiunto" + } + } + }, + "relayAddedUnreachable": "Relay aggiunto ma sembra irraggiungibile: {url}", + "@relayAddedUnreachable": { + "description": "Messaggio mostrato quando un relay viene aggiunto ma non risponde", + "placeholders": { + "url": { + "type": "String", + "description": "L'URL del relay che è stato aggiunto" + } + } + }, + "invalidRelayTitle": "Relay Non Valido", + "relayUrlHint": "relay.esempio.com o wss://relay.esempio.com", "add": "Aggiungi", "save": "Salva", diff --git a/lib/services/nostr_service.dart b/lib/services/nostr_service.dart index b49c71ff..d13f7058 100644 --- a/lib/services/nostr_service.dart +++ b/lib/services/nostr_service.dart @@ -12,15 +12,29 @@ import 'package:mostro_mobile/services/deep_link_service.dart'; import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; class NostrService { - late Settings settings; + Settings? _settings; final Nostr _nostr = Nostr.instance; final Logger _logger = Logger(); bool _isInitialized = false; NostrService(); + + /// Safe getter for settings with fallback + Settings get settings => _settings ?? Settings( + relays: [], + fullPrivacyMode: false, + mostroPublicKey: '', + ); Future init(Settings settings) async { - this.settings = settings; + // Validate settings before initialization + if (settings.relays.isEmpty) { + throw Exception('Cannot initialize NostrService: No relays provided'); + } + + _logger.i('Initializing NostrService with relays: ${settings.relays}'); + _settings = settings; + try { await _nostr.services.relays.init( relaysUrl: settings.relays, @@ -30,39 +44,69 @@ class NostrService { retryOnError: true, onRelayListening: (relayUrl, receivedData, channel) { if (receivedData is NostrEvent) { - _logger.i('Event from $relayUrl: ${receivedData.content}'); + _logger.d('Event from $relayUrl: ${receivedData.id}'); } else if (receivedData is NostrNotice) { _logger.i('Notice from $relayUrl: ${receivedData.message}'); } else if (receivedData is NostrEventOkCommand) { - _logger.i( - 'OK from $relayUrl: ${receivedData.eventId} (accepted: ${receivedData.isEventAccepted})'); + _logger.d('OK from $relayUrl: ${receivedData.eventId} (accepted: ${receivedData.isEventAccepted})'); } else if (receivedData is NostrRequestEoseCommand) { - _logger.i( - 'EOSE from $relayUrl for subscription: ${receivedData.subscriptionId}'); + _logger.d('EOSE from $relayUrl for subscription: ${receivedData.subscriptionId}'); } else if (receivedData is NostrCountResponse) { - _logger.i('Count from $relayUrl: ${receivedData.count}'); + _logger.d('Count from $relayUrl: ${receivedData.count}'); } }, onRelayConnectionError: (relay, error, channel) { _logger.w('Failed to connect to relay $relay: $error'); }, onRelayConnectionDone: (relay, socket) { - _logger.i('Connection to relay: $relay via $socket is done'); + _logger.i('Successfully connected to relay: $relay'); }, ); + _isInitialized = true; - _logger.i('Nostr initialized successfully'); + _logger.i('NostrService initialized successfully with ${settings.relays.length} relays'); } catch (e) { - _logger.e('Failed to initialize Nostr: $e'); + _isInitialized = false; + _logger.e('Failed to initialize NostrService: $e'); rethrow; } } Future updateSettings(Settings newSettings) async { - final relays = Nostr.instance.services.relays.relaysList; - if (!ListEquality().equals(relays, newSettings.relays)) { - _logger.i('Updating relays...'); - await init(newSettings); + // Compare with current settings instead of relying on dart_nostr internal state + if (!ListEquality().equals(settings.relays, newSettings.relays)) { + _logger.i('Updating relays from ${settings.relays} to ${newSettings.relays}'); + + // Validate that new relay list is not empty + if (newSettings.relays.isEmpty) { + _logger.w('Warning: Attempting to update with empty relay list'); + return; + } + + try { + // Set initialization flag to false during update to prevent race conditions + _isInitialized = false; + + // Disconnect from current relays first + await _nostr.services.relays.disconnectFromRelays(); + _logger.i('Disconnected from previous relays'); + + // Initialize with new relay list + await init(newSettings); + _logger.i('Successfully updated to new relays: ${newSettings.relays}'); + } catch (e) { + _logger.e('Failed to update relays: $e'); + // Try to restore previous state if update fails + try { + await init(settings); + _logger.i('Restored previous relay configuration'); + } catch (restoreError) { + _logger.e('Failed to restore previous relay configuration: $restoreError'); + rethrow; + } + } + } else { + _logger.d('Relay list unchanged, skipping update'); } } @@ -77,13 +121,28 @@ class NostrService { throw Exception('Nostr is not initialized. Call init() first.'); } + // Defensive check: ensure relay list is not empty + if (settings.relays.isEmpty) { + throw Exception('Cannot publish event: No relays configured. Please add at least one relay.'); + } + try { + _logger.i('Publishing event ${event.id} to relays: ${settings.relays}'); + await _nostr.services.relays.sendEventToRelaysAsync( event, timeout: Config.nostrConnectionTimeout, ); + + _logger.i('Successfully published event ${event.id}'); } catch (e) { - _logger.w('Failed to publish event: $e'); + _logger.w('Failed to publish event ${event.id}: $e'); + + // If it's the empty relay list assertion error, provide better context + if (e.toString().contains('relaysUrl.isNotEmpty')) { + throw Exception('Cannot publish event: Relay list is empty or not properly initialized. Current relays: ${settings.relays}'); + } + rethrow; } } diff --git a/test/features/relays/relays_notifier_test.dart b/test/features/relays/relays_notifier_test.dart new file mode 100644 index 00000000..fb6f1915 --- /dev/null +++ b/test/features/relays/relays_notifier_test.dart @@ -0,0 +1,213 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/features/relays/relays_notifier.dart'; + +import '../../mocks.dart'; + +void main() { + group('RelaysNotifier', () { + late RelaysNotifier notifier; + late MockSettingsNotifier mockSettings; + + setUp(() { + mockSettings = MockSettingsNotifier(); + notifier = RelaysNotifier(mockSettings); + }); + + group('normalizeRelayUrl', () { + test('should accept valid wss:// URLs', () { + expect(notifier.normalizeRelayUrl('wss://relay.mostro.network'), + 'wss://relay.mostro.network'); + expect(notifier.normalizeRelayUrl('WSS://RELAY.EXAMPLE.COM'), + 'wss://relay.example.com'); + expect(notifier.normalizeRelayUrl(' wss://relay.test.com '), + 'wss://relay.test.com'); + }); + + test('should add wss:// prefix to domain-only inputs', () { + expect(notifier.normalizeRelayUrl('relay.mostro.network'), + 'wss://relay.mostro.network'); + expect(notifier.normalizeRelayUrl('example.com'), + 'wss://example.com'); + expect(notifier.normalizeRelayUrl('sub.domain.example.org'), + 'wss://sub.domain.example.org'); + }); + + test('should reject non-secure websockets', () { + expect(notifier.normalizeRelayUrl('ws://relay.example.com'), null); + expect(notifier.normalizeRelayUrl('WS://RELAY.TEST.COM'), null); + }); + + test('should reject http URLs', () { + expect(notifier.normalizeRelayUrl('http://example.com'), null); + expect(notifier.normalizeRelayUrl('https://example.com'), null); + expect(notifier.normalizeRelayUrl('HTTP://EXAMPLE.COM'), null); + }); + + test('should reject invalid formats', () { + expect(notifier.normalizeRelayUrl('holahola'), null); + expect(notifier.normalizeRelayUrl('not-a-domain'), null); + expect(notifier.normalizeRelayUrl(''), null); + expect(notifier.normalizeRelayUrl(' '), null); + expect(notifier.normalizeRelayUrl('invalid..domain'), null); + expect(notifier.normalizeRelayUrl('.example.com'), null); + expect(notifier.normalizeRelayUrl('example.'), null); + }); + + test('should handle edge cases', () { + expect(notifier.normalizeRelayUrl('localhost.local'), 'wss://localhost.local'); + expect(notifier.normalizeRelayUrl('192.168.1.1'), null); // IP without domain + expect(notifier.normalizeRelayUrl('test'), null); // No dot + expect(notifier.normalizeRelayUrl('test.'), null); // Ends with dot + }); + }); + + group('isValidDomainFormat', () { + test('should accept valid domains', () { + expect(notifier.isValidDomainFormat('relay.mostro.network'), true); + expect(notifier.isValidDomainFormat('example.com'), true); + expect(notifier.isValidDomainFormat('sub.domain.example.org'), true); + expect(notifier.isValidDomainFormat('wss://relay.example.com'), true); + expect(notifier.isValidDomainFormat('test-relay.example.com'), true); + expect(notifier.isValidDomainFormat('a.b'), true); + }); + + test('should reject invalid domains', () { + expect(notifier.isValidDomainFormat('holahola'), false); + expect(notifier.isValidDomainFormat('invalid..domain'), false); + expect(notifier.isValidDomainFormat('.example.com'), false); + expect(notifier.isValidDomainFormat('example.'), false); + expect(notifier.isValidDomainFormat(''), false); + expect(notifier.isValidDomainFormat('test'), false); // No dot + expect(notifier.isValidDomainFormat('-example.com'), false); + expect(notifier.isValidDomainFormat('example-.com'), false); + }); + + test('should handle protocol prefixes correctly', () { + expect(notifier.isValidDomainFormat('wss://relay.example.com'), true); + expect(notifier.isValidDomainFormat('ws://relay.example.com'), true); + expect(notifier.isValidDomainFormat('http://relay.example.com'), true); + expect(notifier.isValidDomainFormat('https://relay.example.com'), true); + }); + }); + + group('addRelayWithSmartValidation', () { + test('should return error for invalid domain format', () async { + final result = await notifier.addRelayWithSmartValidation( + 'holahola', + errorOnlySecure: 'Only secure websockets (wss://) are allowed', + errorNoHttp: 'HTTP URLs are not supported. Use websocket URLs (wss://)', + errorInvalidDomain: 'Invalid domain format. Use format like: relay.example.com', + errorAlreadyExists: 'This relay is already in your list', + errorNotValid: 'Not a valid Nostr relay - no response to protocol test', + ); + expect(result.success, false); + expect(result.error, contains('Invalid domain format')); + }); + + test('should return error for non-secure websocket', () async { + final result = await notifier.addRelayWithSmartValidation( + 'ws://relay.example.com', + errorOnlySecure: 'Only secure websockets (wss://) are allowed', + errorNoHttp: 'HTTP URLs are not supported. Use websocket URLs (wss://)', + errorInvalidDomain: 'Invalid domain format. Use format like: relay.example.com', + errorAlreadyExists: 'This relay is already in your list', + errorNotValid: 'Not a valid Nostr relay - no response to protocol test', + ); + expect(result.success, false); + expect(result.error, contains('Only secure websockets')); + }); + + test('should return error for http URLs', () async { + final result = await notifier.addRelayWithSmartValidation( + 'http://example.com', + errorOnlySecure: 'Only secure websockets (wss://) are allowed', + errorNoHttp: 'HTTP URLs are not supported. Use websocket URLs (wss://)', + errorInvalidDomain: 'Invalid domain format. Use format like: relay.example.com', + errorAlreadyExists: 'This relay is already in your list', + errorNotValid: 'Not a valid Nostr relay - no response to protocol test', + ); + expect(result.success, false); + expect(result.error, contains('HTTP URLs are not supported')); + }); + + test('should return error for https URLs', () async { + final result = await notifier.addRelayWithSmartValidation( + 'https://example.com', + errorOnlySecure: 'Only secure websockets (wss://) are allowed', + errorNoHttp: 'HTTP URLs are not supported. Use websocket URLs (wss://)', + errorInvalidDomain: 'Invalid domain format. Use format like: relay.example.com', + errorAlreadyExists: 'This relay is already in your list', + errorNotValid: 'Not a valid Nostr relay - no response to protocol test', + ); + expect(result.success, false); + expect(result.error, contains('HTTP URLs are not supported')); + }); + }); + + group('Real relay connectivity tests', () { + test('should accept valid working relay relay.damus.io', () async { + final result = await notifier.addRelayWithSmartValidation( + 'relay.damus.io', + errorOnlySecure: 'Only secure websockets (wss://) are allowed', + errorNoHttp: 'HTTP URLs are not supported. Use websocket URLs (wss://)', + errorInvalidDomain: 'Invalid domain format. Use format like: relay.example.com', + errorAlreadyExists: 'This relay is already in your list', + errorNotValid: 'Not a valid Nostr relay - no response to protocol test', + ); + expect(result.success, true, reason: 'relay.damus.io should be accepted as valid'); + expect(result.normalizedUrl, 'wss://relay.damus.io'); + expect(result.isHealthy, true, reason: 'relay.damus.io should respond to protocol test'); + }, timeout: const Timeout(Duration(seconds: 30))); + + test('should reject non-existent relay re.xyz.com', () async { + final result = await notifier.addRelayWithSmartValidation( + 're.xyz.com', + errorOnlySecure: 'Only secure websockets (wss://) are allowed', + errorNoHttp: 'HTTP URLs are not supported. Use websocket URLs (wss://)', + errorInvalidDomain: 'Invalid domain format. Use format like: relay.example.com', + errorAlreadyExists: 'This relay is already in your list', + errorNotValid: 'Not a valid Nostr relay - no response to protocol test', + ); + expect(result.success, false, reason: 're.xyz.com should be rejected as non-existent'); + expect(result.error, contains('Not a valid Nostr relay')); + }, timeout: const Timeout(Duration(seconds: 30))); + }); + + group('URL edge cases', () { + test('should handle various domain formats', () { + final validCases = [ + 'relay.mostro.network', + 'sub.domain.example.com', + 'test-relay.example.org', + 'a.b.c.d.e.com', + 'relay123.example456.com', + ]; + + for (final domain in validCases) { + expect(notifier.normalizeRelayUrl(domain), 'wss://$domain', + reason: 'Should accept valid domain: $domain'); + } + }); + + test('should reject invalid domain formats', () { + final invalidCases = [ + 'holahola', + 'not-a-domain', + 'test', + '-invalid.com', + 'invalid-.com', + 'invalid..com', + '.invalid.com', + 'invalid.com.', + '', + ' ', + ]; + + for (final domain in invalidCases) { + expect(notifier.normalizeRelayUrl(domain), null, + reason: 'Should reject invalid domain: $domain'); + } + }); + }); + }); +} \ No newline at end of file diff --git a/test/features/relays/widgets/relay_selector_test.dart b/test/features/relays/widgets/relay_selector_test.dart new file mode 100644 index 00000000..02c799bb --- /dev/null +++ b/test/features/relays/widgets/relay_selector_test.dart @@ -0,0 +1,336 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:mostro_mobile/features/relays/relays_notifier.dart'; +import 'package:mostro_mobile/features/relays/relays_provider.dart'; +import 'package:mostro_mobile/features/relays/widgets/relay_selector.dart'; +import 'package:mostro_mobile/generated/l10n.dart'; + +import '../../../mocks.mocks.dart'; + +void main() { + group('RelaySelector Dialog Integration Tests', () { + late MockRelaysNotifier mockNotifier; + + setUp(() { + mockNotifier = MockRelaysNotifier(); + when(mockNotifier.state).thenReturn([]); + }); + + Widget createTestWidget() { + return ProviderScope( + overrides: [ + relaysProvider.overrideWith((ref) => mockNotifier), + ], + child: MaterialApp( + localizationsDelegates: const [ + S.delegate, + ], + supportedLocales: const [ + Locale('en'), + Locale('es'), + Locale('it'), + ], + theme: ThemeData.dark(), + home: Scaffold( + body: Consumer( + builder: (context, ref, child) => ElevatedButton( + onPressed: () => RelaySelector.showAddDialog(context, ref), + child: const Text('Show Dialog'), + ), + ), + ), + ), + ); + } + + testWidgets('should show add relay dialog with proper UI elements', (tester) async { + await tester.pumpWidget(createTestWidget()); + + // Tap the button to show dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Verify dialog elements are present + expect(find.text('Add Relay'), findsOneWidget); + expect(find.byType(TextField), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + expect(find.text('Add'), findsOneWidget); + expect(find.text('relay.example.com or wss://relay.example.com'), findsOneWidget); + }); + + testWidgets('should show error dialog for invalid input', (tester) async { + // Mock validation result for invalid input + when(mockNotifier.addRelayWithSmartValidation( + 'holahola', + errorOnlySecure: anyNamed('errorOnlySecure'), + errorNoHttp: anyNamed('errorNoHttp'), + errorInvalidDomain: anyNamed('errorInvalidDomain'), + errorAlreadyExists: anyNamed('errorAlreadyExists'), + errorNotValid: anyNamed('errorNotValid'), + )).thenAnswer((_) async => RelayValidationResult( + success: false, + error: 'Invalid domain format. Use format like: relay.example.com', + )); + + await tester.pumpWidget(createTestWidget()); + + // Show dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Enter invalid input + await tester.enterText(find.byType(TextField), 'holahola'); + + // Tap Add button + await tester.tap(find.text('Add')); + await tester.pumpAndSettle(); + + // Wait for loading dialog and error dialog + await tester.pumpAndSettle(const Duration(seconds: 1)); + + // Verify error dialog appears + expect(find.text('Invalid Relay'), findsOneWidget); + expect(find.text('Invalid domain format. Use format like: relay.example.com'), + findsOneWidget); + }); + + testWidgets('should show loading indicator during validation', (tester) async { + // Mock slow validation + final completer = Completer(); + when(mockNotifier.addRelayWithSmartValidation( + 'relay.example.com', + errorOnlySecure: anyNamed('errorOnlySecure'), + errorNoHttp: anyNamed('errorNoHttp'), + errorInvalidDomain: anyNamed('errorInvalidDomain'), + errorAlreadyExists: anyNamed('errorAlreadyExists'), + errorNotValid: anyNamed('errorNotValid'), + )).thenAnswer((_) => completer.future); + + await tester.pumpWidget(createTestWidget()); + + // Show dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Enter valid input + await tester.enterText(find.byType(TextField), 'relay.example.com'); + + // Tap Add button + await tester.tap(find.text('Add')); + await tester.pump(); + + // Verify loading dialog appears + expect(find.text('Testing relay...'), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + // Complete the validation + completer.complete(RelayValidationResult( + success: true, + normalizedUrl: 'wss://relay.example.com', + isHealthy: true, + )); + + await tester.pumpAndSettle(); + + // Loading dialog should be gone + expect(find.text('Testing relay...'), findsNothing); + }); + + testWidgets('should show success message for valid relay', (tester) async { + when(mockNotifier.addRelayWithSmartValidation( + 'relay.example.com', + errorOnlySecure: anyNamed('errorOnlySecure'), + errorNoHttp: anyNamed('errorNoHttp'), + errorInvalidDomain: anyNamed('errorInvalidDomain'), + errorAlreadyExists: anyNamed('errorAlreadyExists'), + errorNotValid: anyNamed('errorNotValid'), + )).thenAnswer((_) async => RelayValidationResult( + success: true, + normalizedUrl: 'wss://relay.example.com', + isHealthy: true, + )); + + await tester.pumpWidget(createTestWidget()); + + // Show dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Enter valid input + await tester.enterText(find.byType(TextField), 'relay.example.com'); + + // Tap Add button + await tester.tap(find.text('Add')); + await tester.pumpAndSettle(); + + // Verify success message appears + expect(find.text('Relay added successfully: wss://relay.example.com'), + findsOneWidget); + + // Dialog should be closed + expect(find.text('Add Relay'), findsNothing); + }); + + testWidgets('should show error for unreachable relay', (tester) async { + when(mockNotifier.addRelayWithSmartValidation( + 'unreachable.example.com', + errorOnlySecure: anyNamed('errorOnlySecure'), + errorNoHttp: anyNamed('errorNoHttp'), + errorInvalidDomain: anyNamed('errorInvalidDomain'), + errorAlreadyExists: anyNamed('errorAlreadyExists'), + errorNotValid: anyNamed('errorNotValid'), + )).thenAnswer((_) async => RelayValidationResult( + success: false, + error: 'Not a valid Nostr relay - no response to protocol test', + )); + + await tester.pumpWidget(createTestWidget()); + + // Show dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Enter unreachable relay + await tester.enterText(find.byType(TextField), 'unreachable.example.com'); + + // Tap Add button + await tester.tap(find.text('Add')); + await tester.pumpAndSettle(); + + // Verify error dialog appears + expect(find.text('Invalid Relay'), findsOneWidget); + expect(find.text('Not a valid Nostr relay - no response to protocol test'), + findsOneWidget); + }); + + testWidgets('should handle different error types correctly', (tester) async { + final errorTestCases = [ + { + 'input': 'ws://insecure.example.com', + 'error': 'Only secure websockets (wss://) are allowed', + }, + { + 'input': 'http://example.com', + 'error': 'HTTP URLs are not supported. Use websocket URLs (wss://)', + }, + { + 'input': 'wss://existing.relay.com', + 'error': 'This relay is already in your list', + }, + ]; + + for (final testCase in errorTestCases) { + // Setup mock for this test case + when(mockNotifier.addRelayWithSmartValidation( + testCase['input']!, + errorOnlySecure: anyNamed('errorOnlySecure'), + errorNoHttp: anyNamed('errorNoHttp'), + errorInvalidDomain: anyNamed('errorInvalidDomain'), + errorAlreadyExists: anyNamed('errorAlreadyExists'), + errorNotValid: anyNamed('errorNotValid'), + )).thenAnswer((_) async => RelayValidationResult( + success: false, + error: testCase['error']!, + )); + + await tester.pumpWidget(createTestWidget()); + + // Show dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Enter input + await tester.enterText(find.byType(TextField), testCase['input']!); + + // Tap Add button + await tester.tap(find.text('Add')); + await tester.pumpAndSettle(); + + // Verify specific error message + expect(find.text(testCase['error']!), findsOneWidget, + reason: 'Error message not found for input: ${testCase['input']}'); + + // Close error dialog + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + // Close add relay dialog + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + } + }); + + testWidgets('should not submit empty input', (tester) async { + await tester.pumpWidget(createTestWidget()); + + // Show dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Leave TextField empty and tap Add + await tester.tap(find.text('Add')); + await tester.pumpAndSettle(); + + // Verify no validation was called + verifyZeroInteractions(mockNotifier); + + // Dialog should still be open + expect(find.text('Add Relay'), findsOneWidget); + }); + + testWidgets('should handle whitespace-only input', (tester) async { + await tester.pumpWidget(createTestWidget()); + + // Show dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Enter only whitespace + await tester.enterText(find.byType(TextField), ' '); + await tester.tap(find.text('Add')); + await tester.pumpAndSettle(); + + // Verify no validation was called + verifyZeroInteractions(mockNotifier); + }); + + testWidgets('should trim input before validation', (tester) async { + when(mockNotifier.addRelayWithSmartValidation( + 'relay.example.com', + errorOnlySecure: anyNamed('errorOnlySecure'), + errorNoHttp: anyNamed('errorNoHttp'), + errorInvalidDomain: anyNamed('errorInvalidDomain'), + errorAlreadyExists: anyNamed('errorAlreadyExists'), + errorNotValid: anyNamed('errorNotValid'), + )).thenAnswer((_) async => RelayValidationResult( + success: true, + normalizedUrl: 'wss://relay.example.com', + isHealthy: true, + )); + + await tester.pumpWidget(createTestWidget()); + + // Show dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Enter input with whitespace + await tester.enterText(find.byType(TextField), ' relay.example.com '); + await tester.tap(find.text('Add')); + await tester.pumpAndSettle(); + + // Verify validation was called with trimmed input + verify(mockNotifier.addRelayWithSmartValidation( + 'relay.example.com', + errorOnlySecure: anyNamed('errorOnlySecure'), + errorNoHttp: anyNamed('errorNoHttp'), + errorInvalidDomain: anyNamed('errorInvalidDomain'), + errorAlreadyExists: anyNamed('errorAlreadyExists'), + errorNotValid: anyNamed('errorNotValid'), + )).called(1); + }); + }); +} \ No newline at end of file diff --git a/test/mocks.dart b/test/mocks.dart index 317f6f70..87de30db 100644 --- a/test/mocks.dart +++ b/test/mocks.dart @@ -8,6 +8,7 @@ import 'package:mostro_mobile/data/repositories/open_orders_repository.dart'; import 'package:mostro_mobile/data/repositories/session_storage.dart'; import 'package:mostro_mobile/data/repositories/mostro_storage.dart'; import 'package:mostro_mobile/features/key_manager/key_manager.dart'; +import 'package:mostro_mobile/features/relays/relays_notifier.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/features/settings/settings_notifier.dart'; import 'package:mostro_mobile/features/subscriptions/subscription_manager.dart'; @@ -33,15 +34,19 @@ import 'mocks.mocks.dart'; Settings, Ref, ProviderSubscription, + RelaysNotifier, ]) // Custom mock for SettingsNotifier that returns a specific Settings object class MockSettingsNotifier extends SettingsNotifier { - final Settings _testSettings; - - MockSettingsNotifier(this._testSettings, MockSharedPreferencesAsync prefs) - : super(prefs) { - state = _testSettings; + MockSettingsNotifier() : super(MockSharedPreferencesAsync()) { + state = Settings( + relays: [], + fullPrivacyMode: false, + mostroPublicKey: 'test', + defaultFiatCode: 'USD', + selectedLanguage: null, + ); } } diff --git a/test/mocks.mocks.dart b/test/mocks.mocks.dart index 516b85ff..b5d45d54 100644 --- a/test/mocks.mocks.dart +++ b/test/mocks.mocks.dart @@ -6,25 +6,29 @@ import 'dart:async' as _i5; import 'package:dart_nostr/dart_nostr.dart' as _i3; -import 'package:dart_nostr/nostr/model/relay_informations.dart' as _i10; +import 'package:dart_nostr/nostr/model/relay_informations.dart' as _i12; import 'package:flutter_riverpod/flutter_riverpod.dart' as _i4; import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i11; +import 'package:mockito/src/dummies.dart' as _i13; import 'package:mostro_mobile/data/models.dart' as _i7; -import 'package:mostro_mobile/data/models/order.dart' as _i12; -import 'package:mostro_mobile/data/repositories/mostro_storage.dart' as _i20; +import 'package:mostro_mobile/data/models/order.dart' as _i14; +import 'package:mostro_mobile/data/repositories/mostro_storage.dart' as _i22; import 'package:mostro_mobile/data/repositories/open_orders_repository.dart' - as _i15; -import 'package:mostro_mobile/data/repositories/session_storage.dart' as _i18; -import 'package:mostro_mobile/features/key_manager/key_manager.dart' as _i19; + as _i17; +import 'package:mostro_mobile/data/repositories/session_storage.dart' as _i20; +import 'package:mostro_mobile/features/key_manager/key_manager.dart' as _i21; +import 'package:mostro_mobile/features/relays/relay.dart' as _i23; +import 'package:mostro_mobile/features/relays/relays_notifier.dart' as _i10; import 'package:mostro_mobile/features/settings/settings.dart' as _i2; -import 'package:mostro_mobile/services/deep_link_service.dart' as _i13; -import 'package:mostro_mobile/services/mostro_service.dart' as _i14; -import 'package:mostro_mobile/services/nostr_service.dart' as _i9; +import 'package:mostro_mobile/features/settings/settings_notifier.dart' as _i9; +import 'package:mostro_mobile/services/deep_link_service.dart' as _i15; +import 'package:mostro_mobile/services/mostro_service.dart' as _i16; +import 'package:mostro_mobile/services/nostr_service.dart' as _i11; import 'package:riverpod/src/internals.dart' as _i8; import 'package:sembast/sembast.dart' as _i6; -import 'package:sembast/src/api/transaction.dart' as _i17; -import 'package:shared_preferences/src/shared_preferences_async.dart' as _i16; +import 'package:sembast/src/api/transaction.dart' as _i19; +import 'package:shared_preferences/src/shared_preferences_async.dart' as _i18; +import 'package:state_notifier/state_notifier.dart' as _i24; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -185,10 +189,32 @@ class _FakeNode_13 extends _i1.SmartFake implements _i8.Node { ); } +class _FakeSettingsNotifier_14 extends _i1.SmartFake + implements _i9.SettingsNotifier { + _FakeSettingsNotifier_14( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeRelayValidationResult_15 extends _i1.SmartFake + implements _i10.RelayValidationResult { + _FakeRelayValidationResult_15( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + /// A class which mocks [NostrService]. /// /// See the documentation for Mockito's code generation for more information. -class MockNostrService extends _i1.Mock implements _i9.NostrService { +class MockNostrService extends _i1.Mock implements _i11.NostrService { MockNostrService() { _i1.throwOnMissingStub(this); } @@ -208,15 +234,6 @@ class MockNostrService extends _i1.Mock implements _i9.NostrService { returnValue: false, ) as bool); - @override - set settings(_i2.Settings? _settings) => super.noSuchMethod( - Invocation.setter( - #settings, - _settings, - ), - returnValueForMissingStub: null, - ); - @override _i5.Future init(_i2.Settings? settings) => (super.noSuchMethod( Invocation.method( @@ -239,14 +256,14 @@ class MockNostrService extends _i1.Mock implements _i9.NostrService { ) as _i5.Future); @override - _i5.Future<_i10.RelayInformations?> getRelayInfo(String? relayUrl) => + _i5.Future<_i12.RelayInformations?> getRelayInfo(String? relayUrl) => (super.noSuchMethod( Invocation.method( #getRelayInfo, [relayUrl], ), - returnValue: _i5.Future<_i10.RelayInformations?>.value(), - ) as _i5.Future<_i10.RelayInformations?>); + returnValue: _i5.Future<_i12.RelayInformations?>.value(), + ) as _i5.Future<_i12.RelayInformations?>); @override _i5.Future publishEvent(_i3.NostrEvent? event) => (super.noSuchMethod( @@ -329,7 +346,7 @@ class MockNostrService extends _i1.Mock implements _i9.NostrService { #getMostroPubKey, [], ), - returnValue: _i11.dummyValue( + returnValue: _i13.dummyValue( this, Invocation.method( #getMostroPubKey, @@ -408,7 +425,7 @@ class MockNostrService extends _i1.Mock implements _i9.NostrService { content, ], ), - returnValue: _i5.Future.value(_i11.dummyValue( + returnValue: _i5.Future.value(_i13.dummyValue( this, Invocation.method( #createRumor, @@ -439,7 +456,7 @@ class MockNostrService extends _i1.Mock implements _i9.NostrService { encryptedContent, ], ), - returnValue: _i5.Future.value(_i11.dummyValue( + returnValue: _i5.Future.value(_i13.dummyValue( this, Invocation.method( #createSeal, @@ -491,7 +508,7 @@ class MockNostrService extends _i1.Mock implements _i9.NostrService { ); @override - _i5.Future<_i12.Order?> fetchEventById( + _i5.Future<_i14.Order?> fetchEventById( String? eventId, [ List? specificRelays, ]) => @@ -503,11 +520,11 @@ class MockNostrService extends _i1.Mock implements _i9.NostrService { specificRelays, ], ), - returnValue: _i5.Future<_i12.Order?>.value(), - ) as _i5.Future<_i12.Order?>); + returnValue: _i5.Future<_i14.Order?>.value(), + ) as _i5.Future<_i14.Order?>); @override - _i5.Future<_i13.OrderInfo?> fetchOrderInfoByEventId( + _i5.Future<_i15.OrderInfo?> fetchOrderInfoByEventId( String? eventId, [ List? specificRelays, ]) => @@ -519,14 +536,14 @@ class MockNostrService extends _i1.Mock implements _i9.NostrService { specificRelays, ], ), - returnValue: _i5.Future<_i13.OrderInfo?>.value(), - ) as _i5.Future<_i13.OrderInfo?>); + returnValue: _i5.Future<_i15.OrderInfo?>.value(), + ) as _i5.Future<_i15.OrderInfo?>); } /// A class which mocks [MostroService]. /// /// See the documentation for Mockito's code generation for more information. -class MockMostroService extends _i1.Mock implements _i14.MostroService { +class MockMostroService extends _i1.Mock implements _i16.MostroService { MockMostroService() { _i1.throwOnMissingStub(this); } @@ -706,7 +723,7 @@ class MockMostroService extends _i1.Mock implements _i14.MostroService { /// /// See the documentation for Mockito's code generation for more information. class MockOpenOrdersRepository extends _i1.Mock - implements _i15.OpenOrdersRepository { + implements _i17.OpenOrdersRepository { MockOpenOrdersRepository() { _i1.throwOnMissingStub(this); } @@ -799,7 +816,7 @@ class MockOpenOrdersRepository extends _i1.Mock /// See the documentation for Mockito's code generation for more information. // ignore: must_be_immutable class MockSharedPreferencesAsync extends _i1.Mock - implements _i16.SharedPreferencesAsync { + implements _i18.SharedPreferencesAsync { MockSharedPreferencesAsync() { _i1.throwOnMissingStub(this); } @@ -1005,7 +1022,7 @@ class MockDatabase extends _i1.Mock implements _i6.Database { @override String get path => (super.noSuchMethod( Invocation.getter(#path), - returnValue: _i11.dummyValue( + returnValue: _i13.dummyValue( this, Invocation.getter(#path), ), @@ -1013,14 +1030,14 @@ class MockDatabase extends _i1.Mock implements _i6.Database { @override _i5.Future transaction( - _i5.FutureOr Function(_i17.Transaction)? action) => + _i5.FutureOr Function(_i19.Transaction)? action) => (super.noSuchMethod( Invocation.method( #transaction, [action], ), - returnValue: _i11.ifNotNull( - _i11.dummyValueOrNull( + returnValue: _i13.ifNotNull( + _i13.dummyValueOrNull( this, Invocation.method( #transaction, @@ -1051,7 +1068,7 @@ class MockDatabase extends _i1.Mock implements _i6.Database { /// A class which mocks [SessionStorage]. /// /// See the documentation for Mockito's code generation for more information. -class MockSessionStorage extends _i1.Mock implements _i18.SessionStorage { +class MockSessionStorage extends _i1.Mock implements _i20.SessionStorage { MockSessionStorage() { _i1.throwOnMissingStub(this); } @@ -1314,7 +1331,7 @@ class MockSessionStorage extends _i1.Mock implements _i18.SessionStorage { /// A class which mocks [KeyManager]. /// /// See the documentation for Mockito's code generation for more information. -class MockKeyManager extends _i1.Mock implements _i19.KeyManager { +class MockKeyManager extends _i1.Mock implements _i21.KeyManager { MockKeyManager() { _i1.throwOnMissingStub(this); } @@ -1465,7 +1482,7 @@ class MockKeyManager extends _i1.Mock implements _i19.KeyManager { /// A class which mocks [MostroStorage]. /// /// See the documentation for Mockito's code generation for more information. -class MockMostroStorage extends _i1.Mock implements _i20.MostroStorage { +class MockMostroStorage extends _i1.Mock implements _i22.MostroStorage { MockMostroStorage() { _i1.throwOnMissingStub(this); } @@ -1856,7 +1873,7 @@ class MockSettings extends _i1.Mock implements _i2.Settings { @override String get mostroPublicKey => (super.noSuchMethod( Invocation.getter(#mostroPublicKey), - returnValue: _i11.dummyValue( + returnValue: _i13.dummyValue( this, Invocation.getter(#mostroPublicKey), ), @@ -1932,7 +1949,7 @@ class MockRef extends _i1.Mock #refresh, [provider], ), - returnValue: _i11.dummyValue( + returnValue: _i13.dummyValue( this, Invocation.method( #refresh, @@ -2039,7 +2056,7 @@ class MockRef extends _i1.Mock #read, [provider], ), - returnValue: _i11.dummyValue( + returnValue: _i13.dummyValue( this, Invocation.method( #read, @@ -2063,7 +2080,7 @@ class MockRef extends _i1.Mock #watch, [provider], ), - returnValue: _i11.dummyValue( + returnValue: _i13.dummyValue( this, Invocation.method( #watch, @@ -2159,7 +2176,7 @@ class MockProviderSubscription extends _i1.Mock #read, [], ), - returnValue: _i11.dummyValue( + returnValue: _i13.dummyValue( this, Invocation.method( #read, @@ -2177,3 +2194,218 @@ class MockProviderSubscription extends _i1.Mock returnValueForMissingStub: null, ); } + +/// A class which mocks [RelaysNotifier]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockRelaysNotifier extends _i1.Mock implements _i10.RelaysNotifier { + MockRelaysNotifier() { + _i1.throwOnMissingStub(this); + } + + @override + _i9.SettingsNotifier get settings => (super.noSuchMethod( + Invocation.getter(#settings), + returnValue: _FakeSettingsNotifier_14( + this, + Invocation.getter(#settings), + ), + ) as _i9.SettingsNotifier); + + @override + bool get mounted => (super.noSuchMethod( + Invocation.getter(#mounted), + returnValue: false, + ) as bool); + + @override + _i5.Stream> get stream => (super.noSuchMethod( + Invocation.getter(#stream), + returnValue: _i5.Stream>.empty(), + ) as _i5.Stream>); + + @override + List<_i23.Relay> get state => (super.noSuchMethod( + Invocation.getter(#state), + returnValue: <_i23.Relay>[], + ) as List<_i23.Relay>); + + @override + List<_i23.Relay> get debugState => (super.noSuchMethod( + Invocation.getter(#debugState), + returnValue: <_i23.Relay>[], + ) as List<_i23.Relay>); + + @override + bool get hasListeners => (super.noSuchMethod( + Invocation.getter(#hasListeners), + returnValue: false, + ) as bool); + + @override + set onError(_i4.ErrorListener? _onError) => super.noSuchMethod( + Invocation.setter( + #onError, + _onError, + ), + returnValueForMissingStub: null, + ); + + @override + set state(List<_i23.Relay>? value) => super.noSuchMethod( + Invocation.setter( + #state, + value, + ), + returnValueForMissingStub: null, + ); + + @override + _i5.Future addRelay(_i23.Relay? relay) => (super.noSuchMethod( + Invocation.method( + #addRelay, + [relay], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future updateRelay( + _i23.Relay? oldRelay, + _i23.Relay? updatedRelay, + ) => + (super.noSuchMethod( + Invocation.method( + #updateRelay, + [ + oldRelay, + updatedRelay, + ], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future removeRelay(String? url) => (super.noSuchMethod( + Invocation.method( + #removeRelay, + [url], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + String? normalizeRelayUrl(String? input) => + (super.noSuchMethod(Invocation.method( + #normalizeRelayUrl, + [input], + )) as String?); + + @override + bool isValidDomainFormat(String? input) => (super.noSuchMethod( + Invocation.method( + #isValidDomainFormat, + [input], + ), + returnValue: false, + ) as bool); + + @override + _i5.Future testRelayConnectivity(String? url) => (super.noSuchMethod( + Invocation.method( + #testRelayConnectivity, + [url], + ), + returnValue: _i5.Future.value(false), + ) as _i5.Future); + + @override + _i5.Future<_i10.RelayValidationResult> addRelayWithSmartValidation( + String? input, { + required String? errorOnlySecure, + required String? errorNoHttp, + required String? errorInvalidDomain, + required String? errorAlreadyExists, + required String? errorNotValid, + }) => + (super.noSuchMethod( + Invocation.method( + #addRelayWithSmartValidation, + [input], + { + #errorOnlySecure: errorOnlySecure, + #errorNoHttp: errorNoHttp, + #errorInvalidDomain: errorInvalidDomain, + #errorAlreadyExists: errorAlreadyExists, + #errorNotValid: errorNotValid, + }, + ), + returnValue: _i5.Future<_i10.RelayValidationResult>.value( + _FakeRelayValidationResult_15( + this, + Invocation.method( + #addRelayWithSmartValidation, + [input], + { + #errorOnlySecure: errorOnlySecure, + #errorNoHttp: errorNoHttp, + #errorInvalidDomain: errorInvalidDomain, + #errorAlreadyExists: errorAlreadyExists, + #errorNotValid: errorNotValid, + }, + ), + )), + ) as _i5.Future<_i10.RelayValidationResult>); + + @override + _i5.Future refreshRelayHealth() => (super.noSuchMethod( + Invocation.method( + #refreshRelayHealth, + [], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + bool updateShouldNotify( + List<_i23.Relay>? old, + List<_i23.Relay>? current, + ) => + (super.noSuchMethod( + Invocation.method( + #updateShouldNotify, + [ + old, + current, + ], + ), + returnValue: false, + ) as bool); + + @override + _i4.RemoveListener addListener( + _i24.Listener>? listener, { + bool? fireImmediately = true, + }) => + (super.noSuchMethod( + Invocation.method( + #addListener, + [listener], + {#fireImmediately: fireImmediately}, + ), + returnValue: () {}, + ) as _i4.RemoveListener); + + @override + void dispose() => super.noSuchMethod( + Invocation.method( + #dispose, + [], + ), + returnValueForMissingStub: null, + ); +} diff --git a/test/notifiers/add_order_notifier_test.dart b/test/notifiers/add_order_notifier_test.dart index 0e5e77c5..8f0c28b7 100644 --- a/test/notifiers/add_order_notifier_test.dart +++ b/test/notifiers/add_order_notifier_test.dart @@ -80,8 +80,11 @@ void main() { sessionStorageProvider.overrideWithValue(mockSessionStorage), keyManagerProvider.overrideWithValue(mockKeyManager), sessionNotifierProvider.overrideWith((ref) => mockSessionNotifier), - settingsProvider.overrideWith((ref) => - MockSettingsNotifier(testSettings, mockSharedPreferencesAsync)), + settingsProvider.overrideWith((ref) { + final mockSettings = MockSettingsNotifier(); + mockSettings.state = testSettings; + return mockSettings; + }), mostroStorageProvider.overrideWithValue(mockMostroStorage), ], ); diff --git a/test/notifiers/take_order_notifier_test.dart b/test/notifiers/take_order_notifier_test.dart index 33e6efe4..f34cc6e9 100644 --- a/test/notifiers/take_order_notifier_test.dart +++ b/test/notifiers/take_order_notifier_test.dart @@ -120,15 +120,16 @@ void main() { sessionStorageProvider.overrideWithValue(mockSessionStorage), keyManagerProvider.overrideWithValue(mockKeyManager), sessionNotifierProvider.overrideWith((ref) => mockSessionNotifier), - settingsProvider.overrideWith((ref) => MockSettingsNotifier( - Settings( + settingsProvider.overrideWith((ref) { + final mockSettings = MockSettingsNotifier(); + mockSettings.state = Settings( relays: ['wss://relay.damus.io'], fullPrivacyMode: false, mostroPublicKey: '9d9d0455a96871f2dc4289b8312429db2e925f167b37c77bf7b28014be235980', defaultFiatCode: 'USD', - ), - mockPreferences - )), + ); + return mockSettings; + }), mostroStorageProvider.overrideWithValue(mockMostroStorage), ]); @@ -188,15 +189,17 @@ void main() { sessionStorageProvider.overrideWithValue(mockSessionStorage), keyManagerProvider.overrideWithValue(mockKeyManager), sessionNotifierProvider.overrideWith((ref) => mockSessionNotifier), - settingsProvider.overrideWith((ref) => MockSettingsNotifier( - Settings( - relays: ['wss://relay.damus.io'], - fullPrivacyMode: false, - mostroPublicKey: - '6d5c471d0e88c8c688c85dd8a3d84e3c7c5e8a3b6d7a6b2c9e8c5d9a7b3e6c8a', - defaultFiatCode: 'USD', - ), - mockPreferences)), + settingsProvider.overrideWith((ref) { + final mockSettings = MockSettingsNotifier(); + mockSettings.state = Settings( + relays: ['wss://relay.damus.io'], + fullPrivacyMode: false, + mostroPublicKey: + '6d5c471d0e88c8c688c85dd8a3d84e3c7c5e8a3b6d7a6b2c9e8c5d9a7b3e6c8a', + defaultFiatCode: 'USD', + ); + return mockSettings; + }), mostroStorageProvider.overrideWithValue(mockMostroStorage), ]); @@ -254,15 +257,17 @@ void main() { sessionStorageProvider.overrideWithValue(mockSessionStorage), keyManagerProvider.overrideWithValue(mockKeyManager), sessionNotifierProvider.overrideWith((ref) => mockSessionNotifier), - settingsProvider.overrideWith((ref) => MockSettingsNotifier( - Settings( - relays: ['wss://relay.damus.io'], - fullPrivacyMode: false, - mostroPublicKey: - '6d5c471d0e88c8c688c85dd8a3d84e3c7c5e8a3b6d7a6b2c9e8c5d9a7b3e6c8a', - defaultFiatCode: 'USD', - ), - mockPreferences)), + settingsProvider.overrideWith((ref) { + final mockSettings = MockSettingsNotifier(); + mockSettings.state = Settings( + relays: ['wss://relay.damus.io'], + fullPrivacyMode: false, + mostroPublicKey: + '6d5c471d0e88c8c688c85dd8a3d84e3c7c5e8a3b6d7a6b2c9e8c5d9a7b3e6c8a', + defaultFiatCode: 'USD', + ); + return mockSettings; + }), mostroStorageProvider.overrideWithValue(mockMostroStorage), ]); @@ -307,15 +312,17 @@ void main() { sessionStorageProvider.overrideWithValue(mockSessionStorage), keyManagerProvider.overrideWithValue(mockKeyManager), sessionNotifierProvider.overrideWith((ref) => mockSessionNotifier), - settingsProvider.overrideWith((ref) => MockSettingsNotifier( - Settings( - relays: ['wss://relay.damus.io'], - fullPrivacyMode: false, - mostroPublicKey: - '6d5c471d0e88c8c688c85dd8a3d84e3c7c5e8a3b6d7a6b2c9e8c5d9a7b3e6c8a', - defaultFiatCode: 'USD', - ), - mockPreferences)), + settingsProvider.overrideWith((ref) { + final mockSettings = MockSettingsNotifier(); + mockSettings.state = Settings( + relays: ['wss://relay.damus.io'], + fullPrivacyMode: false, + mostroPublicKey: + '6d5c471d0e88c8c688c85dd8a3d84e3c7c5e8a3b6d7a6b2c9e8c5d9a7b3e6c8a', + defaultFiatCode: 'USD', + ); + return mockSettings; + }), mostroStorageProvider.overrideWithValue(mockMostroStorage), ]);