parent
f462a3d966
commit
1af9cdbc6d
@ -0,0 +1,130 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
|
||||||
|
/// Represents a Nostr event.
|
||||||
|
class NostrEvent {
|
||||||
|
/// Event ID (32-byte hex string).
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
/// Public key of the event creator (pubkey).
|
||||||
|
final String pubkey;
|
||||||
|
|
||||||
|
/// Unix timestamp in seconds.
|
||||||
|
final int createdAt;
|
||||||
|
|
||||||
|
/// Event kind (integer).
|
||||||
|
final int kind;
|
||||||
|
|
||||||
|
/// Event tags (array of arrays).
|
||||||
|
final List<List<String>> tags;
|
||||||
|
|
||||||
|
/// Event content (string).
|
||||||
|
final String content;
|
||||||
|
|
||||||
|
/// Event signature (64-byte hex string).
|
||||||
|
final String sig;
|
||||||
|
|
||||||
|
/// Creates a [NostrEvent] with the provided values.
|
||||||
|
NostrEvent({
|
||||||
|
required this.id,
|
||||||
|
required this.pubkey,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.kind,
|
||||||
|
required this.tags,
|
||||||
|
required this.content,
|
||||||
|
required this.sig,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Creates a [NostrEvent] from a JSON array (Nostr event format).
|
||||||
|
factory NostrEvent.fromJson(List<dynamic> json) {
|
||||||
|
return NostrEvent(
|
||||||
|
id: json[0] as String,
|
||||||
|
pubkey: json[1] as String,
|
||||||
|
createdAt: json[2] as int,
|
||||||
|
kind: json[3] as int,
|
||||||
|
tags: (json[4] as List<dynamic>)
|
||||||
|
.map((tag) => (tag as List<dynamic>).map((e) => e.toString()).toList())
|
||||||
|
.toList(),
|
||||||
|
content: json[5] as String,
|
||||||
|
sig: json[6] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts the [NostrEvent] to a JSON array (Nostr event format).
|
||||||
|
List<dynamic> toJson() {
|
||||||
|
return [
|
||||||
|
id,
|
||||||
|
pubkey,
|
||||||
|
createdAt,
|
||||||
|
kind,
|
||||||
|
tags,
|
||||||
|
content,
|
||||||
|
sig,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates an event from content and signs it with a private key.
|
||||||
|
///
|
||||||
|
/// [content] - Event content.
|
||||||
|
/// [kind] - Event kind (default: 1 for text note).
|
||||||
|
/// [privateKey] - Private key in hex format for signing.
|
||||||
|
/// [tags] - Optional tags for the event.
|
||||||
|
factory NostrEvent.create({
|
||||||
|
required String content,
|
||||||
|
int kind = 1,
|
||||||
|
required String privateKey,
|
||||||
|
List<List<String>>? tags,
|
||||||
|
}) {
|
||||||
|
// Derive public key from private key (simplified)
|
||||||
|
final privateKeyBytes = _hexToBytes(privateKey);
|
||||||
|
final publicKeyBytes = sha256.convert(privateKeyBytes).bytes.sublist(0, 32);
|
||||||
|
final pubkey = publicKeyBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
|
||||||
|
final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||||
|
final eventTags = tags ?? [];
|
||||||
|
|
||||||
|
// Create event data for signing
|
||||||
|
final eventData = [
|
||||||
|
0,
|
||||||
|
pubkey,
|
||||||
|
createdAt,
|
||||||
|
kind,
|
||||||
|
eventTags,
|
||||||
|
content,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Generate event ID (hash of event data)
|
||||||
|
final eventJson = jsonEncode(eventData);
|
||||||
|
final idBytes = sha256.convert(utf8.encode(eventJson)).bytes.sublist(0, 32);
|
||||||
|
final id = idBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
|
||||||
|
// Generate signature (simplified - in real Nostr, use secp256k1)
|
||||||
|
final sigBytes = sha256.convert(utf8.encode(id + privateKey)).bytes.sublist(0, 32);
|
||||||
|
final sig = sigBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
|
||||||
|
return NostrEvent(
|
||||||
|
id: id,
|
||||||
|
pubkey: pubkey,
|
||||||
|
createdAt: createdAt,
|
||||||
|
kind: kind,
|
||||||
|
tags: eventTags,
|
||||||
|
content: content,
|
||||||
|
sig: sig,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts hex string to bytes.
|
||||||
|
static List<int> _hexToBytes(String hex) {
|
||||||
|
final result = <int>[];
|
||||||
|
for (int i = 0; i < hex.length; i += 2) {
|
||||||
|
result.add(int.parse(hex.substring(i, i + 2), radix: 16));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'NostrEvent(id: ${id.substring(0, 8)}..., kind: $kind, content: ${content.substring(0, content.length > 20 ? 20 : content.length)}...)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
|
||||||
|
/// Represents a Nostr keypair (private and public keys).
|
||||||
|
class NostrKeyPair {
|
||||||
|
/// Private key in hex format (32 bytes, 64 hex characters).
|
||||||
|
final String privateKey;
|
||||||
|
|
||||||
|
/// Public key in hex format (32 bytes, 64 hex characters).
|
||||||
|
final String publicKey;
|
||||||
|
|
||||||
|
/// Creates a [NostrKeyPair] with the provided keys.
|
||||||
|
///
|
||||||
|
/// [privateKey] - Private key in hex format.
|
||||||
|
/// [publicKey] - Public key in hex format.
|
||||||
|
NostrKeyPair({
|
||||||
|
required this.privateKey,
|
||||||
|
required this.publicKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Generates a new Nostr keypair.
|
||||||
|
///
|
||||||
|
/// Returns a new [NostrKeyPair] with random private and public keys.
|
||||||
|
factory NostrKeyPair.generate() {
|
||||||
|
// Generate random 32-byte private key
|
||||||
|
final random = List<int>.generate(32, (i) => DateTime.now().microsecondsSinceEpoch % 256);
|
||||||
|
final privateKeyBytes = sha256.convert(utf8.encode(DateTime.now().toString() + random.toString())).bytes.sublist(0, 32);
|
||||||
|
final privateKey = privateKeyBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
|
||||||
|
// Derive public key from private key (simplified - in real Nostr, use secp256k1)
|
||||||
|
final publicKeyBytes = sha256.convert(privateKeyBytes).bytes.sublist(0, 32);
|
||||||
|
final publicKey = publicKeyBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
|
||||||
|
return NostrKeyPair(
|
||||||
|
privateKey: privateKey,
|
||||||
|
publicKey: publicKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a [NostrKeyPair] from a JSON map.
|
||||||
|
factory NostrKeyPair.fromJson(Map<String, dynamic> json) {
|
||||||
|
return NostrKeyPair(
|
||||||
|
privateKey: json['privateKey'] as String,
|
||||||
|
publicKey: json['publicKey'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts the [NostrKeyPair] to a JSON map.
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'privateKey': privateKey,
|
||||||
|
'publicKey': publicKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'NostrKeyPair(publicKey: ${publicKey.substring(0, 8)}...)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
/// Represents a Nostr relay connection.
|
||||||
|
class NostrRelay {
|
||||||
|
/// Relay URL (e.g., 'wss://relay.example.com').
|
||||||
|
final String url;
|
||||||
|
|
||||||
|
/// Whether the relay is currently connected.
|
||||||
|
bool isConnected;
|
||||||
|
|
||||||
|
/// Creates a [NostrRelay] instance.
|
||||||
|
NostrRelay({
|
||||||
|
required this.url,
|
||||||
|
this.isConnected = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Creates a [NostrRelay] from a URL string.
|
||||||
|
factory NostrRelay.fromUrl(String url) {
|
||||||
|
return NostrRelay(url: url);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'NostrRelay(url: $url, connected: $isConnected)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
return other is NostrRelay && other.url == url;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => url.hashCode;
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,233 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
|
import 'models/nostr_keypair.dart';
|
||||||
|
import 'models/nostr_event.dart';
|
||||||
|
import 'models/nostr_relay.dart';
|
||||||
|
|
||||||
|
/// Exception thrown when Nostr operations fail.
|
||||||
|
class NostrException implements Exception {
|
||||||
|
/// Error message.
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
/// Creates a [NostrException] with the provided message.
|
||||||
|
NostrException(this.message);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'NostrException: $message';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service for interacting with Nostr protocol.
|
||||||
|
///
|
||||||
|
/// This service provides:
|
||||||
|
/// - Keypair generation
|
||||||
|
/// - Event publishing to relays
|
||||||
|
/// - Metadata synchronization with multiple relays
|
||||||
|
///
|
||||||
|
/// The service is modular and UI-independent, designed for testing without real relays.
|
||||||
|
class NostrService {
|
||||||
|
/// List of configured relays.
|
||||||
|
final List<NostrRelay> _relays = [];
|
||||||
|
|
||||||
|
/// Active WebSocket connections to relays.
|
||||||
|
final Map<String, WebSocketChannel?> _connections = {};
|
||||||
|
|
||||||
|
/// Stream controllers for relay messages.
|
||||||
|
final Map<String, StreamController<Map<String, dynamic>>> _messageControllers = {};
|
||||||
|
|
||||||
|
/// Creates a [NostrService] instance.
|
||||||
|
NostrService();
|
||||||
|
|
||||||
|
/// Generates a new Nostr keypair.
|
||||||
|
///
|
||||||
|
/// Returns a [NostrKeyPair] with random private and public keys.
|
||||||
|
NostrKeyPair generateKeyPair() {
|
||||||
|
return NostrKeyPair.generate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a relay to the service.
|
||||||
|
///
|
||||||
|
/// [relayUrl] - The WebSocket URL of the relay (e.g., 'wss://relay.example.com').
|
||||||
|
void addRelay(String relayUrl) {
|
||||||
|
final relay = NostrRelay.fromUrl(relayUrl);
|
||||||
|
if (!_relays.contains(relay)) {
|
||||||
|
_relays.add(relay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a relay from the service.
|
||||||
|
///
|
||||||
|
/// [relayUrl] - The URL of the relay to remove.
|
||||||
|
void removeRelay(String relayUrl) {
|
||||||
|
_relays.removeWhere((r) => r.url == relayUrl);
|
||||||
|
disconnectRelay(relayUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the list of configured relays.
|
||||||
|
List<NostrRelay> getRelays() {
|
||||||
|
return List.unmodifiable(_relays);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connects to a relay.
|
||||||
|
///
|
||||||
|
/// [relayUrl] - The URL of the relay to connect to.
|
||||||
|
///
|
||||||
|
/// Returns a [Stream] of messages from the relay.
|
||||||
|
///
|
||||||
|
/// Throws [NostrException] if connection fails.
|
||||||
|
Future<Stream<Map<String, dynamic>>> connectRelay(String relayUrl) async {
|
||||||
|
try {
|
||||||
|
if (_connections.containsKey(relayUrl) && _connections[relayUrl] != null) {
|
||||||
|
// Already connected
|
||||||
|
return _messageControllers[relayUrl]!.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
final channel = WebSocketChannel.connect(Uri.parse(relayUrl));
|
||||||
|
_connections[relayUrl] = channel;
|
||||||
|
|
||||||
|
final controller = StreamController<Map<String, dynamic>>();
|
||||||
|
_messageControllers[relayUrl] = controller;
|
||||||
|
|
||||||
|
// Update relay status
|
||||||
|
final relay = _relays.firstWhere((r) => r.url == relayUrl, orElse: () => NostrRelay.fromUrl(relayUrl));
|
||||||
|
relay.isConnected = true;
|
||||||
|
|
||||||
|
// Listen for messages
|
||||||
|
channel.stream.listen(
|
||||||
|
(message) {
|
||||||
|
try {
|
||||||
|
final data = jsonDecode(message as String);
|
||||||
|
if (data is List) {
|
||||||
|
controller.add({
|
||||||
|
'type': data[0] as String,
|
||||||
|
'data': data.length > 1 ? data[1] : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore invalid messages
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) {
|
||||||
|
relay.isConnected = false;
|
||||||
|
controller.addError(NostrException('Relay error: $error'));
|
||||||
|
},
|
||||||
|
onDone: () {
|
||||||
|
relay.isConnected = false;
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return controller.stream;
|
||||||
|
} catch (e) {
|
||||||
|
throw NostrException('Failed to connect to relay: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disconnects from a relay.
|
||||||
|
///
|
||||||
|
/// [relayUrl] - The URL of the relay to disconnect from.
|
||||||
|
void disconnectRelay(String relayUrl) {
|
||||||
|
final channel = _connections[relayUrl];
|
||||||
|
if (channel != null) {
|
||||||
|
channel.sink.close();
|
||||||
|
_connections.remove(relayUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
final controller = _messageControllers[relayUrl];
|
||||||
|
if (controller != null) {
|
||||||
|
controller.close();
|
||||||
|
_messageControllers.remove(relayUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
final relay = _relays.firstWhere((r) => r.url == relayUrl, orElse: () => NostrRelay.fromUrl(relayUrl));
|
||||||
|
relay.isConnected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publishes an event to a relay.
|
||||||
|
///
|
||||||
|
/// [event] - The Nostr event to publish.
|
||||||
|
/// [relayUrl] - The URL of the relay to publish to.
|
||||||
|
///
|
||||||
|
/// Returns a [Future] that completes when the event is published.
|
||||||
|
///
|
||||||
|
/// Throws [NostrException] if publishing fails.
|
||||||
|
Future<void> publishEvent(NostrEvent event, String relayUrl) async {
|
||||||
|
try {
|
||||||
|
final channel = _connections[relayUrl];
|
||||||
|
if (channel == null) {
|
||||||
|
throw NostrException('Not connected to relay: $relayUrl');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send event in Nostr format: ["EVENT", <event_json>]
|
||||||
|
final message = jsonEncode(['EVENT', event.toJson()]);
|
||||||
|
channel.sink.add(message);
|
||||||
|
} catch (e) {
|
||||||
|
throw NostrException('Failed to publish event: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publishes an event to all connected relays.
|
||||||
|
///
|
||||||
|
/// [event] - The Nostr event to publish.
|
||||||
|
///
|
||||||
|
/// Returns a map of relay URLs to success/failure status.
|
||||||
|
Future<Map<String, bool>> publishEventToAllRelays(NostrEvent event) async {
|
||||||
|
final results = <String, bool>{};
|
||||||
|
|
||||||
|
for (final relay in _relays) {
|
||||||
|
if (relay.isConnected) {
|
||||||
|
try {
|
||||||
|
await publishEvent(event, relay.url);
|
||||||
|
results[relay.url] = true;
|
||||||
|
} catch (e) {
|
||||||
|
results[relay.url] = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
results[relay.url] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Syncs metadata by publishing an event with metadata content.
|
||||||
|
///
|
||||||
|
/// [metadata] - The metadata to sync (as a Map).
|
||||||
|
/// [privateKey] - Private key for signing the event.
|
||||||
|
/// [kind] - Event kind (default: 0 for metadata).
|
||||||
|
///
|
||||||
|
/// Returns the created and published event.
|
||||||
|
///
|
||||||
|
/// Throws [NostrException] if sync fails.
|
||||||
|
Future<NostrEvent> syncMetadata({
|
||||||
|
required Map<String, dynamic> metadata,
|
||||||
|
required String privateKey,
|
||||||
|
int kind = 0,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
// Create event with metadata as content
|
||||||
|
final content = jsonEncode(metadata);
|
||||||
|
final event = NostrEvent.create(
|
||||||
|
content: content,
|
||||||
|
kind: kind,
|
||||||
|
privateKey: privateKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Publish to all connected relays
|
||||||
|
await publishEventToAllRelays(event);
|
||||||
|
|
||||||
|
return event;
|
||||||
|
} catch (e) {
|
||||||
|
throw NostrException('Failed to sync metadata: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes all connections and cleans up resources.
|
||||||
|
void dispose() {
|
||||||
|
for (final relayUrl in _connections.keys.toList()) {
|
||||||
|
disconnectRelay(relayUrl);
|
||||||
|
}
|
||||||
|
_relays.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,306 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:app_boilerplate/data/nostr/nostr_service.dart';
|
||||||
|
import 'package:app_boilerplate/data/nostr/models/nostr_keypair.dart';
|
||||||
|
import 'package:app_boilerplate/data/nostr/models/nostr_event.dart';
|
||||||
|
import 'package:app_boilerplate/data/nostr/models/nostr_relay.dart';
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('NostrService - Keypair Generation', () {
|
||||||
|
/// Tests that keypair generation creates valid keypairs.
|
||||||
|
test('generateKeyPair - success', () {
|
||||||
|
// Arrange
|
||||||
|
final service = NostrService();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final keypair = service.generateKeyPair();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(keypair.privateKey, isNotEmpty);
|
||||||
|
expect(keypair.publicKey, isNotEmpty);
|
||||||
|
expect(keypair.privateKey.length, equals(64)); // 32 bytes = 64 hex chars
|
||||||
|
expect(keypair.publicKey.length, equals(64)); // 32 bytes = 64 hex chars
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Tests that each generated keypair is unique.
|
||||||
|
test('generateKeyPair - generates unique keypairs', () {
|
||||||
|
// Arrange
|
||||||
|
final service = NostrService();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final keypair1 = service.generateKeyPair();
|
||||||
|
final keypair2 = service.generateKeyPair();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(keypair1.privateKey, isNot(equals(keypair2.privateKey)));
|
||||||
|
expect(keypair1.publicKey, isNot(equals(keypair2.publicKey)));
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Tests keypair JSON serialization.
|
||||||
|
test('NostrKeyPair - JSON serialization', () {
|
||||||
|
// Arrange
|
||||||
|
final keypair = NostrKeyPair.generate();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final json = keypair.toJson();
|
||||||
|
final restored = NostrKeyPair.fromJson(json);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(restored.privateKey, equals(keypair.privateKey));
|
||||||
|
expect(restored.publicKey, equals(keypair.publicKey));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('NostrService - Relay Management', () {
|
||||||
|
/// Tests adding a relay.
|
||||||
|
test('addRelay - success', () {
|
||||||
|
// Arrange
|
||||||
|
final service = NostrService();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
service.addRelay('wss://relay.example.com');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
final relays = service.getRelays();
|
||||||
|
expect(relays.length, equals(1));
|
||||||
|
expect(relays[0].url, equals('wss://relay.example.com'));
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Tests adding multiple relays.
|
||||||
|
test('addRelay - multiple relays', () {
|
||||||
|
// Arrange
|
||||||
|
final service = NostrService();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
service.addRelay('wss://relay1.example.com');
|
||||||
|
service.addRelay('wss://relay2.example.com');
|
||||||
|
service.addRelay('wss://relay3.example.com');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
final relays = service.getRelays();
|
||||||
|
expect(relays.length, equals(3));
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Tests that duplicate relays are not added.
|
||||||
|
test('addRelay - prevents duplicates', () {
|
||||||
|
// Arrange
|
||||||
|
final service = NostrService();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
service.addRelay('wss://relay.example.com');
|
||||||
|
service.addRelay('wss://relay.example.com');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
final relays = service.getRelays();
|
||||||
|
expect(relays.length, equals(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Tests removing a relay.
|
||||||
|
test('removeRelay - success', () {
|
||||||
|
// Arrange
|
||||||
|
final service = NostrService();
|
||||||
|
service.addRelay('wss://relay.example.com');
|
||||||
|
|
||||||
|
// Act
|
||||||
|
service.removeRelay('wss://relay.example.com');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
final relays = service.getRelays();
|
||||||
|
expect(relays.length, equals(0));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('NostrService - Event Creation', () {
|
||||||
|
/// Tests creating a Nostr event.
|
||||||
|
test('NostrEvent.create - success', () {
|
||||||
|
// Arrange
|
||||||
|
final keypair = NostrKeyPair.generate();
|
||||||
|
final content = 'Test event content';
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final event = NostrEvent.create(
|
||||||
|
content: content,
|
||||||
|
privateKey: keypair.privateKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(event.content, equals(content));
|
||||||
|
expect(event.pubkey, isNotEmpty);
|
||||||
|
expect(event.id, isNotEmpty);
|
||||||
|
expect(event.sig, isNotEmpty);
|
||||||
|
expect(event.kind, equals(1)); // Default kind
|
||||||
|
expect(event.createdAt, greaterThan(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Tests creating event with custom kind.
|
||||||
|
test('NostrEvent.create - custom kind', () {
|
||||||
|
// Arrange
|
||||||
|
final keypair = NostrKeyPair.generate();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final event = NostrEvent.create(
|
||||||
|
content: 'Metadata',
|
||||||
|
kind: 0,
|
||||||
|
privateKey: keypair.privateKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(event.kind, equals(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Tests creating event with tags.
|
||||||
|
test('NostrEvent.create - with tags', () {
|
||||||
|
// Arrange
|
||||||
|
final keypair = NostrKeyPair.generate();
|
||||||
|
final tags = [
|
||||||
|
['t', 'tag1'],
|
||||||
|
['p', 'pubkey123'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final event = NostrEvent.create(
|
||||||
|
content: 'Tagged content',
|
||||||
|
privateKey: keypair.privateKey,
|
||||||
|
tags: tags,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(event.tags.length, equals(2));
|
||||||
|
expect(event.tags[0], equals(['t', 'tag1']));
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Tests event JSON serialization.
|
||||||
|
test('NostrEvent - JSON serialization', () {
|
||||||
|
// Arrange
|
||||||
|
final keypair = NostrKeyPair.generate();
|
||||||
|
final event = NostrEvent.create(
|
||||||
|
content: 'Test',
|
||||||
|
privateKey: keypair.privateKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final json = event.toJson();
|
||||||
|
final restored = NostrEvent.fromJson(json);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(restored.id, equals(event.id));
|
||||||
|
expect(restored.content, equals(event.content));
|
||||||
|
expect(restored.kind, equals(event.kind));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('NostrService - Metadata Sync', () {
|
||||||
|
/// Tests syncing metadata to relays.
|
||||||
|
test('syncMetadata - success', () async {
|
||||||
|
// Arrange
|
||||||
|
final service = NostrService();
|
||||||
|
final keypair = NostrKeyPair.generate();
|
||||||
|
final metadata = {
|
||||||
|
'name': 'Test User',
|
||||||
|
'about': 'Test description',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock relay connection (without actual WebSocket)
|
||||||
|
// Note: In a real test, you'd mock the WebSocket channel
|
||||||
|
// For now, we test that the event is created correctly
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
final event = await service.syncMetadata(
|
||||||
|
metadata: metadata,
|
||||||
|
privateKey: keypair.privateKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert - event should be created
|
||||||
|
expect(event, isNotNull);
|
||||||
|
expect(event.content, contains('Test User'));
|
||||||
|
expect(event.kind, equals(0)); // Metadata kind
|
||||||
|
} catch (e) {
|
||||||
|
// Expected to fail without real relay connection
|
||||||
|
expect(e, isA<NostrException>());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Tests syncing metadata with custom kind.
|
||||||
|
test('syncMetadata - custom kind', () async {
|
||||||
|
// Arrange
|
||||||
|
final service = NostrService();
|
||||||
|
final keypair = NostrKeyPair.generate();
|
||||||
|
final metadata = {'key': 'value'};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
final event = await service.syncMetadata(
|
||||||
|
metadata: metadata,
|
||||||
|
privateKey: keypair.privateKey,
|
||||||
|
kind: 30000, // Custom kind
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(event.kind, equals(30000));
|
||||||
|
} catch (e) {
|
||||||
|
// Expected to fail without real relay connection
|
||||||
|
expect(e, isA<NostrException>());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('NostrService - Error Handling', () {
|
||||||
|
/// Tests error handling when publishing without relay connection.
|
||||||
|
test('publishEvent - fails when not connected', () async {
|
||||||
|
// Arrange
|
||||||
|
final service = NostrService();
|
||||||
|
final keypair = NostrKeyPair.generate();
|
||||||
|
final event = NostrEvent.create(
|
||||||
|
content: 'Test',
|
||||||
|
privateKey: keypair.privateKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
expect(
|
||||||
|
() => service.publishEvent(event, 'wss://relay.example.com'),
|
||||||
|
throwsA(isA<NostrException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Tests error handling when publishing to all relays without connections.
|
||||||
|
test('publishEventToAllRelays - handles disconnected relays', () async {
|
||||||
|
// Arrange
|
||||||
|
final service = NostrService();
|
||||||
|
service.addRelay('wss://relay1.example.com');
|
||||||
|
service.addRelay('wss://relay2.example.com');
|
||||||
|
final keypair = NostrKeyPair.generate();
|
||||||
|
final event = NostrEvent.create(
|
||||||
|
content: 'Test',
|
||||||
|
privateKey: keypair.privateKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final results = await service.publishEventToAllRelays(event);
|
||||||
|
|
||||||
|
// Assert - all should fail since not connected
|
||||||
|
expect(results.length, equals(2));
|
||||||
|
expect(results['wss://relay1.example.com'], isFalse);
|
||||||
|
expect(results['wss://relay2.example.com'], isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('NostrService - Cleanup', () {
|
||||||
|
/// Tests that dispose cleans up resources.
|
||||||
|
test('dispose - cleans up resources', () {
|
||||||
|
// Arrange
|
||||||
|
final service = NostrService();
|
||||||
|
service.addRelay('wss://relay1.example.com');
|
||||||
|
service.addRelay('wss://relay2.example.com');
|
||||||
|
|
||||||
|
// Act
|
||||||
|
service.dispose();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
final relays = service.getRelays();
|
||||||
|
expect(relays.length, equals(0));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in new issue