Files
buzz/mobile/test/features/channels/channel_management_provider_test.dart
Tom Brow 6eff84d127 fix(mobile): join starter channels after accepting invite (#5915)
### What changed?

After a new mobile invite claim succeeds, Buzz now best-effort ensures
membership in the same public starter channels as desktop:

- `#general`
- `#welcome-everyone`

Missing starters use desktop's deterministic per-relay IDs and exact
public channel configuration, so concurrent mobile and desktop setup
converges safely. Setup failures do not invalidate or retry an already
successful invite claim, and failure for one starter does not block the
other.

The success sheet offers **Continue to #welcome-everyone** when that
channel is available. Mobile does not create the private `Welcome`
channel because it cannot provision the desktop Welcome agents that make
that channel useful.

This PR is stacked on #6145 because it deliberately reuses that PR's
open-channel directory and join behavior. Once #6145 merges, this PR can
be retargeted to `main` without changing its BUZZ-12 diff.

Fixes
[BUZZ-12](https://linear.app/squareup/issue/BUZZ-12/bug-community-appears-empty-after-using-invite-link-on-mobile).

### How is it tested?

- Desktop/mobile deterministic starter-ID parity coverage.
- Existing-channel join and missing-channel creation coverage.
- Duplicate-create convergence and per-channel failure isolation
coverage.
- Invite success remains successful when starter setup fails.
- Widget coverage for continuing directly into `#welcome-everyone`.
- Focused invite/deep-link tests: 23 passed.
- `just mobile-check`: passed.
- `just mobile-test`: 1,483 passed.
- Pre-push repository checks: passed.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
2026-08-24 12:59:21 -07:00

805 lines
24 KiB
Dart

import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/features/channels/mobile_huddle_controller.dart';
import 'package:buzz/shared/relay/relay.dart';
/// Tests for [channelDetailsFromEvent].
///
/// The function maps a kind:39000 metadata event to [ChannelDetails], and is
/// the source of truth for the merge that [Channel.mergeDetails] performs in
/// the channel detail view. Anything `ChannelData.fromEvent` parses that's
/// also exposed on `ChannelDetails` MUST be propagated here — otherwise
/// `mergeDetails` silently clears that state on the merged Channel.
void main() {
test('extracts unique relay members from current and legacy tags', () {
final pubkeys = relayMemberPubkeysFromEvents([
NostrEvent(
id: 'members-1',
pubkey: 'relay',
createdAt: 1700000000,
kind: 13534,
tags: const [
['member', 'ALICE', 'member'],
['member', 'bob', 'admin'],
['p', 'alice', '', 'member'],
['name', 'not-a-member'],
['member', ''],
],
content: '',
sig: 'sig',
),
]);
expect(pubkeys, ['alice', 'bob']);
});
test('builds an alphabetized directory from the latest profile events', () {
final users = directoryUsersFromProfileEvents([
NostrEvent(
id: 'alice-old',
pubkey: 'alice',
createdAt: 10,
kind: 0,
tags: const [],
content: '{"display_name":"Zoe"}',
sig: 'sig',
),
NostrEvent(
id: 'bob',
pubkey: 'bob',
createdAt: 20,
kind: 0,
tags: const [],
content: '{"display_name":"Bob"}',
sig: 'sig',
),
NostrEvent(
id: 'alice-new',
pubkey: 'ALICE',
createdAt: 30,
kind: 0,
tags: const [],
content:
'{"display_name":"Alice","picture":"https://example.com/alice.png"}',
sig: 'sig',
),
NostrEvent(
id: 'not-a-profile',
pubkey: 'charlie',
createdAt: 40,
kind: 1,
tags: const [],
content: '{}',
sig: 'sig',
),
]);
expect(users.map((user) => user.label), ['Alice', 'Bob']);
expect(users.first.pubkey, 'alice');
expect(users.first.avatarUrl, 'https://example.com/alice.png');
});
test('propagates archived state from kind:39000 archived tag', () {
// Regression: previously this mapping ignored the `archived` tag, so
// `Channel.mergeDetails` would clear the archived flag the list provider
// had set, and the detail screen would show compose/manage actions for
// expired/archived TTL channels.
final details = channelDetailsFromEvent(
NostrEvent(
id: 'meta-1',
pubkey: 'creator',
createdAt: 1700000000,
kind: 39000,
tags: const [
['d', 'c8c629ae-d35c-44fa-bc39-f6c1816756cc'],
['name', 'expired-ttl'],
['t', 'stream'],
['public'],
['ttl', '86400'],
['archived', 'true'],
],
content: '',
sig: 'sig',
),
);
expect(details.archivedAt, isNotNull);
expect(details.ttlSeconds, 86400);
});
test('omits archivedAt when no archived tag is present', () {
final details = channelDetailsFromEvent(
NostrEvent(
id: 'meta-1',
pubkey: 'creator',
createdAt: 1700000000,
kind: 39000,
tags: const [
['d', 'c8c629ae-d35c-44fa-bc39-f6c1816756cc'],
['name', 'active'],
['t', 'stream'],
['public'],
],
content: '',
sig: 'sig',
),
);
expect(details.archivedAt, isNull);
expect(details.ttlSeconds, isNull);
});
test('propagates ttl_deadline tag', () {
final details = channelDetailsFromEvent(
NostrEvent(
id: 'meta-1',
pubkey: 'creator',
createdAt: 1700000000,
kind: 39000,
tags: const [
['d', 'c8c629ae-d35c-44fa-bc39-f6c1816756cc'],
['name', 'with-deadline'],
['t', 'stream'],
['public'],
['ttl', '86400'],
['ttl_deadline', '2026-05-14T19:54:06.989151+00:00'],
],
content: '',
sig: 'sig',
),
);
expect(details.ttlSeconds, 86400);
expect(details.ttlDeadline, isNotNull);
expect(details.ttlDeadline!.isUtc, isTrue);
});
group('buildCreateChannelTags', () {
test('builds an ongoing channel without a ttl tag', () {
final tags = buildCreateChannelTags(
channelId: 'c8c629ae-d35c-44fa-bc39-f6c1816756cc',
name: 'release-notes',
channelType: 'stream',
visibility: 'open',
description: ' Ship updates ',
);
expect(tags, [
['h', 'c8c629ae-d35c-44fa-bc39-f6c1816756cc'],
['name', 'release-notes'],
['visibility', 'open'],
['channel_type', 'stream'],
['about', 'Ship updates'],
]);
});
test('adds the selected ttl for a temporary channel', () {
final tags = buildCreateChannelTags(
channelId: 'c8c629ae-d35c-44fa-bc39-f6c1816756cc',
name: 'incident-room',
channelType: 'stream',
visibility: 'private',
ttlSeconds: 604800,
);
expect(tags, contains(equals(['ttl', '604800'])));
});
});
group('buildDeleteMessageTags', () {
test('emits both channel h tag and target e tag', () {
final tags = buildDeleteMessageTags(
channelId: 'c8c629ae-d35c-44fa-bc39-f6c1816756cc',
eventId: 'abc123',
);
expect(tags, [
['h', 'c8c629ae-d35c-44fa-bc39-f6c1816756cc'],
['e', 'abc123'],
]);
});
});
group('build channel lifecycle tags', () {
test('channel edits match desktop kind 9002 tags', () {
expect(
buildUpdateChannelTags(
channelId: 'channel-id',
name: ' ###general ',
description: ' Team updates ',
),
[
['h', 'channel-id'],
['name', 'general'],
['about', 'Team updates'],
],
);
});
test('channel edits reject a hash-only name', () {
expect(
() => buildUpdateChannelTags(channelId: 'channel-id', name: ' ### '),
throwsArgumentError,
);
});
test('archive matches kind 9002 tags', () {
expect(buildSetChannelArchivedTags('channel-id', archived: true), [
['h', 'channel-id'],
['archived', 'true'],
]);
});
test('unarchive matches kind 9002 tags', () {
expect(buildSetChannelArchivedTags('channel-id', archived: false), [
['h', 'channel-id'],
['archived', 'false'],
]);
});
test('delete matches desktop kind 9008 tags', () {
expect(buildDeleteChannelTags('channel-id'), [
['h', 'channel-id'],
]);
});
});
test(
'create and join stop before submitting after a community switch',
() async {
final keys = nostr.Keys.generate();
final session = _RecordingPublishRelaySession();
final actionsProvider = Provider<ChannelActions>((ref) {
return ChannelActions(
ref: ref,
session: session,
signedEventRelay: SignedEventRelay(session: session, nsec: keys.nsec),
currentPubkey: keys.public,
isCommunityValid: () => false,
);
});
final container = ProviderContainer(retry: (_, _) => null);
addTearDown(container.dispose);
final actions = container.read(actionsProvider);
await expectLater(
actions.createChannel(
channelId: _channelId,
name: 'general',
channelType: 'stream',
visibility: 'open',
),
throwsA(isA<StateError>()),
);
await expectLater(
actions.joinChannel(_channelId),
throwsA(isA<StateError>()),
);
expect(session.publishedEvents, isEmpty);
},
);
group('Huddle channel lifecycle', () {
test(
'starts from accepted signed events without refreshing all channels',
() async {
final keys = nostr.Keys.generate();
final session = _RecordingPublishRelaySession();
final actionsProvider = Provider<ChannelActions>((ref) {
return ChannelActions(
ref: ref,
session: session,
signedEventRelay: SignedEventRelay(
session: session,
nsec: keys.nsec,
),
currentPubkey: keys.public,
);
});
final container = ProviderContainer(
retry: (_, _) => null,
overrides: [
relaySessionProvider.overrideWith(() => session),
myPubkeyProvider.overrideWithValue(keys.public),
],
);
addTearDown(container.dispose);
final actions = container.read(actionsProvider);
final backingChannelId = await actions.createHuddleBackingChannel();
final started = await actions.announceHuddleStarted(
parentChannelId: _channelId,
ephemeralChannelId: backingChannelId,
);
expect(session.historyQueryCount, 0);
expect(session.publishedEvents, hasLength(2));
final create = session.publishedEvents.first;
expect(create.kind, 9007);
expect(create.tags, contains(equals(['h', backingChannelId])));
expect(create.tags, contains(equals(['visibility', 'private'])));
expect(create.tags, contains(equals(['channel_type', 'stream'])));
expect(create.tags, contains(equals(['ttl', '3600'])));
final start = session.publishedEvents.last;
expect(start.kind, EventKind.huddleStarted);
expect(start.tags, [
['h', _channelId],
]);
expect(start.content, '{"ephemeral_channel_id":"$backingChannelId"}');
expect(started.id, start.id);
},
);
test(
'publishes ephemeral Huddle reactions on the backing channel',
() async {
final keys = nostr.Keys.generate();
final session = _RecordingPublishRelaySession();
final actionsProvider = Provider<ChannelActions>((ref) {
return ChannelActions(
ref: ref,
session: session,
signedEventRelay: SignedEventRelay(
session: session,
nsec: keys.nsec,
),
currentPubkey: keys.public,
);
});
final container = ProviderContainer(
retry: (_, _) => null,
overrides: [
relaySessionProvider.overrideWith(() => session),
myPubkeyProvider.overrideWithValue(keys.public),
],
);
addTearDown(container.dispose);
await container
.read(actionsProvider)
.sendHuddleReaction(
channelId: _channelId,
emoji: ' 🎉 ',
senderName: 'Self',
);
expect(session.publishedEvents, hasLength(1));
final reaction = session.publishedEvents.single;
expect(reaction.kind, EventKind.huddleReaction);
expect(reaction.content, '🎉');
expect(reaction.tags, [
['h', _channelId],
['reaction', '🎉'],
['sender_name', 'Self'],
]);
},
);
test('last-human count ignores a stale cached roster', () async {
final session = _ConnectionAwareRelaySession();
final container = ProviderContainer(
retry: (_, _) => null,
overrides: [
relaySessionProvider.overrideWith(() => session),
channelMembersProvider(_channelId).overrideWith(
(ref) async => [
ChannelMember(
pubkey: _memberPubkey,
role: 'admin',
joinedAt: DateTime(2025),
),
ChannelMember(
pubkey:
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
role: 'member',
joinedAt: DateTime(2025),
),
],
),
],
);
addTearDown(container.dispose);
container.read(relaySessionProvider);
session.connect();
expect(
await container.read(channelMembersProvider(_channelId).future),
hasLength(2),
);
final humanCount = await container.read(huddleHumanCountProvider)(
_channelId,
);
expect(humanCount, 1);
expect(session.historyQueryCount, 1);
});
});
test(
'stale channel actions cannot publish after a community switch',
() async {
final session = RelaySessionNotifier();
final container = ProviderContainer(
retry: (_, _) => null,
overrides: [
relayConfigProvider.overrideWith(_FixedRelayConfigNotifier.new),
relaySessionProvider.overrideWith(() => session),
],
);
addTearDown(container.dispose);
final staleActions = container.read(channelActionsProvider);
container
.read(relayConfigProvider.notifier)
.update(baseUrl: 'https://other-community.example', nsec: null);
final operations = <Future<void> Function()>[
() =>
staleActions.updateChannel(channelId: _channelId, name: 'renamed'),
() => staleActions.archiveChannel(_channelId),
() => staleActions.unarchiveChannel(_channelId),
() => staleActions.deleteChannel(_channelId),
() => staleActions.setCanvas(channelId: _channelId, content: 'canvas'),
() => staleActions.changeMemberRole(
channelId: _channelId,
pubkey: _memberPubkey,
role: 'admin',
),
() => staleActions.removeMember(
channelId: _channelId,
pubkey: _memberPubkey,
),
() => staleActions.leaveChannel(_channelId),
];
for (final operation in operations) {
await expectLater(operation(), throwsA(isA<StateError>()));
}
},
);
group('channelMembersProvider', () {
test('waits for the relay connection before fetching members', () async {
final session = _ConnectionAwareRelaySession();
final container = ProviderContainer(
retry: (_, _) => null,
overrides: [relaySessionProvider.overrideWith(() => session)],
);
addTearDown(container.dispose);
final subscription = container.listen(
channelMembersProvider(_channelId),
(_, _) {},
);
addTearDown(subscription.close);
expect(
await container.read(channelMembersProvider(_channelId).future),
isEmpty,
);
expect(session.historyQueryCount, 0);
session.connect();
await container.pump();
final members = await container.read(
channelMembersProvider(_channelId).future,
);
expect(session.historyQueryCount, 1);
expect(members, hasLength(1));
expect(members.single.pubkey, _memberPubkey);
expect(members.single.role, 'admin');
});
test(
'keeps the provider member snapshot available during reconnect',
() async {
final session = _ConnectionAwareRelaySession();
final container = ProviderContainer(
retry: (_, _) => null,
overrides: [relaySessionProvider.overrideWith(() => session)],
);
addTearDown(container.dispose);
final subscription = container.listen(
channelMembersProvider(_channelId),
(_, _) {},
);
addTearDown(subscription.close);
session.connect();
await container.pump();
final connectedMembers = await container.read(
channelMembersProvider(_channelId).future,
);
expect(connectedMembers, hasLength(1));
expect(session.historyQueryCount, 1);
session.setStatus(SessionStatus.reconnecting);
await container.pump();
final reconnectingMembers = container
.read(channelMembersProvider(_channelId))
.asData
?.value;
expect(reconnectingMembers, connectedMembers);
expect(session.historyQueryCount, 1);
},
);
test('keeps the member snapshot available during reconnect', () {
final cachedMembers = [
ChannelMember(
pubkey: _memberPubkey,
role: 'member',
joinedAt: DateTime.fromMillisecondsSinceEpoch(1000),
),
];
final refreshedMember = ChannelMember(
pubkey: _memberPubkey,
role: 'admin',
joinedAt: DateTime.fromMillisecondsSinceEpoch(2000),
);
expect(
channelMembersForAutocomplete(
membersAsync: const AsyncData([]),
sessionStatus: SessionStatus.connected,
cachedMembers: cachedMembers,
),
isEmpty,
);
expect(
channelMembersForAutocomplete(
membersAsync: const AsyncData([]),
sessionStatus: SessionStatus.reconnecting,
cachedMembers: cachedMembers,
),
same(cachedMembers),
);
expect(
channelMembersForAutocomplete(
membersAsync: AsyncData([refreshedMember]),
sessionStatus: SessionStatus.connected,
cachedMembers: cachedMembers,
),
[refreshedMember],
);
});
});
group('directory providers relay-config invalidation', () {
NostrEvent profile(String pubkey, String name) => NostrEvent(
id: '$pubkey-profile',
pubkey: pubkey,
createdAt: 1700000000,
kind: 0,
tags: const [],
content: '{"display_name":"$name"}',
sig: 'sig',
);
ProviderContainer buildContainer(_DirectoryFakeRelaySession session) {
return ProviderContainer(
retry: (_, _) => null,
overrides: [
relaySessionProvider.overrideWith(() => session),
myPubkeyProvider.overrideWithValue('me'),
],
);
}
test('browse directory refetches when the relay config changes', () async {
final session = _DirectoryFakeRelaySession(
profileEvents: [profile('alice', 'Alice')],
);
final container = buildContainer(session);
addTearDown(container.dispose);
// Keep the autoDispose provider alive across the config change, the
// way an open New message sheet would.
final subscription = container.listen(
relayDirectoryUsersProvider,
(_, _) {},
);
addTearDown(subscription.close);
final firstUsers = await container.read(
relayDirectoryUsersProvider.future,
);
expect(firstUsers.map((user) => user.label), ['Alice']);
expect(session.directoryQueryCount, 1);
// Simulate switching to a community that shares the same signing key:
// session notifier instance and pubkey both survive; only the relay
// config changes.
session.profileEvents = [profile('bob', 'Bob')];
container
.read(relayConfigProvider.notifier)
.update(baseUrl: 'http://other-community.example', nsec: null);
await container.pump();
final secondUsers = await container.read(
relayDirectoryUsersProvider.future,
);
expect(secondUsers.map((user) => user.label), ['Bob']);
expect(session.directoryQueryCount, 2);
});
test('search results refetch when the relay config changes', () async {
final session = _DirectoryFakeRelaySession(
profileEvents: [profile('alice', 'Alice')],
);
final container = buildContainer(session);
addTearDown(container.dispose);
final subscription = container.listen(
relayDirectorySearchProvider('ali'),
(_, _) {},
);
addTearDown(subscription.close);
final firstResults = await container.read(
relayDirectorySearchProvider('ali').future,
);
expect(firstResults.map((user) => user.label), ['Alice']);
expect(session.searchQueryCount, 1);
session.profileEvents = [profile('alina', 'Alina')];
container
.read(relayConfigProvider.notifier)
.update(baseUrl: 'http://other-community.example', nsec: null);
await container.pump();
final secondResults = await container.read(
relayDirectorySearchProvider('ali').future,
);
expect(secondResults.map((user) => user.label), ['Alina']);
expect(session.searchQueryCount, 2);
});
test('cached search families are released once unlistened', () async {
final session = _DirectoryFakeRelaySession(
profileEvents: [profile('alice', 'Alice')],
);
final container = buildContainer(session);
addTearDown(container.dispose);
final subscription = container.listen(
relayDirectorySearchProvider('ali'),
(_, _) {},
);
await container.read(relayDirectorySearchProvider('ali').future);
subscription.close();
await container.pump();
// A fresh read after disposal hits the relay again instead of reusing
// a session-lifetime cache entry.
await container.read(relayDirectorySearchProvider('ali').future);
expect(session.searchQueryCount, 2);
});
});
}
const _channelId = '11111111-1111-4111-8111-111111111111';
const _memberPubkey =
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
class _RecordingPublishRelaySession extends RelaySessionNotifier {
final publishedEvents = <NostrEvent>[];
int historyQueryCount = 0;
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<NostrEvent> publish(
NostrEvent event, {
Duration timeout = const Duration(seconds: 8),
}) async {
publishedEvents.add(event);
return event;
}
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
historyQueryCount++;
return const [];
}
}
class _FixedRelayConfigNotifier extends RelayConfigNotifier {
@override
RelayConfig build() =>
RelayConfig(baseUrl: 'https://first-community.example', nsec: null);
@override
void update({required String baseUrl, String? nsec}) {
state = RelayConfig(baseUrl: baseUrl, nsec: nsec);
}
}
class _ConnectionAwareRelaySession extends RelaySessionNotifier {
int historyQueryCount = 0;
@override
SessionState build() =>
const SessionState(status: SessionStatus.disconnected);
void connect() {
state = const SessionState(status: SessionStatus.connected);
}
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
historyQueryCount++;
return [
NostrEvent(
id: 'members',
pubkey: 'owner',
createdAt: 1,
kind: 39002,
tags: const [
['d', _channelId],
['p', _memberPubkey, 'wss://relay.example', 'admin'],
],
content: '',
sig: 'sig',
),
];
}
void setStatus(SessionStatus status) {
state = SessionState(status: status);
}
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) async => () {};
}
/// Fake [RelaySessionNotifier] that serves canned kind:0 profile events from
/// [queryRelay] and counts directory vs. search queries.
class _DirectoryFakeRelaySession extends RelaySessionNotifier {
_DirectoryFakeRelaySession({required this.profileEvents});
List<NostrEvent> profileEvents;
int directoryQueryCount = 0;
int searchQueryCount = 0;
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) async {
if (filters.any((filter) => filter.search != null)) {
searchQueryCount++;
} else {
directoryQueryCount++;
}
return profileEvents;
}
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
return const [];
}
}