import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart' as http_testing; import 'package:nostr/nostr.dart' as nostr; import 'package:pointycastle/digests/sha256.dart'; import 'package:buzz/shared/relay/relay.dart'; void main() { test('queryRelay sends NIP-98 auth over POST /query', () async { final keychain = nostr.Keys.generate(); final nsec = keychain.nsec; http.Request? capturedRequest; final client = http_testing.MockClient((request) async { capturedRequest = request; return http.Response('[]', 200); }); final session = RelaySessionNotifier(httpClient: client); final container = ProviderContainer( overrides: [ relaySessionProvider.overrideWith(() => session), relayConfigProvider.overrideWith( () => _FakeRelayConfigNotifier( baseUrl: 'https://relay.example/base', nsec: nsec, ), ), ], ); addTearDown(container.dispose); const filter = NostrFilter( kinds: EventKind.channelTimelineContentKinds, tags: { '#h': [_channelId], }, limit: 50, extensions: { 'top_level': true, 'include_summaries': true, 'include_aux': true, }, ); await container.read(relaySessionProvider.notifier).queryRelay([filter]); expect(capturedRequest, isNotNull); expect(capturedRequest!.method, 'POST'); expect(capturedRequest!.url.toString(), 'https://relay.example/query'); expect(capturedRequest!.headers['Content-Type'], 'application/json'); expect(jsonDecode(capturedRequest!.body), [filter.toJson()]); final authHeader = capturedRequest!.headers['Authorization']; expect(authHeader, isNotNull); expect(authHeader, startsWith('Nostr ')); final encoded = authHeader!.substring('Nostr '.length); final decoded = utf8.decode(base64Url.decode(base64Url.normalize(encoded))); final authEvent = jsonDecode(decoded) as Map; final tags = (authEvent['tags'] as List) .map((tag) => (tag as List).cast()) .toList(); final payloadHash = SHA256Digest() .process(utf8.encode(capturedRequest!.body)) .map((byte) => byte.toRadixString(16).padLeft(2, '0')) .join(); expect(authEvent['kind'], 27235); expect(authEvent['pubkey'], keychain.public); expect( tags, anyElement(equals(['u', 'https://relay.example/query'])), ); expect(tags, anyElement(equals(['method', 'POST']))); expect(tags, anyElement(equals(['payload', payloadHash]))); expect(tags.any((tag) => tag.length == 2 && tag[0] == 'nonce'), isTrue); }); test('queryRelay rejects malformed event arrays', () async { final keychain = nostr.Keys.generate(); final session = RelaySessionNotifier( httpClient: http_testing.MockClient( (_) async => http.Response('[{}]', 200), ), ); final container = ProviderContainer( overrides: [ relaySessionProvider.overrideWith(() => session), relayConfigProvider.overrideWith( () => _FakeRelayConfigNotifier( baseUrl: 'https://relay.example', nsec: keychain.nsec, ), ), ], ); addTearDown(container.dispose); await expectLater( container.read(relaySessionProvider.notifier).queryRelay(const []), throwsA(isA()), ); }); test('delivers the same live event to each matching subscription', () async { final session = RelaySessionNotifier(); final firstEvents = []; final secondEvents = []; const filter = NostrFilter( kinds: EventKind.channelEventKinds, tags: { '#h': [_channelId], }, limit: 50, ); final firstSubscribe = session.subscribe(filter, firstEvents.add); session.debugHandleMessage(['EOSE', 'l-1']); final unsubscribeFirst = await firstSubscribe; final secondSubscribe = session.subscribe(filter, secondEvents.add); session.debugHandleMessage(['EOSE', 'l-2']); final unsubscribeSecond = await secondSubscribe; final event = _event(); session.debugHandleMessage(['EVENT', 'l-1', event.toJson()]); session.debugHandleMessage(['EVENT', 'l-2', event.toJson()]); session.debugFlushEventBuffer(); expect(firstEvents.map((event) => event.id), [event.id]); expect(secondEvents.map((event) => event.id), [event.id]); session.debugHandleMessage(['EVENT', 'l-1', event.toJson()]); session.debugFlushEventBuffer(); expect(firstEvents.map((event) => event.id), [event.id]); expect(secondEvents.map((event) => event.id), [event.id]); unsubscribeFirst(); unsubscribeSecond(); }); test('live subscribe fails when relay closes before ready', () async { final session = RelaySessionNotifier(); const filter = NostrFilter(kinds: [EventKind.agentObserverFrame], limit: 0); final subscribe = session.subscribe(filter, (_) {}); session.debugHandleMessage([ 'CLOSED', 'l-1', 'restricted: p-gated events require #p matching your pubkey', ]); await expectLater( subscribe, throwsA( isA().having( (error) => error.toString(), 'message', contains('p-gated events require #p'), ), ), ); }); test( 'live onClosed callback runs when relay closes an open subscription', () async { final session = RelaySessionNotifier(); final closedMessages = []; const filter = NostrFilter( kinds: [EventKind.agentObserverFrame], limit: 0, ); final subscribe = session.subscribe( filter, (_) {}, onClosed: closedMessages.add, ); session.debugHandleMessage(['EOSE', 'l-1']); final unsubscribe = await subscribe; session.debugHandleMessage([ 'CLOSED', 'l-1', 'restricted: no longer valid', ]); expect(closedMessages, ['restricted: no longer valid']); unsubscribe(); }, ); } const _channelId = '11111111-1111-4111-8111-111111111111'; class _FakeRelayConfigNotifier extends RelayConfigNotifier { final String _baseUrl; final String? _nsec; _FakeRelayConfigNotifier({required String baseUrl, required String? nsec}) : _baseUrl = baseUrl, _nsec = nsec; @override RelayConfig build() => RelayConfig(baseUrl: _baseUrl, nsec: _nsec); } NostrEvent _event() { return const NostrEvent( id: 'event-1', pubkey: 'alice', createdAt: 20, kind: EventKind.streamMessageV2, tags: [ ['h', _channelId], ], content: 'hello', sig: 'sig', ); }