Files
buzz/mobile/test/features/channels/channels_provider_test.dart
T
cls 9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

731 lines
24 KiB
Dart

import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
/// Tests for [ChannelsNotifier] in the pure-Nostr world.
///
/// The provider performs a two-step WS query:
/// 1. kind:39002 memberships tagged `#p:<my-pubkey>`
/// 2. kind:39000 metadata for those channel ids
/// then layers per-channel live subscriptions on the `#h` tag.
///
/// Tests stub out the relay session by overriding [relaySessionProvider] with
/// a [_FakeRelaySession] that returns canned events from [fetchHistory] and
/// records [subscribe] calls so we can assert filter shapes and emit live
/// events on demand.
void main() {
const myPk = 'me';
test(
'subscribes per-channel with #h tags (only joined, non-archived)',
() async {
final session = _FakeRelaySession(
memberships: [
_membership(_channelA, myPk),
_membership(_channelB, myPk),
_membership(_channelD, myPk),
],
metadata: [
_meta(id: _channelA, name: 'general'),
_meta(id: _channelB, name: 'random'),
// channelD metadata missing -> won't appear in channel list
],
);
final container = _buildContainer(session: session);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
// One subscription per joined, non-archived channel.
expect(session.subscribeFilters, hasLength(2));
expect(
session.subscribeFilters.map((f) => f.tags['#h']?.single).toSet(),
{_channelA, _channelB},
);
for (final filter in session.subscribeFilters) {
expect(filter.kinds, EventKind.channelEventKinds);
expect(filter.limit, 0);
}
},
);
test(
'refreshing an unchanged channel set issues zero new live REQs',
() async {
final session = _FakeRelaySession(
memberships: [
_membership(_channelA, myPk),
_membership(_channelB, myPk),
],
metadata: [
_meta(id: _channelA, name: 'general'),
_meta(id: _channelB, name: 'random'),
],
);
final container = _buildContainer(session: session);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
final initialSubscribeCount = session.totalSubscribeCount;
await container.read(channelsProvider.notifier).refresh();
expect(session.totalSubscribeCount, initialSubscribeCount);
expect(session.unsubscribeCount, 0);
expect(session.subscribeFilters, hasLength(2));
},
);
test(
'live subscription diff only removes and adds changed channels',
() async {
final session = _FakeRelaySession(
memberships: [
_membership(_channelA, myPk),
_membership(_channelB, myPk),
],
metadata: [
_meta(id: _channelA, name: 'general'),
_meta(id: _channelB, name: 'random'),
],
);
final container = _buildContainer(session: session);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
session.memberships = [
_membership(_channelB, myPk),
_membership(_channelD, myPk),
];
session.metadata = [
_meta(id: _channelB, name: 'random'),
_meta(id: _channelD, name: 'support'),
];
await container.read(channelsProvider.notifier).refresh();
expect(session.totalSubscribeCount, 3);
expect(session.unsubscribeCount, 1);
expect(
session.subscribeFilters
.map((filter) => filter.tags['#h']!.single)
.toSet(),
{_channelB, _channelD},
);
},
);
test(
'empty channel refresh removes every retained live subscription',
() async {
final session = _FakeRelaySession(
memberships: [
_membership(_channelA, myPk),
_membership(_channelB, myPk),
],
metadata: [
_meta(id: _channelA, name: 'general'),
_meta(id: _channelB, name: 'random'),
],
);
final container = _buildContainer(session: session);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
session.memberships = [];
session.metadata = [];
await container.read(channelsProvider.notifier).refresh();
expect(session.activeChannels, isEmpty);
expect(session.activeSubscriptionCount, 0);
expect(session.unsubscribeCount, 2);
},
);
test(
'overlapping refreshes retain one live subscription per desired channel',
() async {
final session = _FakeRelaySession(
memberships: [
_membership(_channelA, myPk),
_membership(_channelB, myPk),
],
metadata: [
_meta(id: _channelA, name: 'general'),
_meta(id: _channelB, name: 'random'),
],
);
final container = _buildContainer(session: session);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
session.pauseNextSubscribe();
session.memberships = [
_membership(_channelA, myPk),
_membership(_channelB, myPk),
_membership(_channelD, myPk),
];
session.metadata = [
_meta(id: _channelA, name: 'general'),
_meta(id: _channelB, name: 'random'),
_meta(id: _channelD, name: 'support'),
];
final firstRefresh = container.read(channelsProvider.notifier).refresh();
await session.nextSubscribeStarted;
final secondRefresh = container.read(channelsProvider.notifier).refresh();
session.resumePausedSubscribe();
await Future.wait([firstRefresh, secondRefresh]);
expect(session.activeChannels, {_channelA, _channelB, _channelD});
expect(session.activeSubscriptionCount, 3);
},
);
test(
'community switch replaces retained live subscriptions on the new relay',
() async {
final session = _FakeRelaySession(
memberships: [_membership(_channelA, myPk)],
metadata: [_meta(id: _channelA, name: 'general')],
);
final container = _buildContainer(session: session);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
expect(session.activeChannels, {_channelA});
session.setStatus(SessionStatus.disconnected);
session.memberships = [_membership(_channelB, myPk)];
session.metadata = [_meta(id: _channelB, name: 'random')];
container
.read(relayConfigProvider.notifier)
.update(baseUrl: 'https://new-community.example');
await Future<void>.delayed(Duration.zero);
session.setStatus(SessionStatus.connected);
await container.read(channelsProvider.future);
await _waitUntil(
() =>
session.activeChannels.length == 1 &&
session.activeChannels.contains(_channelB),
);
expect(session.activeChannels, {_channelB});
expect(session.activeSubscriptionCount, 1);
expect(session.unsubscribeCount, 1);
},
);
test('live channel events update channel lastMessageAt', () async {
final session = _FakeRelaySession(
memberships: [_membership(_channelA, myPk)],
metadata: [_meta(id: _channelA, name: 'general', createdAt: 10)],
);
final container = _buildContainer(session: session);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
// Emit a live message event on channelA.
session.emit(
NostrEvent(
id: 'event-1',
pubkey: 'alice',
createdAt: 20,
kind: EventKind.streamMessageV2,
tags: const [
['h', _channelA],
],
content: 'new message',
sig: 'sig',
),
);
final channels = container.read(channelsProvider).value!;
expect(channels.single.lastMessageAt?.millisecondsSinceEpoch, 20 * 1000);
});
test('ephemeral (TTL) channels appear in the list', () async {
// Regression: previously the provider unconditionally dropped any channel
// with a `ttl` tag, which made TTL channels invisible on iOS even when the
// user was a member. They should be included so the existing
// `_EphemeralBadge` UI in `channels_page.dart` can render them.
final session = _FakeRelaySession(
memberships: [_membership(_channelA, myPk), _membership(_channelB, myPk)],
metadata: [
_meta(id: _channelA, name: 'general'),
_meta(
id: _channelB,
name: 'agent-creation-deep-dive',
ttlSeconds: 86400,
),
],
);
final container = _buildContainer(session: session);
addTearDown(container.dispose);
final channels = await container.read(channelsProvider.future);
expect(
channels.map((c) => c.name),
containsAll(['general', 'agent-creation-deep-dive']),
);
final ephemeral = channels.firstWhere(
(c) => c.name == 'agent-creation-deep-dive',
);
expect(ephemeral.isEphemeral, isTrue);
expect(ephemeral.ttlSeconds, 86400);
});
test('hidden DMs are filtered from the channel list', () async {
final session = _FakeRelaySession(
memberships: [_membership(_channelA, myPk), _membership(_channelB, myPk)],
metadata: [
_meta(id: _channelA, name: 'Alice', channelType: 'dm'),
_meta(id: _channelB, name: 'Bob', channelType: 'dm'),
],
hiddenDmEvents: [
_hiddenDms([_channelA], pubkey: myPk),
],
);
final container = _buildContainer(session: session);
addTearDown(container.dispose);
final channels = await container.read(channelsProvider.future);
expect(channels.map((c) => c.id), [_channelB]);
expect(
session.historyFilters.any(
(filter) =>
filter.kinds.contains(EventKind.dmVisibility) &&
filter.tags['#p']?.single == myPk,
),
isTrue,
);
});
test(
'archived kind:39000 metadata sets Channel.isArchived (covers TTL auto-archive)',
() async {
// The relay's TTL reaper auto-archives expired ephemeral channels and
// republishes kind:39000 with `["archived", "true"]`. The Channel needs
// `archivedAt != null` so the `_SliverChannelsList` filter
// (`!channel.isArchived`) hides it from the sidebar after expiry.
// Previously the mobile parser ignored the `archived` tag, so expired
// TTL channels would have stayed visible after the `!isEphemeral` guard
// was removed.
final session = _FakeRelaySession(
memberships: [
_membership(_channelA, myPk),
_membership(_channelB, myPk),
],
metadata: [
_meta(id: _channelA, name: 'active'),
_meta(
id: _channelB,
name: 'expired-ttl',
ttlSeconds: 86400,
archived: true,
),
],
);
final container = _buildContainer(session: session);
addTearDown(container.dispose);
final channels = await container.read(channelsProvider.future);
final expired = channels.firstWhere((c) => c.name == 'expired-ttl');
expect(expired.isArchived, isTrue);
expect(expired.isEphemeral, isTrue);
// The active channel must not be flagged archived.
final active = channels.firstWhere((c) => c.name == 'active');
expect(active.isArchived, isFalse);
},
);
test(
'archive transition invalidates cached channelDetailsProvider',
() async {
// Codex review v2 caught: if a TTL channel is opened (caching its
// ChannelDetails) and then the reaper archives it, the cached details
// — built from the pre-archive kind:39000 — would clobber the newer
// archivedAt set on the base Channel during `mergeDetails`. We invalidate
// the details provider when the archived state flips so the next
// mergeDetails sees fresh data.
final session = _FakeRelaySession(
memberships: [_membership(_channelA, myPk)],
metadata: [_meta(id: _channelA, name: 'active')],
);
final container = _buildContainer(session: session);
addTearDown(container.dispose);
// Initial load.
final initial = await container.read(channelsProvider.future);
expect(initial.single.isArchived, isFalse);
// Prime the detail cache.
final detailsFiltersBefore = session.historyFilters
.where((f) => f.kinds.contains(39000) && f.tags['#d'] != null)
.length;
await container.read(channelDetailsProvider(_channelA).future);
final detailsFetchesAfterPrime =
session.historyFilters
.where((f) => f.kinds.contains(39000) && f.tags['#d'] != null)
.length -
detailsFiltersBefore;
expect(detailsFetchesAfterPrime, 1);
// Simulate the reaper auto-archiving the channel by swapping the
// metadata the fake returns, then refreshing the channels provider.
session.metadata
..clear()
..add(_meta(id: _channelA, name: 'active', archived: true));
await container.read(channelsProvider.notifier).refresh();
final refreshed = container.read(channelsProvider).value!;
expect(refreshed.single.isArchived, isTrue);
// Take a fresh baseline AFTER the refresh — the refresh itself issues a
// `kinds:[39000], #d:[id]` query as part of channel metadata refetch and
// we must not count that toward our invalidation assertion. Only the
// fetch triggered by the second `channelDetailsProvider` read should be
// attributed to invalidation.
final detailsFiltersAfterRefresh = session.historyFilters
.where((f) => f.kinds.contains(39000) && f.tags['#d'] != null)
.length;
// Reading the details provider again must trigger a fresh fetch — proving
// the prior cache was invalidated by the archive transition. Without
// invalidation, Riverpod would return the cached pre-archive details and
// no new `kinds:[39000], #d:[id]` filter would be sent.
await container.read(channelDetailsProvider(_channelA).future);
final detailsFetchesFromInvalidation =
session.historyFilters
.where((f) => f.kinds.contains(39000) && f.tags['#d'] != null)
.length -
detailsFiltersAfterRefresh;
expect(detailsFetchesFromInvalidation, greaterThan(0));
},
);
test(
'keeps cached channels and live subscriptions during reconnect',
() async {
final session = _FakeRelaySession(
memberships: [_membership(_channelA, myPk)],
metadata: [_meta(id: _channelA, name: 'general')],
);
final container = _buildContainer(session: session);
addTearDown(container.dispose);
final initial = await container.read(channelsProvider.future);
expect(initial.single.name, 'general');
expect(session.subscribeFilters, hasLength(1));
session.setStatus(SessionStatus.reconnecting);
final reconnecting = await container.read(channelsProvider.future);
expect(reconnecting.single.name, 'general');
expect(session.subscribeFilters, hasLength(1));
expect(session.unsubscribeCount, 0);
},
);
test(
'refreshes cached channels after a disconnected community switch',
() async {
final session = _FakeRelaySession(
memberships: [_membership(_channelA, myPk)],
metadata: [_meta(id: _channelA, name: 'general')],
);
final container = _buildContainer(session: session);
addTearDown(container.dispose);
expect(
(await container.read(channelsProvider.future)).single.name,
'general',
);
session.setStatus(SessionStatus.disconnected);
session.memberships = [_membership(_channelB, myPk)];
session.metadata = [_meta(id: _channelB, name: 'random')];
container
.read(relayConfigProvider.notifier)
.update(baseUrl: 'https://new-community.example');
await Future<void>.delayed(Duration.zero);
expect(container.read(channelsProvider).value?.single.name, 'general');
session.setStatus(SessionStatus.connected);
await Future<void>.delayed(Duration.zero);
expect(container.read(channelsProvider).value?.single.name, 'random');
},
);
test('recovers an initial fetch failure after reconnecting', () async {
final session = _FakeRelaySession(
memberships: [_membership(_channelA, myPk)],
metadata: [_meta(id: _channelA, name: 'general')],
membershipFailures: 1,
);
final container = _buildContainer(session: session);
addTearDown(container.dispose);
await expectLater(container.read(channelsProvider.future), throwsException);
session.setStatus(SessionStatus.reconnecting);
session.setStatus(SessionStatus.connected);
await Future<void>.delayed(Duration.zero);
final recovered = await container.read(channelsProvider.future);
expect(recovered.single.name, 'general');
});
test(
'preserves a successfully loaded empty list while disconnected',
() async {
final session = _FakeRelaySession(memberships: [], metadata: []);
final container = _buildContainer(session: session);
addTearDown(container.dispose);
expect(await container.read(channelsProvider.future), isEmpty);
final fetchCount = session.historyFilters.length;
session.setStatus(SessionStatus.reconnecting);
expect(await container.read(channelsProvider.future), isEmpty);
expect(session.historyFilters, hasLength(fetchCount));
},
);
test('initial fetch issues membership + metadata queries', () async {
final session = _FakeRelaySession(
memberships: [_membership(_channelA, myPk)],
metadata: [_meta(id: _channelA, name: 'general')],
);
final container = _buildContainer(session: session);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
// Two history fetches for channel loading, plus one per non-DM channel
// for high-priority event backfill.
expect(session.historyFilters.length, greaterThanOrEqualTo(2));
expect(session.historyFilters[0].kinds, [39002]);
expect(session.historyFilters[0].tags['#p'], [myPk]);
expect(session.historyFilters[1].kinds, [39000]);
expect(session.historyFilters[1].tags['#d'], [_channelA]);
// And one live subscription on the resulting channel.
expect(session.subscribeFilters, hasLength(1));
});
}
const _channelA = '11111111-1111-4111-8111-111111111111';
const _channelB = '22222222-2222-4222-8222-222222222222';
const _channelD = '44444444-4444-4444-8444-444444444444';
/// Build a kind:39002 membership event tagged with the channel id and member.
NostrEvent _membership(String channelId, String pubkey) => NostrEvent(
id: 'mem-$channelId',
pubkey: 'creator',
createdAt: 1,
kind: 39002,
tags: [
['d', channelId],
['p', pubkey],
],
content: '',
sig: 'sig',
);
NostrEvent _hiddenDms(List<String> channelIds, {required String pubkey}) =>
NostrEvent(
id: 'hidden-${channelIds.join('-')}',
pubkey: 'relay',
createdAt: 2,
kind: EventKind.dmVisibility,
tags: [
['d', pubkey],
['p', pubkey],
for (final channelId in channelIds) ['h', channelId],
],
content: '',
sig: 'sig',
);
/// Build a kind:39000 channel metadata event.
NostrEvent _meta({
required String id,
required String name,
String channelType = 'stream',
int createdAt = 1,
int? ttlSeconds,
bool archived = false,
}) => NostrEvent(
id: 'meta-$id',
pubkey: 'creator',
createdAt: createdAt,
kind: 39000,
tags: [
['d', id],
['name', name],
['t', channelType],
['public'],
if (ttlSeconds != null) ['ttl', '$ttlSeconds'],
if (archived) ['archived', 'true'],
],
content: '',
sig: 'sig',
);
ProviderContainer _buildContainer({required _FakeRelaySession session}) {
return ProviderContainer(
retry: (_, _) => null,
overrides: [
appLifecycleProvider.overrideWith(() => _FakeAppLifecycleNotifier()),
relaySessionProvider.overrideWith(() => session),
myPubkeyProvider.overrideWithValue('me'),
],
);
}
Future<void> _waitUntil(bool Function() predicate) async {
for (var i = 0; i < 100; i++) {
if (predicate()) return;
await Future<void>.delayed(Duration.zero);
}
fail('Timed out waiting for asynchronous provider work');
}
/// Fake [RelaySessionNotifier] that returns canned events from [fetchHistory]
/// and records subscribe calls.
class _FakeRelaySession extends RelaySessionNotifier {
_FakeRelaySession({
required this.memberships,
required this.metadata,
this.hiddenDmEvents = const [],
this.membershipFailures = 0,
});
List<NostrEvent> memberships;
List<NostrEvent> metadata;
final List<NostrEvent> hiddenDmEvents;
int membershipFailures;
final List<NostrFilter> historyFilters = [];
final List<NostrFilter> subscribeFilters = [];
final Map<int, (NostrFilter, void Function(NostrEvent))> _subscriptions = {};
int _nextSubscriptionKey = 0;
Completer<void>? _pausedSubscribe;
Completer<void>? _subscribeStarted;
int unsubscribeCount = 0;
int totalSubscribeCount = 0;
Set<String> get activeChannels => {
for (final (filter, _) in _subscriptions.values) ?filter.tags['#h']?.single,
};
int get activeSubscriptionCount => _subscriptions.length;
Future<void> get nextSubscribeStarted async {
final started = _subscribeStarted;
if (started == null) {
throw StateError('No paused subscription is pending');
}
await started.future;
}
void pauseNextSubscribe() {
if (_pausedSubscribe != null) {
throw StateError('A subscription is already paused');
}
_pausedSubscribe = Completer<void>();
_subscribeStarted = Completer<void>();
}
void resumePausedSubscribe() {
final paused = _pausedSubscribe;
if (paused == null) throw StateError('No subscription is paused');
paused.complete();
}
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
historyFilters.add(filter);
if (filter.kinds.contains(39002) && filter.tags['#p'] != null) {
if (membershipFailures > 0) {
membershipFailures--;
throw Exception('membership fetch failed');
}
// Membership query — return all memberships we have for this pubkey.
final myPk = filter.tags['#p']?.single;
return memberships
.where(
(e) =>
e.tags.any((t) => t.length >= 2 && t[0] == 'p' && t[1] == myPk),
)
.toList();
}
if (filter.kinds.contains(EventKind.dmVisibility)) {
return hiddenDmEvents;
}
if (filter.kinds.contains(39000)) {
// Metadata query — return all metadata events whose `d` tag matches.
final ids = (filter.tags['#d'] ?? const <String>[]).toSet();
return metadata.where((e) => ids.contains(e.getTagValue('d'))).toList();
}
return const [];
}
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) async {
totalSubscribeCount++;
subscribeFilters.add(filter);
final paused = _pausedSubscribe;
if (paused != null) {
_subscribeStarted!.complete();
await paused.future;
_pausedSubscribe = null;
_subscribeStarted = null;
}
final subscriptionKey = ++_nextSubscriptionKey;
_subscriptions[subscriptionKey] = (filter, onEvent);
return () {
final subscription = _subscriptions.remove(subscriptionKey);
if (subscription == null) return;
unsubscribeCount++;
subscribeFilters.remove(subscription.$1);
};
}
void setStatus(SessionStatus status) {
state = SessionState(status: status);
}
/// Emit a live event to all subscribers.
void emit(NostrEvent event) {
for (final (_, listener) in List.of(_subscriptions.values)) {
listener(event);
}
}
}
class _FakeAppLifecycleNotifier extends AppLifecycleNotifier {
@override
AppLifecycleState build() => AppLifecycleState.resumed;
}