Files
buzz/mobile/test/features/channels/deep_link_dispatcher_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

770 lines
22 KiB
Dart

import 'dart:async';
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/features/channels/deep_link_dispatcher.dart';
import 'package:buzz/features/invites/invite_join_provider.dart';
import 'package:buzz/shared/auth/auth.dart';
import 'package:buzz/shared/deeplink/deep_link.dart';
import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/community/community_storage_test.dart';
void main() {
testWidgets('queues deep links in arrival order until acknowledged', (
tester,
) async {
final controller = StreamController<Uri>();
PendingDeepLinkNotifier.debugUriStreamOverride = controller.stream;
addTearDown(() async {
PendingDeepLinkNotifier.debugUriStreamOverride = null;
await controller.close();
});
final container = ProviderContainer();
addTearDown(container.dispose);
container.read(pendingDeepLinkProvider);
const channelId = '580ca78b-9dae-46f3-8854-bd671853ba32';
final firstId = 'aa' * 32;
final secondId = 'bb' * 32;
controller
..add(Uri.parse('buzz://message?channel=$channelId&id=$firstId'))
..add(Uri.parse('buzz://message?channel=$channelId&id=$secondId'));
await tester.pump();
expect(
container.read(pendingDeepLinkProvider),
MessageDeepLink(channelId: channelId, messageId: firstId),
);
container.read(pendingDeepLinkProvider.notifier).consume();
expect(
container.read(pendingDeepLinkProvider),
MessageDeepLink(channelId: channelId, messageId: secondId),
);
container.read(pendingDeepLinkProvider.notifier).consume();
expect(container.read(pendingDeepLinkProvider), isNull);
});
testWidgets('drops a missing channel and dispatches the next queued link', (
tester,
) async {
const missing = ChannelDeepLink(channelId: 'missing-channel');
const next = ChannelDeepLink(channelId: 'channel-1');
final pending = _QueuedPendingDeepLinkNotifier([missing, next]);
await tester.pumpWidget(
ProviderScope(
overrides: [
pendingDeepLinkProvider.overrideWith(() => pending),
channelsProvider.overrideWith(
() => _FakeChannelsNotifier(Future.value([_channel])),
),
],
child: MaterialApp(
home: DeepLinkDispatcher(
destinationBuilder: (channel, link) =>
_CapturedDestination(channel: channel, link: link),
child: const Scaffold(body: SizedBox()),
),
),
),
);
await tester.pumpAndSettle();
expect(pending.consumeCalls, 2);
final destination = tester.widget<_CapturedDestination>(
find.byType(_CapturedDestination),
);
expect(destination.channel.id, 'channel-1');
expect(destination.link, same(next));
});
testWidgets('dispatches a link that is already ready on mount', (
tester,
) async {
const link = MessageDeepLink(
channelId: 'channel-1',
messageId: 'message-2',
threadRootId: 'message-1',
);
await tester.pumpWidget(
ProviderScope(
overrides: [
pendingDeepLinkProvider.overrideWith(
() => _FakePendingDeepLinkNotifier(link),
),
channelsProvider.overrideWith(
() => _FakeChannelsNotifier(Future.value([_channel])),
),
],
child: MaterialApp(
home: DeepLinkDispatcher(
destinationBuilder: (channel, link) =>
_CapturedDestination(channel: channel, link: link),
child: const Scaffold(body: SizedBox()),
),
),
),
);
await tester.pumpAndSettle();
final destination = tester.widget<_CapturedDestination>(
find.byType(_CapturedDestination),
);
final messageLink = destination.link as MessageDeepLink;
expect(destination.channel.id, 'channel-1');
expect(messageLink.messageId, 'message-2');
expect(messageLink.threadRootId, 'message-1');
});
testWidgets('dispatches a channel-only link to the channel root', (
tester,
) async {
const link = ChannelDeepLink(channelId: 'channel-1');
await tester.pumpWidget(
ProviderScope(
overrides: [
pendingDeepLinkProvider.overrideWith(
() => _FakePendingDeepLinkNotifier(link),
),
channelsProvider.overrideWith(
() => _FakeChannelsNotifier(Future.value([_channel])),
),
],
child: MaterialApp(
home: DeepLinkDispatcher(
destinationBuilder: (channel, link) =>
_CapturedDestination(channel: channel, link: link),
child: const Scaffold(body: SizedBox()),
),
),
),
);
await tester.pumpAndSettle();
final destination = tester.widget<_CapturedDestination>(
find.byType(_CapturedDestination),
);
expect(destination.channel.id, 'channel-1');
expect(destination.link, same(link));
});
testWidgets('retains invite and surfaces prepare failure', (tester) async {
const link = InviteDeepLink(
relayUrl: 'wss://relay.example.com',
code: 'invite-code',
);
final container = ProviderContainer(
overrides: [
communityStorageProvider.overrideWithValue(_ThrowingCommunityStorage()),
pendingDeepLinkProvider.overrideWith(
() => _FakePendingDeepLinkNotifier(link),
),
],
);
addTearDown(container.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const MaterialApp(
home: DeepLinkDispatcher(child: Scaffold(body: SizedBox())),
),
),
);
await tester.pumpAndSettle();
expect(container.read(pendingDeepLinkProvider), same(link));
expect(container.read(inviteJoinProvider).status, InviteJoinStatus.idle);
expect(
find.text(
'Could not open this invite. Re-open the invite link to try again.',
),
findsOneWidget,
);
expect(tester.takeException(), isNull);
});
testWidgets('prepares an invite once while listeners re-enter', (
tester,
) async {
const link = InviteDeepLink(
relayUrl: 'wss://relay.example.com',
code: 'invite-code',
);
final storage = _CountingCommunityStorage();
final pending = _RecordingPendingDeepLinkNotifier(link);
final container = ProviderContainer(
overrides: [
communityStorageProvider.overrideWithValue(storage),
pendingDeepLinkProvider.overrideWith(() => pending),
channelsProvider.overrideWith(
() => _FakeChannelsNotifier(Future.value([_channel])),
),
],
);
addTearDown(container.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const MaterialApp(
home: DeepLinkDispatcher(child: Scaffold(body: SizedBox())),
),
),
);
await tester.pumpAndSettle();
expect(storage.loadCalls, 1);
expect(pending.consumeCalls, 1);
expect(find.text('Join this Buzz community?'), findsOneWidget);
});
testWidgets('opens retry setup after durable starter recovery fails', (
tester,
) async {
const link = InviteDeepLink(
relayUrl: 'wss://relay.example.com',
code: 'invite-code',
);
final container = ProviderContainer(
overrides: [
pendingDeepLinkProvider.overrideWith(
() => _FakePendingDeepLinkNotifier(link),
),
inviteJoinProvider.overrideWith(
_FailedStarterRecoveryInviteJoinNotifier.new,
),
],
);
addTearDown(container.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const MaterialApp(
home: DeepLinkDispatcher(child: Scaffold(body: SizedBox())),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Finish setting up'), findsOneWidget);
expect(find.text('Retry setup'), findsOneWidget);
expect(find.text('Starter setup failed'), findsOneWidget);
});
testWidgets(
'shows saved starter recovery progress, then opens welcome-everyone',
(tester) async {
const relayUrl = 'wss://relay.example.com';
const welcomeId = 'welcome-everyone-id';
final storage = CommunityStorage(secure: FakeSecureStorage());
await storage.save(
Community(
id: 'incomplete-community',
name: 'Relay',
relayUrl: relayUrl,
pubkey: 'pubkey',
nsec: 'nsec',
addedAt: DateTime.utc(2026),
starterSetupIncomplete: true,
),
);
final recovery = _DeferredInviteJoinRecovery();
final container = ProviderContainer(
overrides: [
communityStorageProvider.overrideWithValue(storage),
pendingDeepLinkProvider.overrideWith(
() => _FakePendingDeepLinkNotifier(
const InviteDeepLink(relayUrl: relayUrl, code: 'invite-code'),
),
),
inviteJoinRecoveryProvider.overrideWithValue((_) => recovery),
channelsProvider.overrideWith(
() =>
_FakeChannelsNotifier(Future.value([_welcomeEveryoneChannel])),
),
],
);
addTearDown(container.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: MaterialApp(
home: DeepLinkDispatcher(
destinationBuilder: (channel, link) =>
_CapturedDestination(channel: channel, link: link),
child: const Scaffold(body: SizedBox()),
),
),
),
);
await tester.pump();
await tester.pump();
await recovery.started.future;
expect(find.text('Finish setting up'), findsOneWidget);
expect(find.text('Finishing setup…'), findsOneWidget);
expect(
tester
.widget<FilledButton>(
find.widgetWithText(FilledButton, 'Finishing setup…'),
)
.onPressed,
isNull,
);
recovery.complete(welcomeId);
await tester.pumpAndSettle();
expect(find.text('Continue to #welcome-everyone'), findsOneWidget);
await tester.tap(find.text('Continue to #welcome-everyone'));
await tester.pumpAndSettle();
final destination = tester.widget<_CapturedDestination>(
find.byType(_CapturedDestination),
);
expect(destination.link, const ChannelDeepLink(channelId: welcomeId));
},
);
testWidgets('renders setup-specific recovery failures after membership', (
tester,
) async {
const relayUrl = 'wss://relay.example.com';
final storage = CommunityStorage(secure: FakeSecureStorage());
await storage.save(
Community(
id: 'incomplete-community',
name: 'Relay',
relayUrl: relayUrl,
pubkey: 'pubkey',
nsec: 'nsec',
addedAt: DateTime.utc(2026),
starterSetupIncomplete: true,
),
);
final recovery = _DeferredInviteJoinRecovery();
final container = ProviderContainer(
overrides: [
communityStorageProvider.overrideWithValue(storage),
pendingDeepLinkProvider.overrideWith(
() => _FakePendingDeepLinkNotifier(
const InviteDeepLink(relayUrl: relayUrl, code: 'invite-code'),
),
),
inviteJoinRecoveryProvider.overrideWithValue((_) => recovery),
],
);
addTearDown(container.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const MaterialApp(
home: DeepLinkDispatcher(child: Scaffold(body: SizedBox())),
),
),
);
await tester.pump();
await tester.pump();
await recovery.started.future;
recovery.completeError(Exception('relay did not respond'));
await tester.pumpAndSettle();
expect(
find.text(
'Starter channels could not be set up. Retry setup to try again.',
),
findsOneWidget,
);
expect(find.text('Retry setup'), findsOneWidget);
expect(find.textContaining('Could not join this community'), findsNothing);
});
testWidgets('waits for an invite modal before preparing the next invite', (
tester,
) async {
const first = InviteDeepLink(
relayUrl: 'wss://relay.example.com',
code: 'invite-one',
);
const second = InviteDeepLink(
relayUrl: 'wss://relay.example.com',
code: 'invite-two',
);
final pending = _QueuedPendingDeepLinkNotifier([first, second]);
final container = ProviderContainer(
overrides: [
communityStorageProvider.overrideWithValue(
CommunityStorage(secure: FakeSecureStorage()),
),
pendingDeepLinkProvider.overrideWith(() => pending),
],
);
addTearDown(container.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const MaterialApp(
home: DeepLinkDispatcher(child: Scaffold(body: SizedBox())),
),
),
);
await tester.pumpAndSettle();
expect(pending.consumeCalls, 1);
expect(pending.current, same(second));
expect(container.read(inviteJoinProvider).invite, same(first));
expect(find.text('Join this Buzz community?'), findsOneWidget);
await tester.tap(find.widgetWithText(OutlinedButton, 'Cancel'));
await tester.pumpAndSettle();
expect(pending.consumeCalls, 2);
expect(pending.current, isNull);
expect(container.read(inviteJoinProvider).invite, same(second));
expect(find.text('Join this Buzz community?'), findsOneWidget);
});
testWidgets('dispatches a queued channel after preparing an invite', (
tester,
) async {
const invite = InviteDeepLink(
relayUrl: 'wss://relay.example.com',
code: 'invite-code',
);
const channelLink = ChannelDeepLink(channelId: 'channel-1');
final pending = _QueuedPendingDeepLinkNotifier([invite, channelLink]);
await tester.pumpWidget(
ProviderScope(
overrides: [
communityStorageProvider.overrideWithValue(
CommunityStorage(secure: FakeSecureStorage()),
),
pendingDeepLinkProvider.overrideWith(() => pending),
channelsProvider.overrideWith(
() => _FakeChannelsNotifier(Future.value([_channel])),
),
],
child: MaterialApp(
home: DeepLinkDispatcher(
destinationBuilder: (channel, link) =>
_CapturedDestination(channel: channel, link: link),
child: const Scaffold(body: SizedBox()),
),
),
),
);
await tester.pumpAndSettle();
expect(pending.consumeCalls, 1);
expect(pending.current, same(channelLink));
expect(find.text('Join this Buzz community?'), findsOneWidget);
expect(find.byType(_CapturedDestination), findsNothing);
await tester.tap(find.widgetWithText(OutlinedButton, 'Cancel'));
await tester.pumpAndSettle();
expect(pending.consumeCalls, 2);
expect(pending.current, isNull);
final destination = tester.widget<_CapturedDestination>(
find.byType(_CapturedDestination),
);
expect(destination.link, same(channelLink));
});
testWidgets(
'dispatches invite before auth while leaving message links parked',
(tester) async {
final inviteStorage = CommunityStorage(secure: FakeSecureStorage());
final inviteContainer = ProviderContainer(
overrides: [
communityStorageProvider.overrideWithValue(inviteStorage),
pendingDeepLinkProvider.overrideWith(
() => _FakePendingDeepLinkNotifier(
const InviteDeepLink(
relayUrl: 'wss://relay.example.com',
code: 'invite-code',
),
),
),
],
);
addTearDown(inviteContainer.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: inviteContainer,
child: const MaterialApp(
home: DeepLinkDispatcher(
dispatchMessageLinks: false,
child: Scaffold(body: Text('Pairing')),
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Join this Buzz community?'), findsOneWidget);
expect(inviteContainer.read(pendingDeepLinkProvider), isNull);
final messageContainer = ProviderContainer(
overrides: [
pendingDeepLinkProvider.overrideWith(
() => _FakePendingDeepLinkNotifier(
const MessageDeepLink(
channelId: 'channel-1',
messageId: 'message-1',
),
),
),
],
);
addTearDown(messageContainer.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: messageContainer,
child: const MaterialApp(
home: DeepLinkDispatcher(
dispatchMessageLinks: false,
child: Scaffold(body: Text('Pairing')),
),
),
),
);
await tester.pump();
expect(
messageContainer.read(pendingDeepLinkProvider),
isA<MessageDeepLink>(),
);
expect(find.text('Pairing'), findsOneWidget);
},
);
testWidgets('continues a successful invite into welcome-everyone', (
tester,
) async {
const invite = InviteDeepLink(
relayUrl: 'wss://relay.example.com',
code: 'invite-code',
);
final container = ProviderContainer(
overrides: [
pendingDeepLinkProvider.overrideWith(
() => _FakePendingDeepLinkNotifier(invite),
),
channelsProvider.overrideWith(
() => _FakeChannelsNotifier(Future.value([_welcomeEveryoneChannel])),
),
inviteJoinProvider.overrideWith(_SuccessfulInviteJoinNotifier.new),
],
);
addTearDown(container.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: MaterialApp(
home: DeepLinkDispatcher(
destinationBuilder: (channel, link) =>
_CapturedDestination(channel: channel, link: link),
child: const Scaffold(body: SizedBox()),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Join'));
await tester.pumpAndSettle();
expect(find.text('Continue to #welcome-everyone'), findsOneWidget);
await tester.tap(find.text('Continue to #welcome-everyone'));
await tester.pumpAndSettle();
final destination = tester.widget<_CapturedDestination>(
find.byType(_CapturedDestination),
);
expect(destination.channel.id, 'welcome-everyone-id');
expect(
destination.link,
const ChannelDeepLink(channelId: 'welcome-everyone-id'),
);
});
}
final _channel = Channel(
id: 'channel-1',
name: 'general',
channelType: 'stream',
visibility: 'open',
description: 'General discussion',
createdBy: 'creator',
createdAt: DateTime(2026),
memberCount: 2,
isMember: true,
);
final _welcomeEveryoneChannel = Channel(
id: 'welcome-everyone-id',
name: 'welcome-everyone',
channelType: 'stream',
visibility: 'open',
description: 'Say hi',
createdBy: 'creator',
createdAt: DateTime(2026),
memberCount: 1,
isMember: true,
);
class _SuccessfulInviteJoinNotifier extends InviteJoinNotifier {
@override
InviteJoinState build() => const InviteJoinState();
@override
Future<void> prepare(InviteDeepLink invite) async {
state = InviteJoinState(
status: InviteJoinStatus.confirming,
invite: invite,
host: 'relay.example.com',
communityName: 'Example',
);
}
@override
Future<void> confirmJoin() async {
state = state.copyWith(
status: InviteJoinStatus.success,
focusChannelId: 'welcome-everyone-id',
);
}
}
class _FailedStarterRecoveryInviteJoinNotifier extends InviteJoinNotifier {
@override
InviteJoinState build() => const InviteJoinState();
@override
Future<void> prepare(InviteDeepLink invite) async {
state = InviteJoinState(
status: InviteJoinStatus.error,
invite: invite,
host: 'relay.example.com',
communityName: 'Relay',
errorMessage: 'Starter setup failed',
isStarterSetupRecovery: true,
);
}
}
class _CountingCommunityStorage extends CommunityStorage {
int loadCalls = 0;
@override
Future<List<Community>> loadAll() async {
loadCalls++;
return [];
}
}
class _ThrowingCommunityStorage extends CommunityStorage {
@override
Future<List<Community>> loadAll() async {
throw StateError('secure storage unavailable');
}
}
class _QueuedPendingDeepLinkNotifier extends PendingDeepLinkNotifier {
_QueuedPendingDeepLinkNotifier(List<BuzzDeepLink> links)
: _links = List.of(links);
final List<BuzzDeepLink> _links;
int consumeCalls = 0;
BuzzDeepLink? get _firstOrNull => _links.isEmpty ? null : _links.first;
BuzzDeepLink? get current => _firstOrNull;
@override
BuzzDeepLink? build() => _firstOrNull;
@override
void consume() {
consumeCalls++;
_links.removeAt(0);
state = _firstOrNull;
}
}
class _RecordingPendingDeepLinkNotifier extends PendingDeepLinkNotifier {
_RecordingPendingDeepLinkNotifier(this.link);
final BuzzDeepLink link;
int consumeCalls = 0;
@override
BuzzDeepLink? build() => link;
@override
void consume() {
consumeCalls++;
super.consume();
}
}
class _FakePendingDeepLinkNotifier extends PendingDeepLinkNotifier {
_FakePendingDeepLinkNotifier(this.link);
final BuzzDeepLink link;
@override
BuzzDeepLink? build() => link;
}
class _FakeChannelsNotifier extends ChannelsNotifier {
_FakeChannelsNotifier(this.channels);
final Future<List<Channel>> channels;
@override
Future<List<Channel>> build() => channels;
}
class _DeferredInviteJoinRecovery implements InviteJoinRecovery {
final Completer<String?> _result = Completer<String?>();
final Completer<void> started = Completer<void>();
@override
Future<String?> ensureStarterChannels() {
started.complete();
return _result.future;
}
void complete(String? focusChannelId) => _result.complete(focusChannelId);
void completeError(Object error) => _result.completeError(error);
}
class _CapturedDestination extends StatelessWidget {
const _CapturedDestination({required this.channel, required this.link});
final Channel channel;
final BuzzDeepLink link;
@override
Widget build(BuildContext context) => const SizedBox();
}