From 4ff4cb4733c0d5414e7afe2f4f3babfcae5eb9aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Calder=C3=B3n?= Date: Fri, 1 Aug 2025 11:01:42 -0300 Subject: [PATCH 1/4] fix: resolve relay addition and order publishing issues in settings - Fix NostrService relay update mechanism to prevent empty relay list errors during order publishing - Enhance relay validation with fallback WebSocket connectivity testing - Add proper error handling and state management in NostrService updateSettings() - Implement defensive checks in publishEvent() to prevent assertion failures - Add comprehensive localization support for relay-related UI messages - Update all tests to maintain coverage with new method signatures - Resolve BuildContext async gap warnings in relay selector This resolves the issue where users could add new relays through the settings screen but then encounter "relaysUrl.isNotEmpty" assertion failures when trying to publish orders. --- l10n.yaml | 2 +- lib/features/relays/relays_notifier.dart | 300 +++++++++++++++- .../relays/widgets/relay_selector.dart | 120 ++++++- lib/l10n/intl_en.arb | 28 ++ lib/l10n/intl_es.arb | 10 + lib/l10n/intl_it.arb | 10 + lib/services/nostr_service.dart | 91 ++++- .../features/relays/relays_notifier_test.dart | 213 +++++++++++ .../relays/widgets/relay_selector_test.dart | 336 ++++++++++++++++++ test/mocks.dart | 15 +- test/mocks.mocks.dart | 330 ++++++++++++++--- test/notifiers/add_order_notifier_test.dart | 7 +- test/notifiers/take_order_notifier_test.dart | 71 ++-- 13 files changed, 1422 insertions(+), 111 deletions(-) create mode 100644 test/features/relays/relays_notifier_test.dart create mode 100644 test/features/relays/widgets/relay_selector_test.dart 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..5c966ead 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,288 @@ 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 a temporary Nostr instance for testing + final testNostr = Nostr.instance; + + // 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..decfa74e 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: 'relay.example.com or wss://relay.example.com', + 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..5319dda3 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -653,6 +653,34 @@ "@_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", "add": "Add", "save": "Save", diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 648ac989..63560f84 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -683,6 +683,16 @@ "@_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}", + "relayAddedUnreachable": "Relay agregado pero parece inalcanzable: {url}", + "invalidRelayTitle": "Relay Inválido", "add": "Agregar", "save": "Guardar", diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index e62d9dbf..aac7bdb5 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -691,6 +691,16 @@ "@_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}", + "relayAddedUnreachable": "Relay aggiunto ma sembra irraggiungibile: {url}", + "invalidRelayTitle": "Relay Non Valido", "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), ]); From 01e678fbf2fa5cbb84c0c686018f1b74cb35eea8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Calder=C3=B3n?= Date: Fri, 1 Aug 2025 11:19:49 -0300 Subject: [PATCH 2/4] Creating a separate Nostr instance for testing In order to avoid potential conflicts --- lib/features/relays/relays_notifier.dart | 87 ++++++++++++------------ 1 file changed, 42 insertions(+), 45 deletions(-) diff --git a/lib/features/relays/relays_notifier.dart b/lib/features/relays/relays_notifier.dart index 5c966ead..542b0bd4 100644 --- a/lib/features/relays/relays_notifier.dart +++ b/lib/features/relays/relays_notifier.dart @@ -11,7 +11,7 @@ class RelayValidationResult { final String? normalizedUrl; final String? error; final bool isHealthy; - + RelayValidationResult({ required this.success, this.normalizedUrl, @@ -55,9 +55,9 @@ class RelaysNotifier extends StateNotifier> { /// 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')) { @@ -66,7 +66,7 @@ class RelaysNotifier extends StateNotifier> { return 'wss://$input'; // Auto-add wss:// prefix } } - + /// Domain validation using RegExp bool isValidDomainFormat(String input) { // Remove protocol prefix if present @@ -79,19 +79,18 @@ class RelaysNotifier extends StateNotifier> { } 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])?)*$' - ); + 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 { @@ -100,11 +99,11 @@ class RelaysNotifier extends StateNotifier> { 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 @@ -112,11 +111,11 @@ class RelaysNotifier extends StateNotifier> { bool receivedEvent = false; bool receivedEose = false; bool isConnected = false; - + try { - // Create a temporary Nostr instance for testing - final testNostr = Nostr.instance; - + // Create isolated instance for testing + final testNostr = Nostr(); + // Setup listeners to track EVENT and EOSE responses await testNostr.services.relays.init( relaysUrl: [url], @@ -126,16 +125,16 @@ class RelaysNotifier extends StateNotifier> { retryOnError: false, onRelayListening: (relayUrl, receivedData, channel) { // Track EVENT and EOSE responses - + // Check for EVENT message with our subscription ID - if (receivedData is NostrEvent && + if (receivedData is NostrEvent && receivedData.subscriptionId == testSubId) { // Found an event for our subscription receivedEvent = true; } - // Check for EOSE message with our subscription ID + // Check for EOSE message with our subscription ID else if (receivedData is NostrRequestEoseCommand && - receivedData.subscriptionId == testSubId) { + receivedData.subscriptionId == testSubId) { // Found end of stored events for our subscription receivedEose = true; } @@ -151,48 +150,47 @@ class RelaysNotifier extends StateNotifier> { 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 { @@ -203,7 +201,7 @@ class RelaysNotifier extends StateNotifier> { return false; } } - + /// Basic WebSocket connectivity test as fallback Future _testBasicWebSocketConnectivity(String url) async { try { @@ -213,11 +211,11 @@ class RelaysNotifier extends StateNotifier> { 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( @@ -232,28 +230,27 @@ class RelaysNotifier extends StateNotifier> { // 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 { @@ -262,7 +259,7 @@ class RelaysNotifier extends StateNotifier> { // Ignore cleanup errors } } - + /// Smart relay addition with full validation /// Only adds relays that pass BOTH format validation AND connectivity test Future addRelayWithSmartValidation( @@ -293,7 +290,7 @@ class RelaysNotifier extends StateNotifier> { ); } } - + // Step 2: Check for duplicates if (state.any((relay) => relay.url == normalizedUrl)) { return RelayValidationResult( @@ -301,10 +298,10 @@ class RelaysNotifier extends StateNotifier> { 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( @@ -312,12 +309,12 @@ class RelaysNotifier extends StateNotifier> { 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, @@ -327,12 +324,12 @@ class RelaysNotifier extends StateNotifier> { Future refreshRelayHealth() async { final updatedRelays = []; - + for (final relay in state) { final isHealthy = await testRelayConnectivity(relay.url); updatedRelays.add(relay.copyWith(isHealthy: isHealthy)); } - + state = updatedRelays; await _saveRelays(); } From 44ac55da7640bb57e3441558791df104ed4c20dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Calder=C3=B3n?= Date: Fri, 1 Aug 2025 11:28:22 -0300 Subject: [PATCH 3/4] fix: localize hardcoded relay URL hint text Replace hardcoded English hintText in relay input field with localized string. Add relayUrlHint key to all ARB files (en, es, it) with appropriate translations: - English: 'relay.example.com or wss://relay.example.com' - Spanish: 'relay.ejemplo.com o wss://relay.ejemplo.com' - Italian: 'relay.esempio.com o wss://relay.esempio.com' This ensures the relay input field hint text supports multiple languages and complies with the project's localization guidelines. --- lib/features/relays/widgets/relay_selector.dart | 2 +- lib/l10n/intl_en.arb | 1 + lib/l10n/intl_es.arb | 1 + lib/l10n/intl_it.arb | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/features/relays/widgets/relay_selector.dart b/lib/features/relays/widgets/relay_selector.dart index decfa74e..59486fc7 100644 --- a/lib/features/relays/widgets/relay_selector.dart +++ b/lib/features/relays/widgets/relay_selector.dart @@ -97,7 +97,7 @@ class RelaySelector extends ConsumerWidget { labelStyle: const TextStyle(color: AppTheme.textSecondary), border: InputBorder.none, contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - hintText: 'relay.example.com or wss://relay.example.com', + hintText: S.of(context)!.relayUrlHint, hintStyle: const TextStyle(color: AppTheme.textSecondary), ), ), diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 5319dda3..73a2757e 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -681,6 +681,7 @@ } }, "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 63560f84..43f58b91 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -693,6 +693,7 @@ "relayAddedSuccessfully": "Relay agregado exitosamente: {url}", "relayAddedUnreachable": "Relay agregado pero parece inalcanzable: {url}", "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 aac7bdb5..1b18e69f 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -701,6 +701,7 @@ "relayAddedSuccessfully": "Relay aggiunto con successo: {url}", "relayAddedUnreachable": "Relay aggiunto ma sembra irraggiungibile: {url}", "invalidRelayTitle": "Relay Non Valido", + "relayUrlHint": "relay.esempio.com o wss://relay.esempio.com", "add": "Aggiungi", "save": "Salva", From dbe6614d18524261fc244d53a113fe6aa8a8b872 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Calder=C3=B3n?= Date: Fri, 1 Aug 2025 11:30:45 -0300 Subject: [PATCH 4/4] fix: add missing ARB metadata for parameterized relay strings Add required metadata blocks for relayAddedSuccessfully and relayAddedUnreachable in Spanish and Italian ARB files to properly describe the {url} parameter: Spanish (intl_es.arb): - Add @relayAddedSuccessfully metadata with Spanish descriptions - Add @relayAddedUnreachable metadata with Spanish descriptions Italian (intl_it.arb): - Add @relayAddedSuccessfully metadata with Italian descriptions - Add @relayAddedUnreachable metadata with Italian descriptions This ensures proper localization handling of parameterized strings and maintains consistency with the English ARB file metadata structure. --- lib/l10n/intl_es.arb | 18 ++++++++++++++++++ lib/l10n/intl_it.arb | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 43f58b91..26d20f2a 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -691,7 +691,25 @@ "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", diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index 1b18e69f..7fdcbc1c 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -699,7 +699,25 @@ "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",