fix(mobile): retain authoritative live ownership and DM admission gates

Carry the immediate ROOT ownership readiness, consumer, queued-live and DM/huddle generation corrections on the authenticated profile prerequisite. Preserve the original ROOT production delta and bind its signed fixtures to the corrected parent.

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-12 12:29:06 -04:00
parent ef698b4d60
commit 24035e24b6
12 changed files with 574 additions and 30 deletions

View File

@@ -98,6 +98,8 @@ class ChannelActionsSheet extends HookConsumerWidget {
orElse: () => null,
);
final ownsOwnerAgent =
!agentOwnersAsync.isLoading &&
!agentOwnersAsync.hasError &&
currentPubkey != null &&
membersAsync.value?.any(
(member) =>

View File

@@ -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';

View File

@@ -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<int> debugHuddleProfileUpdates(String channelId) =>
_huddleParticipantProfileUpdatesProvider(channelId);
final _huddleParticipantProfileUpdatesProvider = NotifierProvider.autoDispose
.family<_HuddleParticipantProfileUpdates, int, String>(
_HuddleParticipantProfileUpdates.new,

View File

@@ -74,6 +74,8 @@ class ChannelDetailsPage extends HookConsumerWidget {
orElse: () => null,
);
final ownsOwnerAgent =
!agentOwnersAsync.isLoading &&
!agentOwnersAsync.hasError &&
resolvedCurrentPubkey != null &&
members.any(
(member) =>

View File

@@ -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<List<AgentDirectoryEntry>>((
/// profiles. An entry exists only when the `auth` tag verifies — mirrors
/// desktop's `profile_valid_oa_owner_pubkey`.
final agentOwnersProvider = FutureProvider<Map<String, String>>((ref) async {
final agents = await ref.watch(agentDirectoryProvider.future);
if (agents.isEmpty) return const {};
final session = ref.read(relaySessionProvider.notifier);
final events = await session.fetchHistory(
NostrFilters.profilesBatch([for (final agent in agents) agent.pubkey]),
);
final owners = <String, String>{};
for (final event in latestProfileEvents(events).values) {
final owner = verifiedOaOwnerPubkey(event);
if (owner != null) owners[event.pubkey.toLowerCase()] = owner;
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<bool>((ref) async* {
ref.watch(relayConfigProvider);
final admission = ref.read(userCacheProvider.notifier).captureAdmission();
if (ref.watch(relaySessionProvider).status != SessionStatus.connected) {
yield false;
return;
}
return owners;
final availability = StreamController<bool>()..add(false);
var disposed = false;
void Function()? unsubscribe;
ref.onDispose(() {
disposed = true;
unsubscribe?.call();
availability.close();
});
final agents = await ref.watch(agentDirectoryProvider.future);
if (disposed) return;
if (agents.isEmpty) {
yield true;
return;
}
final session = ref.read(relaySessionProvider.notifier);
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);
}
},
);
if (disposed) {
unsubscribe();
return;
}
yield* availability.stream;
});
/// Pubkeys currently known to represent agents across the active relay.

View File

@@ -198,7 +198,29 @@ NostrEvent _edit({
sig: '',
);
Future<Widget> admissionDmHarness(List<String> 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<NostrEvent> messages,
List<TypingEntry> typing = const [],
Map<String, UserProfile> 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 <String, String>{},
),
agentDirectoryProvider.overrideWith(
(ref) async => loadAgentDirectory?.call() ?? const [],
),
if (!realAdmission)
agentOwnersProvider.overrideWith(
(ref) async => loadAgentOwners?.call() ?? const <String, String>{},
),
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(),
),

View File

@@ -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<void Function()> 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 () {};
}
}

View File

@@ -223,6 +223,7 @@ void main() {
sharedChannelIds: {'chan-1'},
userCache: const {},
ownerByAgentPubkey: const {},
ownerSourceAvailable: false,
searchResults: [
UserProfile(
pubkey: agentPubkey,

View File

@@ -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<String>.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,
);
}
});
}

View File

@@ -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<void> controls(bool allowed) async {
await tester.pumpAndSettle();
if (details) {
final edit = find.byKey(
const ValueKey('channel-details-edit-action'),
);
expect(tester.widget<BuzzActionTile>(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<void> _pumpEventQueue() async {
class _MembershipRelaySessionNotifier extends RelaySessionNotifier {
final List<NostrEvent> _memberships;
final Object? subscribeError;
final bool profiles;
bool autoReady;
final List<NostrFilter> liveFilters = [];
final List<_LiveSubscription> _subscriptions = [];
final Completer<void> _subscribed = Completer<void>();
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<void> 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');
}

View File

@@ -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 [
(<List<String>>[], 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);

View File

@@ -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 = <List<dynamic>>[];
Iterable<String> get history => requests
.where((r) => (r[1] as String).startsWith('h-'))
.map((r) => r[1] as String)
.toList();
Iterable<String> 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<dynamic> payload) {
if (payload.first == 'REQ') requests.add(payload);
}
}