Files
buzz/mobile/test/shared/community/community_provider_test.dart
Tom Brow 662014d544 fix(mobile): stale community selection during mobile invite setup (#7951)
Buzz sets up starter channels when a person accepts an invite. If the
phone already had a saved community, the first attempt could fail. Retry
could then succeed.

A delayed read could replace the new active community with the old one.
The read now leaves the saved selection unchanged. The fix also delays
channel-status updates until the channel provider finishes
initialization.

### Before and after

Controlled Flutter app captures against local relays, with the same
delayed storage read. The before capture restores the pre-fix
active-community provider in the test harness. The after capture uses
the fixed provider. These are separate from the native iOS validation
below.

| Before: first attempt fails | After: first attempt succeeds |
| --- | --- |
| ![Before: starter channel setup error with Retry setup
button](https://github.com/user-attachments/assets/14d8ba45-6e53-40ae-bc10-f219f08d2f92)
| ![After: joined community with Continue to welcome channel
button](https://github.com/user-attachments/assets/7dc32d9d-3aae-4378-913c-8ac68cc7d930)
|

### Validation
- Regression tests reproduce the delayed-read ordering and passing
timing control; removing the channel sequencing safeguards makes their
regressions fail.
- Production app against local relays passes the forced race on the
first attempt.
- Native iOS simulator with an existing saved community accepted the
original invite using a fresh identity, joined on the first attempt, and
opened the welcome channel without Retry.

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-09-28 14:44:43 -07:00

725 lines
25 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/shared/community/community.dart';
import 'package:buzz/shared/community/community_provider.dart';
import 'package:buzz/shared/community/community_storage.dart';
import 'package:buzz/shared/push/push_subscription.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'community_storage_test.dart';
void main() {
late FakeSecureStorage fakeSecure;
late CommunityStorage communityStorage;
late ProviderContainer container;
late List<List<Community>> snapshots;
late List<String> deactivatedCommunityIds;
late List<int?> deactivationGenerations;
late List<String> journaledCommunityIds;
late int revocationTriggers;
late CommunityPushLeaseRevocationTrigger revocationTrigger;
late CommunityPushLeaseDeactivator deactivator;
setUp(() {
fakeSecure = FakeSecureStorage();
communityStorage = CommunityStorage(secure: fakeSecure);
snapshots = [];
deactivatedCommunityIds = [];
deactivationGenerations = [];
journaledCommunityIds = [];
revocationTriggers = 0;
revocationTrigger = () async {
revocationTriggers += 1;
};
deactivator = (community, {generation}) async {
deactivatedCommunityIds.add(community.id);
deactivationGenerations.add(generation);
};
});
tearDown(() => container.dispose());
ProviderContainer createContainer() {
Future<void> writeSnapshot(List<Community> communities) async {
snapshots.add(List.of(communities));
}
return ProviderContainer(
overrides: [
communityStorageProvider.overrideWithValue(communityStorage),
communitySnapshotWriterProvider.overrideWithValue(writeSnapshot),
communityPushLeaseDeactivatorProvider.overrideWithValue(deactivator),
communityPushLeaseRevocationEnqueuerProvider.overrideWithValue((
community,
) async {
journaledCommunityIds.add(community.id);
return true;
}),
communityPushLeaseRevocationTriggerProvider.overrideWithValue(
revocationTrigger,
),
],
);
}
group('CommunityListNotifier', () {
test('loads empty list initially', () async {
container = createContainer();
final communities = await container.read(communityListProvider.future);
expect(communities, isEmpty);
expect(snapshots, [isEmpty]);
});
test('exports migrated communities on startup', () async {
final community = Community.create(
name: 'Migrated',
relayUrl: 'https://migrated.example.com',
nsec: nostr.Keys.generate().nsec,
);
// Seed legacy storage to exercise the same migration path as an app
// upgrade.
fakeSecure['buzz_workspaces'] = jsonEncode([community.toJson()]);
container = createContainer();
await container.read(communityListProvider.future);
expect(snapshots.single.single.id, community.id);
expect(fakeSecure['buzz_workspaces'], isNull);
});
test('skips an unchanged snapshot after provider invalidation', () async {
final community = Community.create(
name: 'Stored',
relayUrl: 'https://stored.example.com',
nsec: nostr.Keys.generate().nsec,
);
await communityStorage.save(community);
container = createContainer();
await container.read(communityListProvider.future);
container.invalidate(communityListProvider);
await container.read(communityListProvider.future);
expect(snapshots, hasLength(1));
});
test('addCommunity adds to list', () async {
container = createContainer();
await container.read(communityListProvider.future);
final ws = Community.create(
name: 'Test',
relayUrl: 'https://test.example.com',
);
await container.read(communityListProvider.notifier).addCommunity(ws);
final communities = await container.read(communityListProvider.future);
expect(communities, hasLength(1));
expect(communities.first.name, 'Test');
});
test(
'push notifications default off and opt-in survives restart',
() async {
container = createContainer();
await container.read(communityListProvider.future);
final community = Community.create(
name: 'Test',
relayUrl: 'https://test.example.com',
);
await container
.read(communityListProvider.notifier)
.addCommunity(community);
expect(
(await container.read(
communityListProvider.future,
)).single.pushNotificationsEnabled,
isFalse,
);
await container
.read(communityListProvider.notifier)
.setPushNotificationsEnabled(community.id, true);
container.dispose();
container = createContainer();
expect(
(await container.read(
communityListProvider.future,
)).single.pushNotificationsEnabled,
isTrue,
);
},
);
test(
'lease retry reserves beyond a locally unaccepted generation',
() async {
container = createContainer();
await container.read(communityListProvider.future);
final subscription = BuzzPushSubscription(
filter: BuzzPushFilter(kinds: const [9], pTags: ['a' * 64]),
notificationClass: 'default',
);
final subscriptionState = BuzzPushLeaseSubscriptionState.desired(
desired: [subscription],
).withAccepted(subscriptions: [subscription], generation: 4);
final community =
Community.create(
name: 'Test',
relayUrl: 'https://test.example.com',
).copyWith(
pushNotificationsEnabled: true,
pushSubscriptionState: subscriptionState,
);
final notifier = container.read(communityListProvider.notifier);
await notifier.addCommunity(community);
expect(await notifier.reservePushLeaseGeneration(community.id), 5);
expect(await notifier.reservePushLeaseGeneration(community.id), 6);
final stored = (await communityStorage.loadAll()).single;
expect(stored.pushSubscriptionState.acceptedGeneration, 4);
expect(stored.pushSubscriptionState.generationCursor, 6);
},
);
test('older lease success cannot regress accepted generation', () async {
container = createContainer();
await container.read(communityListProvider.future);
final subscription = BuzzPushSubscription(
filter: BuzzPushFilter(kinds: const [9], pTags: ['a' * 64]),
notificationClass: 'default',
);
final community =
Community.create(
name: 'Test',
relayUrl: 'https://test.example.com',
).copyWith(
pushNotificationsEnabled: true,
pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired(
desired: [subscription],
).withAccepted(subscriptions: [subscription], generation: 6),
);
final notifier = container.read(communityListProvider.notifier);
await notifier.addCommunity(community);
expect(
await notifier.markPushLeaseAccepted(
community.id,
subscriptions: const [],
generation: 5,
),
isFalse,
);
final stored = (await communityStorage.loadAll()).single;
expect(stored.pushSubscriptionState.acceptedGeneration, 6);
expect(
stored.pushSubscriptionState.accepted!.single.toJson(),
subscription.toJson(),
);
});
test('lease success cannot accept stale desired subscriptions', () async {
container = createContainer();
await container.read(communityListProvider.future);
final original = BuzzPushSubscription(
filter: BuzzPushFilter(kinds: const [9], pTags: ['a' * 64]),
notificationClass: 'default',
);
final replacement = BuzzPushSubscription(
filter: BuzzPushFilter(kinds: const [9], pTags: ['b' * 64]),
notificationClass: 'default',
);
final community =
Community.create(
name: 'Test',
relayUrl: 'https://test.example.com',
).copyWith(
pushNotificationsEnabled: true,
pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired(
desired: [original],
),
);
final notifier = container.read(communityListProvider.notifier);
await notifier.addCommunity(community);
final generation = await notifier.reservePushLeaseGeneration(
community.id,
);
await notifier.updateDesiredPushSubscriptions(community.id, [
replacement,
]);
expect(
await notifier.markPushLeaseAccepted(
community.id,
subscriptions: [original],
generation: generation,
),
isFalse,
);
final stored = (await communityStorage.loadAll()).single;
expect(stored.pushSubscriptionState.accepted, isNull);
expect(
stored.pushSubscriptionState.desired.single.toJson(),
replacement.toJson(),
);
});
test('opt-out tombstones an in-flight first publication', () async {
container = createContainer();
await container.read(communityListProvider.future);
final subscription = BuzzPushSubscription(
filter: BuzzPushFilter(kinds: const [9], pTags: ['a' * 64]),
notificationClass: 'default',
);
final community =
Community.create(
name: 'Test',
relayUrl: 'https://test.example.com',
).copyWith(
pushNotificationsEnabled: true,
pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired(
desired: [subscription],
),
);
final notifier = container.read(communityListProvider.notifier);
await notifier.addCommunity(community);
expect(await notifier.reservePushLeaseGeneration(community.id), 1);
await notifier.setPushNotificationsEnabled(community.id, false);
await notifier.markPushLeaseAccepted(
community.id,
subscriptions: [subscription],
generation: 1,
);
final stored = (await communityStorage.loadAll()).single;
expect(stored.pushNotificationsEnabled, isFalse);
expect(stored.pushSubscriptionState.acceptedGeneration, 2);
expect(stored.pushSubscriptionState.generationCursor, 2);
expect(stored.pushSubscriptionState.pendingTombstoneGeneration, isNull);
expect(deactivationGenerations, [2]);
});
test('opt-out persists first and publishes a higher tombstone', () async {
container = createContainer();
await container.read(communityListProvider.future);
final subscription = BuzzPushSubscription(
filter: BuzzPushFilter(kinds: const [9], pTags: ['a' * 64]),
notificationClass: 'default',
);
final community =
Community.create(
name: 'Test',
relayUrl: 'https://test.example.com',
).copyWith(
pushNotificationsEnabled: true,
pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired(
desired: [subscription],
).withAccepted(subscriptions: [subscription], generation: 7),
);
final notifier = container.read(communityListProvider.notifier);
await notifier.addCommunity(community);
await notifier.setPushNotificationsEnabled(community.id, false);
final stored = (await communityStorage.loadAll()).single;
expect(stored.pushNotificationsEnabled, isFalse);
expect(stored.pushSubscriptionState.generationCursor, 8);
expect(stored.pushSubscriptionState.acceptedGeneration, 8);
expect(stored.pushSubscriptionState.pendingTombstoneGeneration, isNull);
expect(deactivatedCommunityIds, [community.id]);
expect(deactivationGenerations, [8]);
container.dispose();
container = createContainer();
expect(
(await container.read(
communityListProvider.future,
)).single.pushNotificationsEnabled,
isFalse,
);
});
test(
'failed opt-out tombstone retries after restart at a newer generation',
() async {
var failTombstone = true;
deactivator = (community, {generation}) async {
deactivatedCommunityIds.add(community.id);
deactivationGenerations.add(generation);
if (failTombstone) {
throw StateError('injected tombstone failure');
}
};
container = createContainer();
await container.read(communityListProvider.future);
final subscription = BuzzPushSubscription(
filter: BuzzPushFilter(kinds: const [9], pTags: ['a' * 64]),
notificationClass: 'default',
);
final community =
Community.create(
name: 'Test',
relayUrl: 'https://test.example.com',
).copyWith(
pushNotificationsEnabled: true,
pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired(
desired: [subscription],
).withAccepted(subscriptions: [subscription], generation: 7),
);
await container
.read(communityListProvider.notifier)
.addCommunity(community);
await container
.read(communityListProvider.notifier)
.setPushNotificationsEnabled(community.id, false);
var stored = (await communityStorage.loadAll()).single;
expect(stored.pushNotificationsEnabled, isFalse);
expect(stored.pushSubscriptionState.acceptedGeneration, 7);
expect(stored.pushSubscriptionState.pendingTombstoneGeneration, 8);
expect(deactivationGenerations, [8]);
container.dispose();
failTombstone = false;
container = createContainer();
await container.read(communityListProvider.future);
await container
.read(communityListProvider.notifier)
.retryPendingPushLeaseTombstone(
community.id,
advanceGeneration: true,
);
stored = (await communityStorage.loadAll()).single;
expect(stored.pushNotificationsEnabled, isFalse);
expect(stored.pushSubscriptionState.acceptedGeneration, 9);
expect(stored.pushSubscriptionState.generationCursor, 9);
expect(stored.pushSubscriptionState.pendingTombstoneGeneration, isNull);
expect(deactivationGenerations, [8, 9]);
},
);
test('removeCommunity removes from list', () async {
container = createContainer();
await container.read(communityListProvider.future);
final ws = Community.create(
name: 'Test',
relayUrl: 'https://test.example.com',
);
await container.read(communityListProvider.notifier).addCommunity(ws);
await container
.read(communityListProvider.notifier)
.removeCommunity(ws.id);
final communities = await container.read(communityListProvider.future);
expect(communities, isEmpty);
expect(journaledCommunityIds, [ws.id]);
expect(deactivatedCommunityIds, isEmpty);
expect(revocationTriggers, 1);
});
test('remote tombstone attempt cannot block local removal', () async {
final remoteAttempt = Completer<void>();
revocationTrigger = () {
revocationTriggers += 1;
return remoteAttempt.future;
};
container = createContainer();
await container.read(communityListProvider.future);
final community = Community.create(
name: 'Test',
relayUrl: 'https://test.example.com',
);
final notifier = container.read(communityListProvider.notifier);
await notifier.addCommunity(community);
await notifier.removeCommunity(community.id);
expect(await communityStorage.loadAll(), isEmpty);
expect(journaledCommunityIds, [community.id]);
expect(revocationTriggers, 1);
remoteAttempt.complete();
});
test('journal persistence failure keeps community credentials', () async {
container = ProviderContainer(
overrides: [
communityStorageProvider.overrideWithValue(communityStorage),
communitySnapshotWriterProvider.overrideWithValue((_) async {}),
communityPushLeaseRevocationEnqueuerProvider.overrideWithValue((
_,
) async {
throw StateError('secure storage unavailable');
}),
communityPushLeaseRevocationTriggerProvider.overrideWithValue(
() async {},
),
],
);
await container.read(communityListProvider.future);
final community = Community.create(
name: 'Test',
relayUrl: 'https://test.example.com',
);
final notifier = container.read(communityListProvider.notifier);
await notifier.addCommunity(community);
await expectLater(
notifier.removeCommunity(community.id),
throwsStateError,
);
expect(
(await communityStorage.loadAll()).map((item) => item.id),
contains(community.id),
);
});
test(
'waits for transition teardown before removing active community',
() async {
container = createContainer();
await container.read(communityListProvider.future);
final ws1 = Community.create(
name: 'One',
relayUrl: 'https://one.example.com',
);
final ws2 = Community.create(
name: 'Two',
relayUrl: 'https://two.example.com',
);
final notifier = container.read(communityListProvider.notifier);
await notifier.addCommunity(ws1);
await notifier.addCommunity(ws2);
await notifier.switchCommunity(ws1.id);
final teardown = Completer<void>();
container
.read(communityTransitionProvider)
.register(() => teardown.future);
final removing = notifier.removeCommunity(ws1.id);
await Future<void>.delayed(Duration.zero);
expect(await communityStorage.loadActiveId(), ws1.id);
expect(
(await communityStorage.loadAll()).map((community) => community.id),
contains(ws1.id),
);
teardown.complete();
await removing;
expect(await communityStorage.loadActiveId(), ws2.id);
expect(
(await communityStorage.loadAll()).map((community) => community.id),
isNot(contains(ws1.id)),
);
},
);
test('renameCommunity updates name', () async {
container = createContainer();
await container.read(communityListProvider.future);
final ws = Community.create(
name: 'Original',
relayUrl: 'https://test.example.com',
);
await container.read(communityListProvider.notifier).addCommunity(ws);
await container
.read(communityListProvider.notifier)
.renameCommunity(ws.id, 'Renamed');
final communities = await container.read(communityListProvider.future);
expect(communities.first.name, 'Renamed');
});
test('waits for transition teardown before updating active ID', () async {
container = createContainer();
await container.read(communityListProvider.future);
final ws1 = Community.create(
name: 'One',
relayUrl: 'https://one.example.com',
);
final ws2 = Community.create(
name: 'Two',
relayUrl: 'https://two.example.com',
);
final notifier = container.read(communityListProvider.notifier);
await notifier.addCommunity(ws1);
await notifier.addCommunity(ws2);
await notifier.switchCommunity(ws1.id);
final teardown = Completer<void>();
container
.read(communityTransitionProvider)
.register(() => teardown.future);
final switching = notifier.switchCommunity(ws2.id);
await Future<void>.delayed(Duration.zero);
expect(await communityStorage.loadActiveId(), ws1.id);
teardown.complete();
await switching;
expect(await communityStorage.loadActiveId(), ws2.id);
});
test('overlapping transitions share one callback run', () async {
container = createContainer();
final coordinator = container.read(communityTransitionProvider);
final teardown = Completer<void>();
var calls = 0;
coordinator.register(() {
calls++;
return teardown.future;
});
var firstCompleted = false;
var secondCompleted = false;
final first = coordinator.run().then((_) => firstCompleted = true);
final second = coordinator.run().then((_) => secondCompleted = true);
await Future<void>.delayed(Duration.zero);
expect(calls, 1);
expect(firstCompleted, isFalse);
expect(secondCompleted, isFalse);
teardown.complete();
await Future.wait([first, second]);
expect(firstCompleted, isTrue);
expect(secondCompleted, isTrue);
});
test('a later transition starts a new callback run', () async {
container = createContainer();
final coordinator = container.read(communityTransitionProvider);
var calls = 0;
coordinator.register(() async => calls++);
await coordinator.run();
await coordinator.run();
expect(calls, 2);
});
test(
'transition callback failure does not block the active ID update',
() async {
container = createContainer();
await container.read(communityListProvider.future);
final ws1 = Community.create(
name: 'One',
relayUrl: 'https://one.example.com',
);
final ws2 = Community.create(
name: 'Two',
relayUrl: 'https://two.example.com',
);
final notifier = container.read(communityListProvider.notifier);
await notifier.addCommunity(ws1);
await notifier.addCommunity(ws2);
await notifier.switchCommunity(ws1.id);
container.read(communityTransitionProvider).register(() async {
throw StateError('native cleanup failed');
});
await notifier.switchCommunity(ws2.id);
expect(await communityStorage.loadActiveId(), ws2.id);
},
);
test('switchCommunity updates active ID', () async {
container = createContainer();
await container.read(communityListProvider.future);
final ws1 = Community.create(
name: 'One',
relayUrl: 'https://one.example.com',
);
final ws2 = Community.create(
name: 'Two',
relayUrl: 'https://two.example.com',
);
final notifier = container.read(communityListProvider.notifier);
await notifier.addCommunity(ws1);
await notifier.addCommunity(ws2);
await notifier.switchCommunity(ws2.id);
final activeId = await communityStorage.loadActiveId();
expect(activeId, ws2.id);
});
});
group('activeCommunityProvider', () {
test('returns null when no communities', () async {
container = createContainer();
final active = await container.read(activeCommunityProvider.future);
expect(active, isNull);
});
test('returns community matching active ID', () async {
container = createContainer();
await container.read(communityListProvider.future);
final ws = Community.create(
name: 'Test',
relayUrl: 'https://test.example.com',
);
final notifier = container.read(communityListProvider.notifier);
await notifier.addCommunity(ws);
await notifier.switchCommunity(ws.id);
final active = await container.read(activeCommunityProvider.future);
expect(active, isNotNull);
expect(active!.id, ws.id);
expect(active.name, 'Test');
});
test('falls back without persisting when active ID is missing', () async {
final community = Community.create(
name: 'Fallback',
relayUrl: 'https://test.example.com',
);
await communityStorage.save(community);
container = createContainer();
final active = await container.read(activeCommunityProvider.future);
expect(active?.id, community.id);
expect(await communityStorage.loadActiveId(), isNull);
});
test('falls back to first community if active ID is invalid', () async {
container = createContainer();
await container.read(communityListProvider.future);
final ws = Community.create(
name: 'Fallback',
relayUrl: 'https://test.example.com',
);
final notifier = container.read(communityListProvider.notifier);
await notifier.addCommunity(ws);
// Set an invalid active ID.
await communityStorage.saveActiveId('nonexistent-id');
// Re-read — should fall back.
container.invalidate(activeCommunityProvider);
final active = await container.read(activeCommunityProvider.future);
expect(active, isNotNull);
expect(active!.id, ws.id);
expect(await communityStorage.loadActiveId(), 'nonexistent-id');
});
});
}