diff --git a/mobile/lib/features/channels/channel_actions_sheet.dart b/mobile/lib/features/channels/channel_actions_sheet.dart index 0df6a03863a..937703be7e1 100644 --- a/mobile/lib/features/channels/channel_actions_sheet.dart +++ b/mobile/lib/features/channels/channel_actions_sheet.dart @@ -98,6 +98,8 @@ class ChannelActionsSheet extends HookConsumerWidget { orElse: () => null, ); final ownsOwnerAgent = + !agentOwnersAsync.isLoading && + !agentOwnersAsync.hasError && currentPubkey != null && membersAsync.value?.any( (member) => diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 7d7536e567d..3c9754329cc 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -8,6 +8,7 @@ import 'package:flutter/rendering.dart' show ScrollDirection; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:hooks_riverpod/misc.dart' show ProviderListenable; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart index 76f3893cc56..5685a79a1a9 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart @@ -7,6 +7,11 @@ const _huddleSpeakingRingSize = 112.0; const _huddleParticipantLabelSpace = 28.0; const _huddleDenseParticipantThreshold = 6; +/// Exposes the actual participant profile producer without starting native audio. +@visibleForTesting +ProviderListenable debugHuddleProfileUpdates(String channelId) => + _huddleParticipantProfileUpdatesProvider(channelId); + final _huddleParticipantProfileUpdatesProvider = NotifierProvider.autoDispose .family<_HuddleParticipantProfileUpdates, int, String>( _HuddleParticipantProfileUpdates.new, diff --git a/mobile/lib/features/channels/channel_details_page.dart b/mobile/lib/features/channels/channel_details_page.dart index a626f0dc4fa..a129b127739 100644 --- a/mobile/lib/features/channels/channel_details_page.dart +++ b/mobile/lib/features/channels/channel_details_page.dart @@ -74,6 +74,8 @@ class ChannelDetailsPage extends HookConsumerWidget { orElse: () => null, ); final ownsOwnerAgent = + !agentOwnersAsync.isLoading && + !agentOwnersAsync.hasError && resolvedCurrentPubkey != null && members.any( (member) => diff --git a/mobile/lib/shared/mentions/agent_identity_provider.dart b/mobile/lib/shared/mentions/agent_identity_provider.dart index 1439ee584ce..207244f0fc3 100644 --- a/mobile/lib/shared/mentions/agent_identity_provider.dart +++ b/mobile/lib/shared/mentions/agent_identity_provider.dart @@ -1,10 +1,11 @@ +import 'dart:async'; import 'dart:collection'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../shared/crypto/nip_oa.dart'; +import '../profile/user_cache_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/utils/string_utils.dart'; @@ -74,18 +75,61 @@ final agentDirectoryProvider = FutureProvider>(( /// profiles. An entry exists only when the `auth` tag verifies — mirrors /// desktop's `profile_valid_oa_owner_pubkey`. final agentOwnersProvider = FutureProvider>((ref) async { + ref.watch(userCacheProvider); + final cache = ref.read(userCacheProvider.notifier); + final source = ref.watch(_agentOwnerProfilesProvider); + final ready = + source.asData?.value ?? + await ref.watch(_agentOwnerProfilesProvider.future); + if (ready != true) throw StateError('Owner profile feed unavailable'); + return cache.profileOwners; +}); + +// The live REQ carries history too; only EOSE (not acquisition timeout) is ready. +final _agentOwnerProfilesProvider = StreamProvider((ref) async* { + ref.watch(relayConfigProvider); + final admission = ref.read(userCacheProvider.notifier).captureAdmission(); + if (ref.watch(relaySessionProvider).status != SessionStatus.connected) { + yield false; + return; + } + final availability = StreamController()..add(false); + var disposed = false; + void Function()? unsubscribe; + ref.onDispose(() { + disposed = true; + unsubscribe?.call(); + availability.close(); + }); final agents = await ref.watch(agentDirectoryProvider.future); - if (agents.isEmpty) return const {}; + if (disposed) return; + if (agents.isEmpty) { + yield true; + return; + } final session = ref.read(relaySessionProvider.notifier); - final events = await session.fetchHistory( - NostrFilters.profilesBatch([for (final agent in agents) agent.pubkey]), + final filter = NostrFilters.profilesBatch([ + for (final agent in agents) agent.pubkey, + ]); + unsubscribe = await session.subscribeWithStatus( + filter, + (event) { + if (!disposed) admission.add(event); + }, + onClosed: (_) { + if (!disposed) availability.add(false); + }, + onStatusChanged: (status) { + if (!disposed) { + availability.add(status == RelaySubscriptionStatus.ready); + } + }, ); - final owners = {}; - for (final event in latestProfileEvents(events).values) { - final owner = verifiedOaOwnerPubkey(event); - if (owner != null) owners[event.pubkey.toLowerCase()] = owner; + if (disposed) { + unsubscribe(); + return; } - return owners; + yield* availability.stream; }); /// Pubkeys currently known to represent agents across the active relay. diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index f3c91edb5d1..505dab67681 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -198,7 +198,29 @@ NostrEvent _edit({ sig: '', ); +Future admissionDmHarness(List pubkeys) async { + SharedPreferences.setMockInitialValues({}); + _testPrefs = await SharedPreferences.getInstance(); + return _buildTestable( + messages: const [], + realAdmission: true, + channel: Channel( + id: _channelId, + name: 'DM', + channelType: 'dm', + visibility: 'private', + description: '', + createdBy: pubkeys.first, + createdAt: DateTime(2025), + memberCount: 2, + participantPubkeys: pubkeys, + isMember: true, + ), + ); +} + Widget _buildTestable({ + bool realAdmission = false, required List messages, List typing = const [], Map users = const {}, @@ -266,9 +288,10 @@ Widget _buildTestable({ huddleTypingNotifier ?? _FakeTypingNotifier(const [], channelId: _huddleChannelId), ), - userCacheProvider.overrideWith( - () => userCacheNotifier ?? _FakeUserCacheNotifier(users), - ), + if (!realAdmission) + userCacheProvider.overrideWith( + () => userCacheNotifier ?? _FakeUserCacheNotifier(users), + ), profileProvider.overrideWith(() => _FakeProfileNotifier()), channelsProvider.overrideWith(() => fakeChannelsNotifier), channelStarsProvider.overrideWith(_FakeChannelStarsNotifier.new), @@ -303,12 +326,14 @@ Widget _buildTestable({ if (member.isBot) member.pubkey.toLowerCase(), }, ), - agentOwnersProvider.overrideWith( - (ref) async => loadAgentOwners?.call() ?? const {}, - ), - agentDirectoryProvider.overrideWith( - (ref) async => loadAgentDirectory?.call() ?? const [], - ), + if (!realAdmission) + agentOwnersProvider.overrideWith( + (ref) async => loadAgentOwners?.call() ?? const {}, + ), + if (!realAdmission) + agentDirectoryProvider.overrideWith( + (ref) async => loadAgentDirectory?.call() ?? const [], + ), if (knownAgentPubkeys != null) knownAgentPubkeysProvider.overrideWithValue(knownAgentPubkeys), if (directoryUsers != null) @@ -348,9 +373,10 @@ Widget _buildTestable({ ), mediaHttpClientProvider.overrideWithValue(mediaClient), ], - if (relaySessionNotifier != null || - (resolvedChannel.isDm && - resolvedChannel.participantPubkeys.toSet().length == 2)) + if (!realAdmission && + (relaySessionNotifier != null || + (resolvedChannel.isDm && + resolvedChannel.participantPubkeys.toSet().length == 2))) relaySessionProvider.overrideWith( () => relaySessionNotifier ?? _IdentityUpdateRelaySession(), ), diff --git a/mobile/test/features/channels/channel_management_provider_test.dart b/mobile/test/features/channels/channel_management_provider_test.dart index a3aeb1fa400..b028cf13a8e 100644 --- a/mobile/test/features/channels/channel_management_provider_test.dart +++ b/mobile/test/features/channels/channel_management_provider_test.dart @@ -70,6 +70,7 @@ void main() { ), ], ); + container.listen(agentOwnersProvider, (_, _) {}); expect( await container.read(agentOwnersProvider.future), expected == null ? isEmpty : {agent.public: expected}, @@ -938,4 +939,18 @@ class _DirectoryFakeRelaySession extends RelaySessionNotifier { }) async { return profileEvents; } + + @override + Future subscribeWithStatus( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + void Function(RelaySubscriptionStatus status)? onStatusChanged, + }) async { + for (final event in profileEvents) { + onEvent(event); + } + onStatusChanged?.call(RelaySubscriptionStatus.ready); + return () {}; + } } diff --git a/mobile/test/features/channels/mentions/mention_candidates_test.dart b/mobile/test/features/channels/mentions/mention_candidates_test.dart index 45313ecd50d..9c98e692eaf 100644 --- a/mobile/test/features/channels/mentions/mention_candidates_test.dart +++ b/mobile/test/features/channels/mentions/mention_candidates_test.dart @@ -223,6 +223,7 @@ void main() { sharedChannelIds: {'chan-1'}, userCache: const {}, ownerByAgentPubkey: const {}, + ownerSourceAvailable: false, searchResults: [ UserProfile( pubkey: agentPubkey, diff --git a/mobile/test/shared/crypto/nip_oa_test.dart b/mobile/test/shared/crypto/nip_oa_test.dart index c31b985c995..23074f1c5ec 100644 --- a/mobile/test/shared/crypto/nip_oa_test.dart +++ b/mobile/test/shared/crypto/nip_oa_test.dart @@ -95,4 +95,65 @@ void main() { isNull, ); }); + test('rejects ambiguous auth and invalid envelopes', () { + final tag = authTag(owner, agent.public); + final valid = profile(agent, [tag]); + final badTags = [ + for (final duplicate in [ + tag, + ['auth'], + ['auth', 'invalid'], + ]) ...[ + [tag, duplicate], + [duplicate, tag], + ], + for (final index in [1, 3]) + [List.from(tag)..[index] = tag[index].toUpperCase()], + ]; + for (final event in [ + for (final tags in badTags) profile(agent, tags), + profile(agent, [tag], kind: 1), + for (final patch in [ + {'content': 'forged'}, + {'created_at': 101}, + {'id': '0' * 64}, + {'sig': '0' * 128}, + {'pubkey': owner.public}, + ]) + NostrEvent.fromJson({...valid.toJson(), ...patch}), + ]) { + expect(verifiedOaOwnerPubkey(event), isNull); + } + }); + test('conditions evaluate the signed profile time, with strict bounds', () { + const valid = [ + '', + 'kind=0', + 'created_at>99&created_at<101', + 'created_at<4294967295', + ]; + for (final conditions in [ + ...valid, + 'kind=1', + 'created_at>100', + 'created_at<100', + 'kind=65536', + 'kind=00', + 'kind=+0', + 'created_at<4294967296', + 'kind=0&', + ' kind=0', + 'kind=0&kind=1', + ]) { + expect( + verifiedOaOwnerPubkey( + profile(agent, [ + authTag(owner, agent.public, conditions: conditions), + ]), + ), + valid.contains(conditions) ? owner.public : isNull, + reason: conditions, + ); + } + }); } diff --git a/mobile/test/shared/mentions/agent_identity_provider_test.dart b/mobile/test/shared/mentions/agent_identity_provider_test.dart index c5355f26a43..023ef7f89db 100644 --- a/mobile/test/shared/mentions/agent_identity_provider_test.dart +++ b/mobile/test/shared/mentions/agent_identity_provider_test.dart @@ -1,4 +1,14 @@ import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channel_actions_sheet.dart'; +import 'package:buzz/features/channels/mentions/mention_candidates_provider.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/widgets/buzz_action_tile.dart'; +import '../../helpers/widget_helpers.dart'; + +import 'package:nostr/nostr.dart' as nostr; +import '../crypto/nip_oa_test.dart' show authTag, profile; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -8,6 +18,144 @@ import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; void main() { + final owner = nostr.Keys.generate(); + final agent = nostr.Keys.generate(); + final owned = profile(agent, [authTag(owner, agent.public)]); + final revoked = profile(agent, [], createdAt: 101); + // Canonical positive/revocation/tie projection is exercised through both + // owner and search providers in channel_management_provider_test.dart. + for (final details in [false, true]) { + testWidgets( + '${details ? 'Details' : 'Actions'} follows live owner authority', + (tester) async { + await tester.binding.setSurfaceSize(const Size(900, 2400)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final restored = profile(agent, [ + authTag(owner, agent.public), + ], createdAt: 103); + final invalid = profile(agent, [ + authTag(owner, owner.public), + ], createdAt: 104); + final session = _MembershipRelaySessionNotifier( + [owned, profile(agent, [], kind: 10100)], + profiles: true, + autoReady: false, + ); + final channel = Channel( + id: _channelId, + name: 'proof', + channelType: 'stream', + visibility: 'open', + description: '', + createdBy: agent.public, + createdAt: DateTime(2025), + memberCount: 1, + isMember: true, + ); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + relaySessionProvider.overrideWith(() => session), + relayConfigProvider.overrideWith(_FixedRelayConfig.new), + currentPubkeyProvider.overrideWithValue(owner.public), + channelMembersProvider(_channelId).overrideWith( + (ref) async => [ + ChannelMember( + pubkey: agent.public, + role: 'owner', + joinedAt: DateTime(2025), + ), + ], + ), + ], + child: details + ? ChannelDetailsPage( + channel: channel, + currentPubkey: owner.public, + onMemberTap: (_, _) {}, + ) + : ChannelActionsSheet(channel: channel, isUnread: false), + ), + ); + await tester.pumpAndSettle(); + final container = ProviderScope.containerOf( + tester.element( + find.byType(details ? ChannelDetailsPage : ChannelActionsSheet), + ), + ); + final candidates = mentionCandidatesProvider(( + channelId: _channelId, + query: '', + )); + final listener = container.listen(candidates, (_, _) {}); + addTearDown(listener.close); + await tester.pumpAndSettle(); + Future controls(bool allowed) async { + await tester.pumpAndSettle(); + if (details) { + final edit = find.byKey( + const ValueKey('channel-details-edit-action'), + ); + expect(tester.widget(edit).isEnabled, allowed); + } else { + expect( + find.text('Archive channel'), + allowed ? findsOneWidget : findsNothing, + ); + expect( + find.text('Delete channel'), + allowed ? findsOneWidget : findsNothing, + ); + } + expect( + container.read(candidates).single.ownerPubkey, + allowed ? owner.public : isNull, + ); + } + + // Acquisition alone is not EOSE, even with a positive cached profile. + await controls(false); + session.autoReady = true; + session.setSubscriptionStatus(RelaySubscriptionStatus.ready); + await controls(true); + session.closeProfiles(); + await controls(false); + expect(container.read(userCacheProvider.notifier).profileOwners, { + agent.public: owner.public, + }); + session._memberships[0] = revoked; + session.connection(SessionStatus.disconnected); + await tester.pumpAndSettle(); + session.connection(SessionStatus.connected); + await controls(false); + for (final event in [revoked, restored, invalid, owned]) { + session.emit(event); + await controls(event.id == restored.id); + if (event.id == restored.id) { + session.setSubscriptionStatus(RelaySubscriptionStatus.retrying); + await controls(false); + session.setSubscriptionStatus(RelaySubscriptionStatus.ready); + await controls(true); + session.connection(SessionStatus.disconnected); + await controls(false); + session.connection(SessionStatus.connected); + await controls(true); + } + } + final cache = container.read(userCacheProvider.notifier); + await cache.refresh([agent.public]); + // A new connection rebuilds the profile producer against stale history. + session.connection(SessionStatus.disconnected); + await tester.pumpAndSettle(); + session.connection(SessionStatus.connected); + await tester.pumpAndSettle(); + session.emit(owned); + await controls(false); + expect(cache.profileOwners, isEmpty); + await tester.pumpWidget(const SizedBox()); + }, + ); + } test('refreshes channel bot roles from live membership updates', () async { final relaySession = _MembershipRelaySessionNotifier([ _membershipEvent(role: 'bot'), @@ -253,13 +401,22 @@ Future _pumpEventQueue() async { class _MembershipRelaySessionNotifier extends RelaySessionNotifier { final List _memberships; final Object? subscribeError; + final bool profiles; + bool autoReady; final List liveFilters = []; final List<_LiveSubscription> _subscriptions = []; final Completer _subscribed = Completer(); var unsubscribeCount = 0; var _membershipIndex = 0; - _MembershipRelaySessionNotifier(this._memberships, {this.subscribeError}); + _MembershipRelaySessionNotifier( + this._memberships, { + this.subscribeError, + this.profiles = false, + this.autoReady = true, + }); + + void connection(SessionStatus status) => state = SessionState(status: status); Future get subscribed => _subscribed.future; @@ -271,7 +428,11 @@ class _MembershipRelaySessionNotifier extends RelaySessionNotifier { NostrFilter filter, { Duration timeout = const Duration(seconds: 8), }) async { - return [_memberships[_membershipIndex++]]; + return profiles + ? _memberships + .where((event) => filter.kinds.contains(event.kind)) + .toList() + : [_memberships[_membershipIndex++]]; } @override @@ -290,7 +451,14 @@ class _MembershipRelaySessionNotifier extends RelaySessionNotifier { onStatusChanged, ); _subscriptions.add(subscription); - onStatusChanged(RelaySubscriptionStatus.ready); + if (profiles) { + for (final event in _memberships.where( + (event) => _matches(filter, event), + )) { + onEvent(event); + } + } + if (autoReady) onStatusChanged(RelaySubscriptionStatus.ready); if (!_subscribed.isCompleted) _subscribed.complete(); return () { unsubscribeCount++; @@ -306,6 +474,14 @@ class _MembershipRelaySessionNotifier extends RelaySessionNotifier { } } + void closeProfiles() { + for (final sub in List.of(_subscriptions)) { + if (!sub.filter.kinds.contains(0)) continue; + sub.onClosed?.call('restricted: no longer valid'); + _subscriptions.remove(sub); + } + } + void closeSubscription(String message) { for (final subscription in List.of(_subscriptions)) { subscription.onClosed?.call(message); @@ -345,3 +521,8 @@ bool _matches(NostrFilter filter, NostrEvent event) { ); }); } + +class _FixedRelayConfig extends RelayConfigNotifier { + @override + RelayConfig build() => const RelayConfig(baseUrl: 'https://relay.invalid'); +} diff --git a/mobile/test/shared/mentions/owner_search_generation_test.dart b/mobile/test/shared/mentions/owner_search_generation_test.dart index 4161ff8003e..fdf100142d6 100644 --- a/mobile/test/shared/mentions/owner_search_generation_test.dart +++ b/mobile/test/shared/mentions/owner_search_generation_test.dart @@ -9,8 +9,8 @@ import 'package:buzz/shared/community/community.dart'; import 'package:buzz/shared/auth/auth_provider.dart'; import 'package:buzz/shared/community/community_provider.dart'; import 'package:buzz/shared/community/community_storage.dart'; -import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -22,7 +22,7 @@ import '../community/community_storage_test.dart' show FakeSecureStorage; import '../crypto/nip_oa_test.dart' show authTag, profile; void main() { - for (final boundary in ['search', 'refresh', 'preload']) { + for (final boundary in ['search', 'refresh', 'preload', 'live']) { for (final transition in boundary == 'search' ? ['unchanged', 'community', 'account', 'ABA'] @@ -79,6 +79,23 @@ void main() { if (boundary == 'search') { container.listen(search, (_, _) {}); await entered.future; + } else if (boundary == 'live') { + container.read(relaySessionProvider); + await session.debugHandleConnected(); + container.listen(agentOwnersProvider, (_, _) {}); + await drainAdmission(); + session.debugHandleMessage([ + 'EVENT', + 'h-1', + {...owned.toJson(), 'kind': 10100, 'content': '{}'}, + ]); + session.debugHandleMessage(['EOSE', 'h-1']); + await drainAdmission(); + session.debugHandleMessage(['EOSE', 'l-2']); + await container.pump(); + expect(await container.read(agentOwnersProvider.future), isEmpty); + // Queue in the real session buffer, not an obsolete callback handle. + session.debugHandleMessage(['EVENT', 'l-2', owned.toJson()]); } else { pending = boundary == 'refresh' ? cache.refresh([agent.public]) @@ -99,6 +116,8 @@ void main() { } if (boundary == 'search') { response.complete(http.Response(jsonEncode([owned.toJson()]), 200)); + } else if (boundary == 'live') { + session.debugFlushEventBuffer(); } else { session.debugHandleMessage(['EVENT', 'h-1', owned.toJson()]); session.debugHandleMessage(['EOSE', 'h-1']); @@ -137,6 +156,15 @@ void main() { expect(oldAdmission.isCurrent, isFalse); expect(current.profilePubkeys, isEmpty); expect(await container.read(agentOwnersProvider.future), isEmpty); + for (final (tags, time, allowed) in [ + (>[], 101, false), + (owned.tags, 102, true), + ]) { + current.captureAdmission().add( + profile(agent, tags, createdAt: time), + ); + await accepts(allowed); + } expect(container.read(search).value, same(found)); } if (boundary != 'search' && transition == 'community') { @@ -144,7 +172,7 @@ void main() { final recovery = container.read(userCacheProvider.notifier).refresh([ agent.public, ]); - const id = 'h-2'; + final id = boundary == 'live' ? 'h-3' : 'h-2'; session.debugHandleMessage(['EVENT', id, owned.toJson()]); session.debugHandleMessage(['EOSE', id]); expect(await recovery, isTrue); diff --git a/mobile/test/shared/mentions/r1_dm_admission_test.dart b/mobile/test/shared/mentions/r1_dm_admission_test.dart new file mode 100644 index 00000000000..fa8c7edaf33 --- /dev/null +++ b/mobile/test/shared/mentions/r1_dm_admission_test.dart @@ -0,0 +1,178 @@ +import 'package:buzz/features/channels/channel_detail_page.dart'; +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/shared/theme/theme_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:flutter/material.dart'; +import '../../features/channels/channel_detail_page_test.dart' + show admissionDmHarness; + +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/auth/auth_provider.dart'; +import 'package:buzz/shared/community/community_provider.dart'; +import 'package:buzz/shared/community/community_storage.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import '../community/community_storage_test.dart' show FakeSecureStorage; +import 'owner_search_generation_test.dart' + show AdmissionCommunities, AdmissionAuth, drainAdmission; +import '../crypto/nip_oa_test.dart' show authTag, profile; + +void main() { + for (final huddle in [false, true]) { + for (final retired in [false, true]) { + testWidgets('R1 ${huddle ? 'huddle' : 'DM'} admission retired=$retired', ( + tester, + ) async { + final owner = nostr.Keys.generate(); + final agent = nostr.Keys.generate(); + final owned = profile(agent, [authTag(owner, agent.public)]); + final initial = Community.create( + name: 'A', + relayUrl: 'https://a.example', + nsec: owner.nsec, + ); + final next = Community.create( + name: 'B', + relayUrl: 'https://b.example', + nsec: owner.nsec, + ); + final storage = CommunityStorage(secure: FakeSecureStorage()); + await storage.saveActiveId(initial.id); + final list = AdmissionCommunities(initial); + final session = RelaySessionNotifier(); + final socket = _RecordingSocket(); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final container = ProviderContainer.test( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + authProvider.overrideWith(AdmissionAuth.new), + communityStorageProvider.overrideWithValue(storage), + communityListProvider.overrideWith(() => list), + relaySessionProvider.overrideWith(() => session), + channelMembersProvider('admission').overrideWith( + (ref) async => [ + ChannelMember( + pubkey: agent.public, + role: 'member', + joinedAt: DateTime(2025), + ), + ], + ), + ], + ); + await container.read(authProvider.future); + await container.read(activeCommunityProvider.future); + container.read(relaySessionProvider); + await session.debugHandleConnected(); + session.debugAttachSocketForTest(socket); + container.listen(agentOwnersProvider, (_, _) {}); + await drainAdmission(); + // Empty directory is a legitimate ready source, not a fake owner map. + session.debugHandleMessage(['EOSE', socket.history.single]); + await drainAdmission(); + expect(await container.read(agentOwnersProvider.future), isEmpty); + if (huddle) { + await container.read(channelMembersProvider('admission').future); + container.listen(debugHuddleProfileUpdates('admission'), (_, _) {}); + } else { + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: await admissionDmHarness([owner.public, agent.public]), + ), + ); + } + await drainAdmission(); + final id = socket.profileIds(huddle).single; + expect(id, startsWith('l-')); + socket.ready(session); + await drainAdmission(); + session.debugHandleMessage(['EVENT', id, owned.toJson()]); + if (retired) { + await storage.saveActiveId(next.id); + list.replace(next); + await container.read(activeCommunityProvider.future); + } + session.debugFlushEventBuffer(); + await drainAdmission(); + final observed = container + .read(userCacheProvider.notifier) + .profileOwners; + // Keep the real consumer and widget alive across lazy invalidation. + if (retired) { + container.read(relaySessionProvider); + if (huddle) container.read(debugHuddleProfileUpdates('admission')); + await tester.pump(); + await session.debugHandleConnected(); + session.debugAttachSocketForTest(socket); + if (huddle) container.read(debugHuddleProfileUpdates('admission')); + container.read(agentOwnersProvider); + await drainAdmission(); + socket.ready(session); + await drainAdmission(); + } + await tester.pump(); + final authority = await container.read(agentOwnersProvider.future); + final currentDm = socket.profileIds(huddle).last; + if (retired) expect(currentDm, isNot(id)); + socket.ready(session); + await drainAdmission(); + final nextOwner = nostr.Keys.generate(); + final advancing = profile(agent, [ + authTag(nextOwner, agent.public), + ], createdAt: 101); + session.debugHandleMessage(['EVENT', currentDm, advancing.toJson()]); + session.debugFlushEventBuffer(); + await tester.pump(); + final recovery = await container.read(agentOwnersProvider.future); + await tester.pumpWidget(const SizedBox.shrink()); + container.dispose(); + await tester.pump(const Duration(milliseconds: 600)); + expect(recovery, {agent.public: nextOwner.public}); + expect(authority, retired ? isEmpty : {agent.public: owner.public}); + expect(observed, retired ? isEmpty : {agent.public: owner.public}); + }); + } + } +} + +class _RecordingSocket extends RelaySocket { + _RecordingSocket() + : super( + wsUrl: 'wss://unused.example', + nsec: null, + onMessage: (_) {}, + onConnected: () {}, + onDisconnected: (_) {}, + ); + final requests = >[]; + Iterable get history => requests + .where((r) => (r[1] as String).startsWith('h-')) + .map((r) => r[1] as String) + .toList(); + Iterable profileIds(bool huddle) => requests + .where( + (r) => + (r[2]['kinds'] as List).contains(0) && + (huddle + ? r[2]['limit'] == 0 + : (r[2]['kinds'] as List).contains(10100)), + ) + .map((r) => r[1] as String); + void ready(RelaySessionNotifier session) { + for (final r in requests.toList()) { + session.debugHandleMessage(['EOSE', r[1]]); + } + } + + @override + void send(List payload) { + if (payload.first == 'REQ') requests.add(payload); + } +}