parent
c2447f4a0d
commit
53d11d49ff
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,240 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../../data/nostr/nostr_service.dart';
|
||||||
|
import '../../data/nostr/models/nostr_relay.dart';
|
||||||
|
import '../../data/sync/sync_engine.dart';
|
||||||
|
|
||||||
|
/// Controller for managing Nostr relay UI state and operations.
|
||||||
|
///
|
||||||
|
/// This controller separates business logic from UI presentation,
|
||||||
|
/// making the UI module testable and modular.
|
||||||
|
class RelayManagementController extends ChangeNotifier {
|
||||||
|
/// Nostr service for relay operations.
|
||||||
|
final NostrService nostrService;
|
||||||
|
|
||||||
|
/// Sync engine for triggering manual syncs.
|
||||||
|
final SyncEngine? syncEngine;
|
||||||
|
|
||||||
|
/// List of relays being managed.
|
||||||
|
List<NostrRelay> _relays = [];
|
||||||
|
|
||||||
|
/// Whether a sync operation is in progress.
|
||||||
|
bool _isSyncing = false;
|
||||||
|
|
||||||
|
/// Error message if any operation fails.
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
/// Whether health check is in progress.
|
||||||
|
bool _isCheckingHealth = false;
|
||||||
|
|
||||||
|
/// Creates a [RelayManagementController] instance.
|
||||||
|
RelayManagementController({
|
||||||
|
required this.nostrService,
|
||||||
|
this.syncEngine,
|
||||||
|
}) {
|
||||||
|
_loadRelays();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List of relays.
|
||||||
|
List<NostrRelay> get relays => List.unmodifiable(_relays);
|
||||||
|
|
||||||
|
/// Whether sync is in progress.
|
||||||
|
bool get isSyncing => _isSyncing;
|
||||||
|
|
||||||
|
/// Current error message, if any.
|
||||||
|
String? get error => _error;
|
||||||
|
|
||||||
|
/// Whether health check is in progress.
|
||||||
|
bool get isCheckingHealth => _isCheckingHealth;
|
||||||
|
|
||||||
|
/// Loads relays from the Nostr service.
|
||||||
|
void _loadRelays() {
|
||||||
|
_relays = nostrService.getRelays();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a relay to the service.
|
||||||
|
///
|
||||||
|
/// [relayUrl] - The WebSocket URL of the relay.
|
||||||
|
///
|
||||||
|
/// Returns true if the relay was added successfully, false if it already exists.
|
||||||
|
bool addRelay(String relayUrl) {
|
||||||
|
try {
|
||||||
|
_error = null;
|
||||||
|
|
||||||
|
// Validate URL format
|
||||||
|
if (!relayUrl.startsWith('wss://') && !relayUrl.startsWith('ws://')) {
|
||||||
|
_error = 'Invalid relay URL. Must start with wss:// or ws://';
|
||||||
|
notifyListeners();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
nostrService.addRelay(relayUrl);
|
||||||
|
_loadRelays();
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
_error = 'Failed to add relay: $e';
|
||||||
|
notifyListeners();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a relay from the service.
|
||||||
|
///
|
||||||
|
/// [relayUrl] - The URL of the relay to remove.
|
||||||
|
void removeRelay(String relayUrl) {
|
||||||
|
try {
|
||||||
|
_error = null;
|
||||||
|
nostrService.removeRelay(relayUrl);
|
||||||
|
_loadRelays();
|
||||||
|
} catch (e) {
|
||||||
|
_error = 'Failed to remove relay: $e';
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connects to a relay and updates status.
|
||||||
|
///
|
||||||
|
/// [relayUrl] - The URL of the relay to connect to.
|
||||||
|
///
|
||||||
|
/// Throws if connection fails (for use in health checks where we want to catch individual failures).
|
||||||
|
Future<void> connectRelay(String relayUrl) async {
|
||||||
|
_error = null;
|
||||||
|
await nostrService.connectRelay(relayUrl);
|
||||||
|
_loadRelays();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disconnects from a relay.
|
||||||
|
///
|
||||||
|
/// [relayUrl] - The URL of the relay to disconnect from.
|
||||||
|
void disconnectRelay(String relayUrl) {
|
||||||
|
try {
|
||||||
|
_error = null;
|
||||||
|
nostrService.disconnectRelay(relayUrl);
|
||||||
|
_loadRelays();
|
||||||
|
} catch (e) {
|
||||||
|
_error = 'Failed to disconnect from relay: $e';
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks health of all relays by attempting to connect.
|
||||||
|
///
|
||||||
|
/// Updates relay connection status.
|
||||||
|
///
|
||||||
|
/// Never throws - all connection failures are handled gracefully.
|
||||||
|
Future<void> checkRelayHealth() async {
|
||||||
|
_isCheckingHealth = true;
|
||||||
|
_error = null;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
// Process each relay health check, catching all errors
|
||||||
|
final futures = <Future<void>>[];
|
||||||
|
for (final relay in _relays) {
|
||||||
|
// Wrap each connection attempt to ensure all exceptions are caught
|
||||||
|
futures.add(
|
||||||
|
Future<void>(() async {
|
||||||
|
try {
|
||||||
|
// Attempt to connect - this may throw synchronously or asynchronously
|
||||||
|
// Wrap in another try-catch to catch synchronous exceptions from WebSocket.connect
|
||||||
|
try {
|
||||||
|
final stream = await nostrService
|
||||||
|
.connectRelay(relay.url)
|
||||||
|
.timeout(
|
||||||
|
const Duration(seconds: 2),
|
||||||
|
onTimeout: () {
|
||||||
|
throw Exception('Connection timeout');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// If we get here, connection succeeded
|
||||||
|
_loadRelays();
|
||||||
|
// Cancel the stream subscription to clean up
|
||||||
|
stream.listen(null).cancel();
|
||||||
|
} catch (e) {
|
||||||
|
// Connection failed - disconnect to mark as unhealthy
|
||||||
|
try {
|
||||||
|
nostrService.disconnectRelay(relay.url);
|
||||||
|
_loadRelays();
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore disconnect errors
|
||||||
|
}
|
||||||
|
// Re-throw to be caught by outer catch
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Catch all exceptions - connection failures are expected in tests
|
||||||
|
// Disconnect relay to mark as unhealthy
|
||||||
|
try {
|
||||||
|
nostrService.disconnectRelay(relay.url);
|
||||||
|
_loadRelays();
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore disconnect errors
|
||||||
|
}
|
||||||
|
// Don't re-throw - this method should never throw
|
||||||
|
}
|
||||||
|
}).catchError((_) {
|
||||||
|
// Final safety net - ensure no exceptions escape
|
||||||
|
try {
|
||||||
|
nostrService.disconnectRelay(relay.url);
|
||||||
|
_loadRelays();
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore all errors
|
||||||
|
}
|
||||||
|
return Future<void>.value();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all health checks to complete (or fail gracefully)
|
||||||
|
// Use eagerError: false so one failure doesn't stop others
|
||||||
|
try {
|
||||||
|
await Future.wait(futures, eagerError: false);
|
||||||
|
} catch (_) {
|
||||||
|
// Swallow any errors - all individual failures already handled above
|
||||||
|
}
|
||||||
|
|
||||||
|
_isCheckingHealth = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggers a manual sync using the sync engine.
|
||||||
|
///
|
||||||
|
/// Returns true if sync was triggered successfully.
|
||||||
|
Future<bool> triggerManualSync() async {
|
||||||
|
if (syncEngine == null) {
|
||||||
|
_error = 'Sync engine not configured';
|
||||||
|
notifyListeners();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isSyncing = true;
|
||||||
|
_error = null;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Trigger sync to all configured relays via Nostr
|
||||||
|
// This is a simplified sync - in a real app, you'd sync specific items
|
||||||
|
await syncEngine!.syncAll();
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
_error = 'Sync failed: $e';
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
_isSyncing = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears the current error message.
|
||||||
|
void clearError() {
|
||||||
|
_error = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
// Don't dispose nostrService or syncEngine - they're managed elsewhere
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,291 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'relay_management_controller.dart';
|
||||||
|
import '../../data/nostr/models/nostr_relay.dart';
|
||||||
|
|
||||||
|
/// Screen for managing Nostr relays.
|
||||||
|
///
|
||||||
|
/// Allows users to view, add, remove, and monitor relay health,
|
||||||
|
/// and trigger manual syncs.
|
||||||
|
class RelayManagementScreen extends StatefulWidget {
|
||||||
|
/// Controller for managing relay state.
|
||||||
|
final RelayManagementController controller;
|
||||||
|
|
||||||
|
/// Creates a [RelayManagementScreen] instance.
|
||||||
|
const RelayManagementScreen({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<RelayManagementScreen> createState() => _RelayManagementScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RelayManagementScreenState extends State<RelayManagementScreen> {
|
||||||
|
final TextEditingController _urlController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_urlController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Nostr Relay Management'),
|
||||||
|
),
|
||||||
|
body: ListenableBuilder(
|
||||||
|
listenable: widget.controller,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
// Error message
|
||||||
|
if (widget.controller.error != null)
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
color: Colors.red.shade100,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error, color: Colors.red.shade700),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
widget.controller.error!,
|
||||||
|
style: TextStyle(color: Colors.red.shade700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: widget.controller.clearError,
|
||||||
|
color: Colors.red.shade700,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Actions section
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// Add relay input
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _urlController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Relay URL',
|
||||||
|
hintText: widget.controller.relays.isNotEmpty
|
||||||
|
? widget.controller.relays.first.url
|
||||||
|
: 'wss://nostrum.satoshinakamoto.win',
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.url,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
final url = _urlController.text.trim();
|
||||||
|
if (url.isNotEmpty) {
|
||||||
|
if (widget.controller.addRelay(url)) {
|
||||||
|
_urlController.clear();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Relay added successfully'),
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Add'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: widget.controller.isCheckingHealth
|
||||||
|
? null
|
||||||
|
: widget.controller.checkRelayHealth,
|
||||||
|
icon: widget.controller.isCheckingHealth
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.health_and_safety),
|
||||||
|
label: const Text('Check Health'),
|
||||||
|
),
|
||||||
|
if (widget.controller.syncEngine != null)
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: widget.controller.isSyncing
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
final success = await widget.controller.triggerManualSync();
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
success
|
||||||
|
? 'Sync triggered successfully'
|
||||||
|
: 'Sync failed: ${widget.controller.error ?? "Unknown error"}',
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: widget.controller.isSyncing
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.sync),
|
||||||
|
label: const Text('Manual Sync'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const Divider(),
|
||||||
|
|
||||||
|
// Relay list
|
||||||
|
Expanded(
|
||||||
|
child: widget.controller.relays.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.cloud_off,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.grey.shade400,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'No relays configured',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Add a relay to get started',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
itemCount: widget.controller.relays.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final relay = widget.controller.relays[index];
|
||||||
|
return _RelayListItem(
|
||||||
|
relay: relay,
|
||||||
|
onConnect: () => widget.controller.connectRelay(relay.url),
|
||||||
|
onDisconnect: () => widget.controller.disconnectRelay(relay.url),
|
||||||
|
onRemove: () {
|
||||||
|
widget.controller.removeRelay(relay.url);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Relay ${relay.url} removed'),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget for displaying a single relay in the list.
|
||||||
|
class _RelayListItem extends StatelessWidget {
|
||||||
|
/// The relay to display.
|
||||||
|
final NostrRelay relay;
|
||||||
|
|
||||||
|
/// Callback when connect is pressed.
|
||||||
|
final VoidCallback onConnect;
|
||||||
|
|
||||||
|
/// Callback when disconnect is pressed.
|
||||||
|
final VoidCallback onDisconnect;
|
||||||
|
|
||||||
|
/// Callback when remove is pressed.
|
||||||
|
final VoidCallback onRemove;
|
||||||
|
|
||||||
|
const _RelayListItem({
|
||||||
|
required this.relay,
|
||||||
|
required this.onConnect,
|
||||||
|
required this.onDisconnect,
|
||||||
|
required this.onRemove,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: relay.isConnected ? Colors.green : Colors.grey,
|
||||||
|
child: Icon(
|
||||||
|
relay.isConnected ? Icons.check : Icons.close,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
relay.url,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
relay.isConnected ? 'Connected' : 'Disconnected',
|
||||||
|
style: TextStyle(
|
||||||
|
color: relay.isConnected ? Colors.green : Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
relay.isConnected ? Icons.link_off : Icons.link,
|
||||||
|
color: relay.isConnected ? Colors.orange : Colors.green,
|
||||||
|
),
|
||||||
|
tooltip: relay.isConnected ? 'Disconnect' : 'Connect',
|
||||||
|
onPressed: relay.isConnected ? onDisconnect : onConnect,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete, color: Colors.red),
|
||||||
|
tooltip: 'Remove',
|
||||||
|
onPressed: onRemove,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,166 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:app_boilerplate/ui/relay_management/relay_management_controller.dart';
|
||||||
|
import 'package:app_boilerplate/data/nostr/nostr_service.dart';
|
||||||
|
import 'package:app_boilerplate/data/nostr/models/nostr_relay.dart';
|
||||||
|
import 'package:app_boilerplate/data/sync/sync_engine.dart';
|
||||||
|
import 'package:app_boilerplate/data/local/local_storage_service.dart';
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// Initialize Flutter bindings and sqflite for testing
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
sqfliteFfiInit();
|
||||||
|
databaseFactory = databaseFactoryFfi;
|
||||||
|
|
||||||
|
late NostrService nostrService;
|
||||||
|
late SyncEngine syncEngine;
|
||||||
|
late LocalStorageService localStorage;
|
||||||
|
late Directory testDir;
|
||||||
|
late String testDbPath;
|
||||||
|
late Directory testCacheDir;
|
||||||
|
late RelayManagementController controller;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
// Create temporary directory for testing
|
||||||
|
testDir = await Directory.systemTemp.createTemp('relay_test_');
|
||||||
|
testDbPath = path.join(testDir.path, 'test_local_storage.db');
|
||||||
|
testCacheDir = Directory(path.join(testDir.path, 'image_cache'));
|
||||||
|
|
||||||
|
// Initialize local storage
|
||||||
|
localStorage = LocalStorageService(
|
||||||
|
testDbPath: testDbPath,
|
||||||
|
testCacheDir: testCacheDir,
|
||||||
|
);
|
||||||
|
await localStorage.initialize();
|
||||||
|
|
||||||
|
// Create services
|
||||||
|
nostrService = NostrService();
|
||||||
|
syncEngine = SyncEngine(
|
||||||
|
localStorage: localStorage,
|
||||||
|
nostrService: nostrService,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create controller
|
||||||
|
controller = RelayManagementController(
|
||||||
|
nostrService: nostrService,
|
||||||
|
syncEngine: syncEngine,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
controller.dispose();
|
||||||
|
syncEngine.dispose();
|
||||||
|
nostrService.dispose();
|
||||||
|
await localStorage.close();
|
||||||
|
try {
|
||||||
|
if (await testDir.exists()) {
|
||||||
|
await testDir.delete(recursive: true);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
group('RelayManagementController', () {
|
||||||
|
test('initial state - empty relay list', () {
|
||||||
|
expect(controller.relays, isEmpty);
|
||||||
|
expect(controller.isSyncing, isFalse);
|
||||||
|
expect(controller.error, isNull);
|
||||||
|
expect(controller.isCheckingHealth, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addRelay - success', () {
|
||||||
|
final url = 'wss://relay.example.com';
|
||||||
|
final result = controller.addRelay(url);
|
||||||
|
|
||||||
|
expect(result, isTrue);
|
||||||
|
expect(controller.relays.length, equals(1));
|
||||||
|
expect(controller.relays[0].url, equals(url));
|
||||||
|
expect(controller.error, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addRelay - invalid URL format', () {
|
||||||
|
final result = controller.addRelay('invalid-url');
|
||||||
|
|
||||||
|
expect(result, isFalse);
|
||||||
|
expect(controller.relays, isEmpty);
|
||||||
|
expect(controller.error, isNotNull);
|
||||||
|
expect(controller.error, contains('Invalid relay URL'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addRelay - duplicate relay', () {
|
||||||
|
final url = 'wss://relay.example.com';
|
||||||
|
controller.addRelay(url);
|
||||||
|
final result = controller.addRelay(url);
|
||||||
|
|
||||||
|
expect(result, isTrue); // Still returns true, but doesn't add duplicate
|
||||||
|
expect(controller.relays.length, equals(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removeRelay - success', () {
|
||||||
|
final url = 'wss://relay.example.com';
|
||||||
|
controller.addRelay(url);
|
||||||
|
expect(controller.relays.length, equals(1));
|
||||||
|
|
||||||
|
controller.removeRelay(url);
|
||||||
|
expect(controller.relays, isEmpty);
|
||||||
|
expect(controller.error, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removeRelay - non-existent relay', () {
|
||||||
|
controller.removeRelay('wss://nonexistent.com');
|
||||||
|
expect(controller.relays, isEmpty);
|
||||||
|
expect(controller.error, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearError - clears error message', () {
|
||||||
|
controller.addRelay('invalid-url');
|
||||||
|
expect(controller.error, isNotNull);
|
||||||
|
|
||||||
|
controller.clearError();
|
||||||
|
expect(controller.error, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checkRelayHealth - attempts to connect to relays', () async {
|
||||||
|
// Add a relay (but don't connect to real relay in tests)
|
||||||
|
controller.addRelay('wss://relay.example.com');
|
||||||
|
expect(controller.relays.length, equals(1));
|
||||||
|
expect(controller.relays[0].isConnected, isFalse);
|
||||||
|
|
||||||
|
// Health check will attempt to connect (will fail in test environment, but that's OK)
|
||||||
|
// The method should complete without throwing - it handles connection failures gracefully
|
||||||
|
// Use runZoned to catch any unhandled exceptions that might escape
|
||||||
|
await runZonedGuarded(
|
||||||
|
() async {
|
||||||
|
await controller.checkRelayHealth();
|
||||||
|
},
|
||||||
|
(error, stack) {
|
||||||
|
// Swallow any unhandled errors - connection failures are expected
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the health check completed
|
||||||
|
expect(controller.isCheckingHealth, isFalse);
|
||||||
|
// Relay should still be in the list (even if disconnected)
|
||||||
|
expect(controller.relays.length, equals(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('triggerManualSync - without sync engine', () async {
|
||||||
|
final controllerWithoutSync = RelayManagementController(
|
||||||
|
nostrService: nostrService,
|
||||||
|
syncEngine: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await controllerWithoutSync.triggerManualSync();
|
||||||
|
expect(result, isFalse);
|
||||||
|
expect(controllerWithoutSync.error, isNotNull);
|
||||||
|
expect(controllerWithoutSync.error, contains('Sync engine not configured'));
|
||||||
|
|
||||||
|
controllerWithoutSync.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,237 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:app_boilerplate/ui/relay_management/relay_management_screen.dart';
|
||||||
|
import 'package:app_boilerplate/ui/relay_management/relay_management_controller.dart';
|
||||||
|
import 'package:app_boilerplate/data/nostr/nostr_service.dart';
|
||||||
|
import 'package:app_boilerplate/data/nostr/models/nostr_relay.dart';
|
||||||
|
import 'package:app_boilerplate/data/sync/sync_engine.dart';
|
||||||
|
import 'package:app_boilerplate/data/local/local_storage_service.dart';
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// Initialize Flutter bindings and sqflite for testing
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
sqfliteFfiInit();
|
||||||
|
databaseFactory = databaseFactoryFfi;
|
||||||
|
|
||||||
|
late NostrService nostrService;
|
||||||
|
late SyncEngine syncEngine;
|
||||||
|
late LocalStorageService localStorage;
|
||||||
|
late Directory testDir;
|
||||||
|
late String testDbPath;
|
||||||
|
late Directory testCacheDir;
|
||||||
|
late RelayManagementController controller;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
// Create temporary directory for testing
|
||||||
|
testDir = await Directory.systemTemp.createTemp('relay_ui_test_');
|
||||||
|
testDbPath = path.join(testDir.path, 'test_local_storage.db');
|
||||||
|
testCacheDir = Directory(path.join(testDir.path, 'image_cache'));
|
||||||
|
|
||||||
|
// Initialize local storage
|
||||||
|
localStorage = LocalStorageService(
|
||||||
|
testDbPath: testDbPath,
|
||||||
|
testCacheDir: testCacheDir,
|
||||||
|
);
|
||||||
|
await localStorage.initialize();
|
||||||
|
|
||||||
|
// Create services
|
||||||
|
nostrService = NostrService();
|
||||||
|
syncEngine = SyncEngine(
|
||||||
|
localStorage: localStorage,
|
||||||
|
nostrService: nostrService,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create controller
|
||||||
|
controller = RelayManagementController(
|
||||||
|
nostrService: nostrService,
|
||||||
|
syncEngine: syncEngine,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
controller.dispose();
|
||||||
|
syncEngine.dispose();
|
||||||
|
nostrService.dispose();
|
||||||
|
await localStorage.close();
|
||||||
|
try {
|
||||||
|
if (await testDir.exists()) {
|
||||||
|
await testDir.delete(recursive: true);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Widget createTestWidget() {
|
||||||
|
return MaterialApp(
|
||||||
|
home: RelayManagementScreen(controller: controller),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
group('RelayManagementScreen', () {
|
||||||
|
testWidgets('displays empty state when no relays', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(createTestWidget());
|
||||||
|
|
||||||
|
expect(find.text('No relays configured'), findsOneWidget);
|
||||||
|
expect(find.text('Add a relay to get started'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.cloud_off), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('displays relay list correctly', (WidgetTester tester) async {
|
||||||
|
controller.addRelay('wss://relay1.example.com');
|
||||||
|
controller.addRelay('wss://relay2.example.com');
|
||||||
|
|
||||||
|
await tester.pumpWidget(createTestWidget());
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Relay URLs may appear in both placeholder and list, so use textContaining
|
||||||
|
expect(find.textContaining('wss://relay1.example.com'), findsWidgets);
|
||||||
|
expect(find.textContaining('wss://relay2.example.com'), findsWidgets);
|
||||||
|
// Verify we have relay list items (Cards)
|
||||||
|
expect(find.byType(Card), findsNWidgets(2));
|
||||||
|
expect(find.text('Disconnected'), findsNWidgets(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('adds relay when Add button is pressed', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(createTestWidget());
|
||||||
|
|
||||||
|
// Find and enter relay URL
|
||||||
|
final urlField = find.byType(TextField);
|
||||||
|
expect(urlField, findsOneWidget);
|
||||||
|
await tester.enterText(urlField, 'wss://new-relay.example.com');
|
||||||
|
|
||||||
|
// Find and tap Add button (by text inside)
|
||||||
|
final addButton = find.text('Add');
|
||||||
|
expect(addButton, findsOneWidget);
|
||||||
|
await tester.tap(addButton);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Verify relay was added
|
||||||
|
expect(find.textContaining('wss://new-relay.example.com'), findsWidgets);
|
||||||
|
expect(find.text('Relay added successfully'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows error for invalid URL', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(createTestWidget());
|
||||||
|
|
||||||
|
// Enter invalid URL
|
||||||
|
final urlField = find.byType(TextField);
|
||||||
|
await tester.enterText(urlField, 'invalid-url');
|
||||||
|
|
||||||
|
// Tap Add button
|
||||||
|
final addButton = find.text('Add');
|
||||||
|
await tester.tap(addButton);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Verify error message is shown
|
||||||
|
expect(find.textContaining('Invalid relay URL'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.error), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('removes relay when delete button is pressed', (WidgetTester tester) async {
|
||||||
|
controller.addRelay('wss://relay.example.com');
|
||||||
|
await tester.pumpWidget(createTestWidget());
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Verify relay is in list
|
||||||
|
expect(find.text('wss://relay.example.com'), findsWidgets);
|
||||||
|
expect(controller.relays.length, equals(1));
|
||||||
|
|
||||||
|
// Find and tap delete button
|
||||||
|
final deleteButton = find.byIcon(Icons.delete);
|
||||||
|
expect(deleteButton, findsOneWidget);
|
||||||
|
await tester.tap(deleteButton);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify relay was removed (check controller state)
|
||||||
|
expect(controller.relays, isEmpty);
|
||||||
|
// Verify empty state is shown
|
||||||
|
expect(find.text('No relays configured'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('displays check health button', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(createTestWidget());
|
||||||
|
|
||||||
|
expect(find.text('Check Health'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.health_and_safety), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('displays manual sync button when sync engine is configured', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(createTestWidget());
|
||||||
|
|
||||||
|
expect(find.text('Manual Sync'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.sync), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows loading state during health check', (WidgetTester tester) async {
|
||||||
|
controller.addRelay('wss://relay.example.com');
|
||||||
|
await tester.pumpWidget(createTestWidget());
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Tap check health button
|
||||||
|
final healthButton = find.text('Check Health');
|
||||||
|
await tester.tap(healthButton);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Check for loading indicator (may be brief)
|
||||||
|
expect(find.byType(CircularProgressIndicator), findsWidgets);
|
||||||
|
|
||||||
|
// Wait for health check to complete
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows error message when present', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(createTestWidget());
|
||||||
|
|
||||||
|
// Trigger an error by adding invalid URL
|
||||||
|
final urlField = find.byType(TextField);
|
||||||
|
await tester.enterText(urlField, 'invalid-url');
|
||||||
|
final addButton = find.text('Add');
|
||||||
|
await tester.tap(addButton);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Verify error container is displayed
|
||||||
|
expect(find.byIcon(Icons.error), findsOneWidget);
|
||||||
|
expect(find.textContaining('Invalid relay URL'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('dismisses error when close button is pressed', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(createTestWidget());
|
||||||
|
|
||||||
|
// Trigger an error
|
||||||
|
final urlField = find.byType(TextField);
|
||||||
|
await tester.enterText(urlField, 'invalid-url');
|
||||||
|
final addButton = find.text('Add');
|
||||||
|
await tester.tap(addButton);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.textContaining('Invalid relay URL'), findsOneWidget);
|
||||||
|
|
||||||
|
// Tap close button
|
||||||
|
final closeButtons = find.byIcon(Icons.close);
|
||||||
|
expect(closeButtons, findsOneWidget);
|
||||||
|
await tester.tap(closeButtons);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Error should be cleared
|
||||||
|
expect(find.textContaining('Invalid relay URL'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('displays relay URL in list item', (WidgetTester tester) async {
|
||||||
|
controller.addRelay('wss://relay.example.com');
|
||||||
|
await tester.pumpWidget(createTestWidget());
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Verify relay URL is displayed (may appear in ListTile title)
|
||||||
|
expect(find.textContaining('wss://relay.example.com'), findsWidgets);
|
||||||
|
// Verify status indicator is present
|
||||||
|
expect(find.byType(CircleAvatar), findsWidgets);
|
||||||
|
// Verify we have a relay card
|
||||||
|
expect(find.byType(Card), findsWidgets);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in new issue