feat: import Chinese-localized Buzz source snapshot
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

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
@@ -0,0 +1,365 @@
import 'dart:async';
import 'dart:convert';
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/agent_activity/observer_models.dart';
import 'package:buzz/features/channels/agent_activity/observer_subscription.dart';
import 'package:buzz/shared/crypto/nip44.dart';
import 'package:buzz/shared/relay/relay.dart';
void main() {
test('provider initializes without circular dependency error', () {
// Regression test: reading the provider should NOT throw
// "Bad state: Tried to read the state of an uninitialized provider".
final container = ProviderContainer(
overrides: [
relaySessionProvider.overrideWith(() => _RecordingRelaySession()),
relayConfigProvider.overrideWith(
() => _FakeRelayConfigNotifier(nsec: null),
),
],
);
addTearDown(container.dispose);
const key = (channelId: 'test-channel', agentPubkey: 'deadbeef');
// This line threw before the fix.
final state = container.read(observerSubscriptionProvider(key));
expect(state.connection, ObserverConnectionState.idle);
expect(state.transcript, isEmpty);
});
test('transitions to error when nsec is invalid', () async {
final container = ProviderContainer(
overrides: [
relaySessionProvider.overrideWith(() => _RecordingRelaySession()),
relayConfigProvider.overrideWith(
() => _FakeRelayConfigNotifier(nsec: 'nsec1invalid'),
),
],
);
addTearDown(container.dispose);
const key = (channelId: 'test-channel', agentPubkey: 'deadbeef');
container.read(observerSubscriptionProvider(key));
// Let the subscription microtask run.
await Future<void>.delayed(Duration.zero);
final state = container.read(observerSubscriptionProvider(key));
// With invalid nsec, it should be in error state, NOT throw.
expect(state.connection, ObserverConnectionState.error);
expect(state.errorMessage, isNotNull);
});
test('stays idle when nsec is null', () async {
final container = ProviderContainer(
overrides: [
relaySessionProvider.overrideWith(() => _RecordingRelaySession()),
relayConfigProvider.overrideWith(
() => _FakeRelayConfigNotifier(nsec: null),
),
],
);
addTearDown(container.dispose);
const key = (channelId: 'test-channel', agentPubkey: 'deadbeef');
container.read(observerSubscriptionProvider(key));
// Let the subscription microtask run. Without an nsec, it should no-op.
await Future<void>.delayed(Duration.zero);
final state = container.read(observerSubscriptionProvider(key));
expect(state.connection, ObserverConnectionState.idle);
expect(state.transcript, isEmpty);
});
test(
'subscribes with correct filter shape and transitions to open',
() async {
final userKeychain = nostr.Keys.generate();
final nsec = userKeychain.nsec;
final myPubkey = userKeychain.public;
// Agent needs a valid 64-char hex pubkey for getConversationKey.
final agentKeychain = nostr.Keys.generate();
final agentPubkey = agentKeychain.public;
final relaySession = _RecordingRelaySession();
final container = ProviderContainer(
overrides: [
relaySessionProvider.overrideWith(() => relaySession),
relayConfigProvider.overrideWith(
() => _FakeRelayConfigNotifier(nsec: nsec),
),
],
);
addTearDown(container.dispose);
final key = (channelId: 'test-channel', agentPubkey: agentPubkey);
container.read(observerSubscriptionProvider(key));
// Let the subscription microtask run.
await Future<void>.delayed(Duration.zero);
final state = container.read(observerSubscriptionProvider(key));
expect(state.connection, ObserverConnectionState.open);
expect(state.transcript, isEmpty);
// Verify the subscription filter shape.
expect(relaySession.filters, hasLength(1));
final filter = relaySession.filters.first;
expect(filter.kinds, [EventKind.agentObserverFrame]);
expect(filter.limit, 0);
expect(filter.tags['#p'], contains(myPubkey));
expect(filter.since, isNull);
},
);
test(
'uses one shared relay subscription for channel-scoped readers',
() async {
final userKeychain = nostr.Keys.generate();
final agentKeychain = nostr.Keys.generate();
final relaySession = _RecordingRelaySession();
final container = ProviderContainer(
overrides: [
relaySessionProvider.overrideWith(() => relaySession),
relayConfigProvider.overrideWith(
() => _FakeRelayConfigNotifier(nsec: userKeychain.nsec),
),
],
);
addTearDown(container.dispose);
container.read(
observerSubscriptionProvider((
channelId: 'first-channel',
agentPubkey: agentKeychain.public,
)),
);
container.read(
observerSubscriptionProvider((
channelId: 'second-channel',
agentPubkey: agentKeychain.public,
)),
);
await Future<void>.delayed(Duration.zero);
expect(relaySession.filters, hasLength(1));
},
);
test('surfaces relay CLOSED messages through observer state', () async {
final userKeychain = nostr.Keys.generate();
final agentKeychain = nostr.Keys.generate();
final relaySession = _RecordingRelaySession();
final container = ProviderContainer(
overrides: [
relaySessionProvider.overrideWith(() => relaySession),
relayConfigProvider.overrideWith(
() => _FakeRelayConfigNotifier(nsec: userKeychain.nsec),
),
],
);
addTearDown(container.dispose);
final key = (channelId: 'test-channel', agentPubkey: agentKeychain.public);
container.read(observerSubscriptionProvider(key));
await Future<void>.delayed(Duration.zero);
relaySession.closeAll('restricted: p-gated events require #p');
final state = container.read(observerSubscriptionProvider(key));
expect(state.connection, ObserverConnectionState.error);
expect(state.errorMessage, contains('p-gated events require #p'));
});
test('ignores stale subscribe completion after identity changes', () async {
final firstUser = nostr.Keys.generate();
final secondUser = nostr.Keys.generate();
final agentKeychain = nostr.Keys.generate();
final relaySession = _RecordingRelaySession()..delaySubscribes = true;
final container = ProviderContainer(
overrides: [
relaySessionProvider.overrideWith(() => relaySession),
relayConfigProvider.overrideWith(
() => _FakeRelayConfigNotifier(nsec: firstUser.nsec),
),
],
);
addTearDown(container.dispose);
final key = (channelId: 'test-channel', agentPubkey: agentKeychain.public);
container.read(observerSubscriptionProvider(key));
await Future<void>.delayed(Duration.zero);
expect(relaySession.filters, hasLength(1));
expect(relaySession.filters.single.tags['#p'], [firstUser.public]);
(container.read(relayConfigProvider.notifier) as _FakeRelayConfigNotifier)
.setNsec(secondUser.nsec);
container.read(observerSubscriptionProvider(key));
await Future<void>.delayed(Duration.zero);
expect(relaySession.filters, hasLength(2));
expect(relaySession.filters.last.tags['#p'], [secondUser.public]);
relaySession.releaseSubscribe(0);
await Future<void>.delayed(Duration.zero);
expect(relaySession.filters, hasLength(1));
expect(relaySession.filters.single.tags['#p'], [secondUser.public]);
relaySession.releaseSubscribe(1);
await Future<void>.delayed(Duration.zero);
final state = container.read(observerSubscriptionProvider(key));
expect(state.connection, ObserverConnectionState.open);
expect(relaySession.filters, hasLength(1));
});
test(
'decrypts observer frames and exposes channel-scoped transcript',
() async {
final ownerKeychain = nostr.Keys.generate();
final agentKeychain = nostr.Keys.generate();
final nsec = ownerKeychain.nsec;
final relaySession = _RecordingRelaySession();
final container = ProviderContainer(
overrides: [
relaySessionProvider.overrideWith(() => relaySession),
relayConfigProvider.overrideWith(
() => _FakeRelayConfigNotifier(nsec: nsec),
),
],
);
addTearDown(container.dispose);
const channelId = 'test-channel';
final key = (channelId: channelId, agentPubkey: agentKeychain.public);
container.read(observerSubscriptionProvider(key));
await Future<void>.delayed(Duration.zero);
final conversationKey = getConversationKey(
agentKeychain.secret,
ownerKeychain.public,
);
final encrypted = nip44Encrypt(
conversationKey,
jsonEncode({
'seq': 1,
'timestamp': '2026-04-30T12:00:00.000Z',
'kind': 'turn_started',
'channelId': channelId,
'turnId': 'turn-1',
'payload': {
'triggeringEventIds': ['0123456789abcdef'],
},
}),
);
final event = nostr.Event.from(
kind: EventKind.agentObserverFrame,
content: encrypted,
tags: [
['p', ownerKeychain.public],
['agent', agentKeychain.public],
['frame', 'telemetry'],
],
secretKey: agentKeychain.secret,
verify: false,
);
relaySession.emit(NostrEvent.fromJson(event.toMap()));
final state = container.read(observerSubscriptionProvider(key));
expect(state.connection, ObserverConnectionState.open);
expect(state.transcript, hasLength(1));
final item = state.transcript.single;
expect(item, isA<LifecycleItem>());
expect((item as LifecycleItem).title, 'Turn started');
final otherChannelState = container.read(
observerSubscriptionProvider((
channelId: 'other-channel',
agentPubkey: agentKeychain.public,
)),
);
expect(otherChannelState.transcript, isEmpty);
},
);
}
class _RecordingRelaySession extends RelaySessionNotifier {
final List<NostrFilter> filters = [];
final List<void Function(NostrEvent)> _listeners = [];
final List<void Function(String message)> _closedListeners = [];
final List<Completer<void>> _subscribeGates = [];
bool delaySubscribes = false;
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) async {
filters.add(filter);
_listeners.add(onEvent);
if (onClosed != null) {
_closedListeners.add(onClosed);
}
if (delaySubscribes) {
final gate = Completer<void>();
_subscribeGates.add(gate);
await gate.future;
}
return () {
filters.remove(filter);
_listeners.remove(onEvent);
if (onClosed != null) {
_closedListeners.remove(onClosed);
}
};
}
void emit(NostrEvent event) {
for (final listener in List.of(_listeners)) {
listener(event);
}
}
void closeAll(String message) {
for (final listener in List.of(_closedListeners)) {
listener(message);
}
filters.clear();
_listeners.clear();
_closedListeners.clear();
}
void releaseSubscribe(int index) {
final gate = _subscribeGates[index];
if (!gate.isCompleted) {
gate.complete();
}
}
}
class _FakeRelayConfigNotifier extends RelayConfigNotifier {
String? _nsec;
_FakeRelayConfigNotifier({required String? nsec}) : _nsec = nsec;
@override
RelayConfig build() =>
RelayConfig(baseUrl: 'http://localhost:3000', nsec: _nsec);
void setNsec(String? nsec) {
_nsec = nsec;
state = RelayConfig(baseUrl: 'http://localhost:3000', nsec: _nsec);
}
}
@@ -0,0 +1,144 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/features/channels/agent_activity/observer_models.dart';
import 'package:buzz/features/channels/agent_activity/transcript_builder.dart';
void main() {
test('aggregates assistant chunks until another item seals the message', () {
final items = buildTranscript([
_updateFrame(
seq: 1,
update: {
'sessionUpdate': 'agent_message_chunk',
'messageId': 'm1',
'content': [
{'type': 'text', 'text': 'Hello'},
],
},
),
_updateFrame(
seq: 2,
update: {
'sessionUpdate': 'agent_message_chunk',
'messageId': 'm1',
'content': [
{'type': 'text', 'text': ' world'},
],
},
),
_updateFrame(
seq: 3,
update: {
'sessionUpdate': 'tool_call',
'toolCallId': 'tool-1',
'title': 'sleep',
'args': {'seconds': 5},
},
),
_updateFrame(
seq: 4,
update: {
'sessionUpdate': 'agent_message_chunk',
'messageId': 'm1',
'content': [
{'type': 'text', 'text': 'Done'},
],
},
),
]);
expect(items, hasLength(3));
expect(items[0], isA<MessageItem>());
expect((items[0] as MessageItem).text, 'Hello world');
expect(items[1], isA<ToolItem>());
expect(items[2], isA<MessageItem>());
expect((items[2] as MessageItem).text, 'Done');
});
test('normalizes buzz tool calls and applies result updates', () {
final items = buildTranscript([
_updateFrame(
seq: 1,
update: {
'sessionUpdate': 'tool_call',
'toolCallId': 'send-1',
'title': 'Sending message to channel',
'status': 'executing',
'args': {'content': 'hi'},
},
),
_updateFrame(
seq: 2,
update: {
'sessionUpdate': 'tool_call_update',
'toolCallId': 'send-1',
'status': 'completed',
'content': [
{'type': 'text', 'text': 'posted #activity-test-channel'},
],
},
),
]);
expect(items, hasLength(1));
expect(items.single, isA<ToolItem>());
final tool = items.single as ToolItem;
expect(tool.buzzToolName, 'send_message');
expect(tool.toolName, 'send_message');
expect(tool.status, ToolStatus.completed);
expect(tool.args, {'content': 'hi'});
expect(tool.result, 'posted #activity-test-channel');
});
test('parses buzz prompt text into user message and metadata', () {
final items = buildTranscript([
ObserverFrame(
seq: 1,
timestamp: _timestamp(1),
kind: 'acp_write',
turnId: 'turn-1',
payload: {
'method': 'session/prompt',
'params': {
'prompt': [
{
'content':
'[Buzz event: stream message]\n'
'Content: @claude can you do that again?\n\n'
'[Channel]\n'
'#activity-test-channel',
},
],
},
},
),
]);
expect(items, hasLength(2));
expect(items[0], isA<MessageItem>());
final message = items[0] as MessageItem;
expect(message.role, 'user');
expect(message.title, 'Stream Message');
expect(message.text, '@claude can you do that again?');
expect(items[1], isA<MetadataItem>());
expect((items[1] as MetadataItem).sections, hasLength(2));
});
}
ObserverFrame _updateFrame({
required int seq,
required Map<String, dynamic> update,
}) {
return ObserverFrame(
seq: seq,
timestamp: _timestamp(seq),
kind: 'acp_read',
turnId: 'turn-1',
payload: {
'method': 'session/update',
'params': {'update': update},
},
);
}
String _timestamp(int seq) =>
DateTime.utc(2026, 4, 30, 12, 0, seq).toIso8601String();
@@ -0,0 +1,71 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:image_picker/image_picker.dart';
import 'package:buzz/features/channels/camera_capture_cleanup.dart';
void main() {
test('deletes the inline camera file after upload succeeds', () async {
final file = File(
'${Directory.systemTemp.path}/buzz-camera-${DateTime.now().microsecondsSinceEpoch}.jpg',
);
await file.writeAsBytes([1, 2, 3]);
await processCapturedImage(XFile(file.path), (_) async {});
expect(await file.exists(), isFalse);
});
test('deletes the inline camera file when upload fails', () async {
final file = File(
'${Directory.systemTemp.path}/buzz-camera-${DateTime.now().microsecondsSinceEpoch}.jpg',
);
await file.writeAsBytes([1, 2, 3]);
await expectLater(
processCapturedImage(
XFile(file.path),
(_) async => throw Exception('upload failed'),
),
throwsException,
);
expect(await file.exists(), isFalse);
});
test('deletes every native picker file after processing', () async {
final suffix = DateTime.now().microsecondsSinceEpoch;
final files = [
File('${Directory.systemTemp.path}/buzz-photo-$suffix-1.jpg'),
File('${Directory.systemTemp.path}/buzz-photo-$suffix-2.jpg'),
];
for (final file in files) {
await file.writeAsBytes([1, 2, 3]);
}
await processTemporaryImages([
for (final file in files) XFile(file.path),
], (_) async {});
for (final file in files) {
expect(await file.exists(), isFalse);
}
});
test('retains a composer-owned copy before native cleanup', () async {
final source = File(
'${Directory.systemTemp.path}/buzz-native-${DateTime.now().microsecondsSinceEpoch}.png',
);
await source.writeAsBytes([4, 5, 6]);
late XFile retained;
await processCapturedImage(XFile(source.path), (image) async {
retained = (await retainTemporaryImages([image])).single;
});
addTearDown(() => File(retained.path).delete());
expect(await source.exists(), isFalse);
expect(await File(retained.path).exists(), isTrue);
expect(await retained.readAsBytes(), [4, 5, 6]);
});
}
@@ -0,0 +1,397 @@
import 'dart:async';
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channel_actions_sheet.dart';
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/l10n/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
const _currentPubkey = 'me';
Channel _channel({String type = 'stream', bool isArchived = false}) => Channel(
id: 'channel-id',
name: type == 'dm' ? 'Alice' : 'general',
channelType: type,
visibility: 'open',
description: '',
createdBy: 'owner',
createdAt: DateTime(2025),
memberCount: 2,
isMember: true,
archivedAt: isArchived ? DateTime(2025, 1, 2) : null,
);
Widget _app({
required Channel channel,
required Future<List<ChannelMember>> Function() loadMembers,
bool isUnread = false,
AsyncValue<Map<String, String>> agentOwners = const AsyncValue.data(
<String, String>{},
),
ChannelActions Function(Ref ref)? createChannelActions,
String? currentPubkey = _currentPubkey,
}) => ProviderScope(
overrides: [
currentPubkeyProvider.overrideWith((ref) => currentPubkey),
channelMembersProvider(channel.id).overrideWith((ref) => loadMembers()),
agentOwnersProvider.overrideWithValue(agentOwners),
if (createChannelActions != null)
channelActionsProvider.overrideWith(createChannelActions),
],
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
theme: AppTheme.light(),
home: Scaffold(
body: ChannelActionsSheet(channel: channel, isUnread: isUnread),
),
),
);
Widget _modalApp({
required Channel channel,
required Future<List<ChannelMember>> Function() loadMembers,
required ChannelActions Function(Ref ref) createChannelActions,
}) => ProviderScope(
overrides: [
currentPubkeyProvider.overrideWith((ref) => _currentPubkey),
channelMembersProvider(channel.id).overrideWith((ref) => loadMembers()),
channelActionsProvider.overrideWith(createChannelActions),
],
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
theme: AppTheme.light(),
home: Builder(
builder: (context) => Scaffold(
body: TextButton(
onPressed: () => showChannelActionsSheet(
context: context,
channel: channel,
isUnread: false,
),
child: const Text('Open actions'),
),
),
),
),
);
void main() {
testWidgets('owner sees the complete regular-channel action set', (
tester,
) async {
await tester.pumpWidget(
_app(
channel: _channel(),
loadMembers: () async => [
ChannelMember(
pubkey: _currentPubkey,
role: 'owner',
joinedAt: DateTime(2025),
),
],
),
);
await tester.pumpAndSettle();
for (final label in [
'Star',
'Mark Unread',
'Move to section…',
'Mute channel',
'Manage channel',
'Copy channel name',
'Copy channel ID',
'Leave channel',
'Archive channel',
'Delete channel',
]) {
expect(find.text(label), findsOneWidget, reason: label);
}
final moveTop = tester.getTopLeft(find.text('Move to section…')).dy;
final muteTop = tester.getTopLeft(find.text('Mute channel')).dy;
final manageTop = tester.getTopLeft(find.text('Manage channel')).dy;
final copyNameTop = tester.getTopLeft(find.text('Copy channel name')).dy;
final copyIdTop = tester.getTopLeft(find.text('Copy channel ID')).dy;
expect(moveTop, lessThan(muteTop));
expect(muteTop, lessThan(manageTop));
expect(manageTop, lessThan(copyNameTop));
expect(copyNameTop, lessThan(copyIdTop));
});
testWidgets('unread channel uses the Mark Read label', (tester) async {
await tester.pumpWidget(
_app(
channel: _channel(),
isUnread: true,
loadMembers: () async => const [],
),
);
await tester.pumpAndSettle();
expect(find.text('Mark Read'), findsOneWidget);
expect(find.text('Mark Unread'), findsNothing);
});
testWidgets('admin can archive but cannot delete', (tester) async {
await tester.pumpWidget(
_app(
channel: _channel(),
loadMembers: () async => [
ChannelMember(
pubkey: _currentPubkey,
role: 'admin',
joinedAt: DateTime(2025),
),
],
),
);
await tester.pumpAndSettle();
expect(find.text('Archive channel'), findsOneWidget);
expect(find.text('Delete channel'), findsNothing);
});
testWidgets('verified owner agent grants archive and delete', (tester) async {
const agentPubkey = 'agent';
await tester.pumpWidget(
_app(
channel: _channel(),
agentOwners: const AsyncValue.data({agentPubkey: _currentPubkey}),
loadMembers: () async => [
ChannelMember(
pubkey: agentPubkey,
role: 'owner',
joinedAt: DateTime(2025),
),
],
),
);
await tester.pumpAndSettle();
expect(find.text('Archive channel'), findsOneWidget);
expect(find.text('Delete channel'), findsOneWidget);
});
testWidgets('unresolved identity grants no lifecycle actions', (
tester,
) async {
await tester.pumpWidget(
_app(
channel: _channel(),
currentPubkey: null,
loadMembers: () async => [
ChannelMember(
pubkey: 'ordinary-owner',
role: 'owner',
joinedAt: DateTime(2025),
),
],
),
);
await tester.pumpAndSettle();
expect(find.text('Archive channel'), findsNothing);
expect(find.text('Delete channel'), findsNothing);
});
testWidgets('archived owner can unarchive but cannot delete', (tester) async {
late _FakeChannelActions actions;
await tester.pumpWidget(
_app(
channel: _channel(isArchived: true),
loadMembers: () async => [
ChannelMember(
pubkey: _currentPubkey,
role: 'owner',
joinedAt: DateTime(2025),
),
],
createChannelActions: (ref) => actions = _FakeChannelActions(ref),
),
);
await tester.pumpAndSettle();
expect(find.text('Archive channel'), findsNothing);
expect(find.text('Unarchive channel'), findsOneWidget);
expect(find.text('Delete channel'), findsNothing);
await tester.tap(find.text('Unarchive channel'));
await tester.pumpAndSettle();
expect(find.text('Unarchive #general?'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Unarchive'));
await tester.pumpAndSettle();
expect(actions.unarchivedChannelId, 'channel-id');
});
testWidgets('owned non-owner agent grants no lifecycle actions', (
tester,
) async {
const agentPubkey = 'agent';
await tester.pumpWidget(
_app(
channel: _channel(),
agentOwners: const AsyncValue.data({agentPubkey: _currentPubkey}),
loadMembers: () async => [
ChannelMember(
pubkey: agentPubkey,
role: 'bot',
joinedAt: DateTime(2025),
),
],
),
);
await tester.pumpAndSettle();
expect(find.text('Archive channel'), findsNothing);
expect(find.text('Delete channel'), findsNothing);
});
testWidgets('agent ownership loading keeps lifecycle actions pending', (
tester,
) async {
await tester.pumpWidget(
_app(
channel: _channel(),
agentOwners: const AsyncValue.loading(),
loadMembers: () async => const [],
),
);
await tester.pump();
expect(find.text('Loading channel actions…'), findsOneWidget);
expect(find.text('Archive channel'), findsNothing);
expect(find.text('Delete channel'), findsNothing);
});
testWidgets('member sees neither owner action', (tester) async {
await tester.pumpWidget(
_app(
channel: _channel(),
loadMembers: () async => [
ChannelMember(
pubkey: _currentPubkey,
role: 'member',
joinedAt: DateTime(2025),
),
],
),
);
await tester.pumpAndSettle();
expect(find.text('Archive channel'), findsNothing);
expect(find.text('Delete channel'), findsNothing);
expect(find.text('Leave channel'), findsOneWidget);
});
testWidgets('shows loading and unavailable capability states', (
tester,
) async {
final pending = Completer<List<ChannelMember>>();
await tester.pumpWidget(
_app(channel: _channel(), loadMembers: () => pending.future),
);
await tester.pump();
expect(find.text('Loading channel actions…'), findsOneWidget);
pending.completeError(Exception('relay unavailable'));
await tester.pumpAndSettle();
expect(find.text('Channel actions unavailable'), findsOneWidget);
});
testWidgets(
'manage leave closes both nested sheets without popping the page',
(tester) async {
await tester.pumpWidget(
_modalApp(
channel: _channel(),
loadMembers: () async => const [],
createChannelActions: (ref) => _FakeChannelActions(ref),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Open actions'));
await tester.pumpAndSettle();
await tester.tap(find.text('Manage channel'));
await tester.pumpAndSettle();
await tester.tap(find.text('Leave channel').last);
await tester.pumpAndSettle();
expect(find.byType(ChannelActionsSheet), findsNothing);
expect(find.byType(Scaffold), findsOneWidget);
},
);
testWidgets('DM omits quick actions, then shows mute and copy rows', (
tester,
) async {
await tester.pumpWidget(
_app(
channel: _channel(type: 'dm'),
loadMembers: () async => const [],
),
);
await tester.pumpAndSettle();
for (final label in [
'Mute channel',
'Copy channel name',
'Copy channel ID',
]) {
expect(find.text(label), findsOneWidget, reason: label);
}
for (final label in [
'Star',
'Unstar',
'Mark Unread',
'Mark Read',
'Move to section…',
'Manage channel',
'Leave channel',
'Archive channel',
'Delete channel',
]) {
expect(find.text(label), findsNothing, reason: label);
}
final muteTop = tester.getTopLeft(find.text('Mute channel')).dy;
final copyNameTop = tester.getTopLeft(find.text('Copy channel name')).dy;
final copyIdTop = tester.getTopLeft(find.text('Copy channel ID')).dy;
expect(muteTop, lessThan(copyNameTop));
expect(copyNameTop, lessThan(copyIdTop));
});
}
class _FakeChannelActions extends ChannelActions {
_FakeChannelActions(Ref ref)
: super(
ref: ref,
session: ref.read(relaySessionProvider.notifier),
signedEventRelay: SignedEventRelay(
session: ref.read(relaySessionProvider.notifier),
nsec: null,
),
currentPubkey: _currentPubkey,
);
String? unarchivedChannelId;
@override
Future<void> leaveChannel(String channelId) async {}
@override
Future<void> unarchiveChannel(String channelId) async {
unarchivedChannelId = channelId;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,370 @@
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/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('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'],
]);
});
});
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);
});
});
}
/// 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 [];
}
}
@@ -0,0 +1,894 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/features/channels/channel_messages_provider.dart';
import 'package:buzz/features/channels/pending_local_messages_provider.dart';
import 'package:buzz/features/channels/thread_replies_provider.dart';
import 'package:buzz/features/channels/timeline_message.dart';
import 'package:buzz/shared/relay/relay.dart';
void main() {
test(
'keeps live events that arrive while initial history is loading',
() async {
final relaySession = _RecordingRelaySessionNotifier();
final container = _buildContainer(relaySession);
addTearDown(container.dispose);
container.read(channelMessagesProvider(_channelId));
await relaySession.subscribed;
relaySession.emit(_event(id: 'live', createdAt: 20));
await _pumpEventQueue();
expect(
container
.read(channelMessagesProvider(_channelId))
.value
?.map((event) => event.id),
['live'],
);
relaySession.completeHistory([_event(id: 'history', createdAt: 10)]);
await _pumpEventQueue();
final messages = container
.read(channelMessagesProvider(_channelId))
.value!;
expect(messages.map((event) => event.id), ['history', 'live']);
expect(relaySession.operations, ['subscribe', 'query', 'fetch']);
expect(relaySession.liveFilters.single.kinds, [
...EventKind.channelEventKinds,
EventKind.channelThreadSummary,
]);
expect(relaySession.liveFilters.single.tags['#h'], [_channelId]);
expect(relaySession.liveFilters.single.limit, 200);
expect(
relaySession.queryFilters.first.kinds,
EventKind.channelTimelineContentKinds,
);
expect(relaySession.queryFilters.first.tags['#h'], [_channelId]);
expect(relaySession.queryFilters.first.extensions['top_level'], isTrue);
expect(
relaySession.historyFilters.first.kinds,
EventKind.channelEventKinds,
);
expect(relaySession.historyFilters.first.tags['#h'], [_channelId]);
},
);
test(
'buffers a live thread summary until the initial window is installed',
() async {
final window = Completer<List<NostrEvent>>();
final relaySession = _RecordingRelaySessionNotifier(
queryResults: [window.future],
);
final container = _buildContainer(relaySession);
addTearDown(container.dispose);
container.read(channelMessagesProvider(_channelId));
await relaySession.subscribed;
final notifier = container.read(
channelMessagesProvider(_channelId).notifier,
);
relaySession.emit(_summary(rootId: 'root', replyCount: 2));
await _pumpEventQueue();
expect(
container.read(channelMessagesProvider(_channelId)).isLoading,
isTrue,
);
window.complete([
_event(id: 'root', createdAt: 10),
_summary(rootId: 'root', replyCount: 1, createdAt: 10),
_bounds(),
]);
await _pumpEventQueue();
expect(notifier.threadSummaries['root']?.replyCount, 2);
expect(
container
.read(channelMessagesProvider(_channelId))
.value
?.map((event) => event.id),
['root'],
);
},
);
test('still loads history when live subscription fails', () async {
final relaySession = _RecordingRelaySessionNotifier(failSubscribe: true);
final container = _buildContainer(relaySession);
addTearDown(container.dispose);
container.read(channelMessagesProvider(_channelId));
await relaySession.subscribed;
relaySession.completeHistory([_event(id: 'history', createdAt: 10)]);
await _pumpEventQueue();
final messages = container.read(channelMessagesProvider(_channelId)).value!;
expect(messages.map((event) => event.id), ['history']);
expect(relaySession.operations, ['subscribe', 'query', 'fetch']);
});
test(
'keeps live messages when history sync fails after subscribing',
() async {
final relaySession = _RecordingRelaySessionNotifier();
final container = _buildContainer(relaySession);
addTearDown(container.dispose);
container.read(channelMessagesProvider(_channelId));
await relaySession.subscribed;
relaySession.emit(_event(id: 'live', createdAt: 20));
await _pumpEventQueue();
relaySession.failHistory(Exception('history failed'));
await _pumpEventQueue();
final state = container.read(channelMessagesProvider(_channelId));
expect(state.hasError, isFalse);
expect(state.value?.map((event) => event.id), ['live']);
},
);
test(
'waits for initial history before publishing and preserves deep-link target',
() async {
final relaySession = _RecordingRelaySessionNotifier();
final container = _buildContainer(relaySession);
addTearDown(container.dispose);
container.read(channelMessagesProvider(_channelId));
await relaySession.subscribed;
final notifier = container.read(
channelMessagesProvider(_channelId).notifier,
);
final targetLoad = notifier.loadEventsById(const ['target']);
relaySession.completeTargetHistory([_event(id: 'target', createdAt: 5)]);
await targetLoad;
expect(
container.read(channelMessagesProvider(_channelId)).isLoading,
isTrue,
);
relaySession.completeHistory([_event(id: 'history', createdAt: 10)]);
await _pumpEventQueue();
expect(
container
.read(channelMessagesProvider(_channelId))
.value
?.map((event) => event.id),
['target', 'history'],
);
},
);
test(
'adds and rolls back a local message in the websocket timeline',
() async {
final relaySession = _RecordingRelaySessionNotifier();
final container = _buildContainer(relaySession);
addTearDown(container.dispose);
container.read(channelMessagesProvider(_channelId));
await relaySession.subscribed;
final notifier = container.read(
channelMessagesProvider(_channelId).notifier,
);
notifier.addLocalMessage(_event(id: 'local', createdAt: 20));
expect(
container
.read(channelMessagesProvider(_channelId))
.value
?.map((event) => event.id),
['local'],
);
relaySession.completeHistory([_event(id: 'history', createdAt: 10)]);
await _pumpEventQueue();
// The initial history merge must retain a local row even if the relay's
// history snapshot was taken before that outgoing event was durable.
expect(
container
.read(channelMessagesProvider(_channelId))
.value
?.map((event) => event.id),
['history', 'local'],
);
notifier.removeLocalMessage('local');
expect(
container
.read(channelMessagesProvider(_channelId))
.value
?.map((event) => event.id),
['history'],
);
},
);
test(
'legacy websocket echo retires ownership without duplicating the row',
() async {
final relaySession = _RecordingRelaySessionNotifier();
final container = _buildContainer(relaySession);
addTearDown(container.dispose);
container.read(channelMessagesProvider(_channelId));
await relaySession.subscribed;
final notifier = container.read(
channelMessagesProvider(_channelId).notifier,
);
final local = _event(id: 'local', createdAt: 20);
notifier.addLocalMessage(local);
relaySession.emit(local);
await _pumpEventQueue();
expect(container.read(pendingLocalMessagesProvider(_channelId)), isEmpty);
expect(
container
.read(channelMessagesProvider(_channelId))
.value
?.map((event) => event.id),
['local'],
);
},
);
test('adds and rolls back a local message in the channel window', () async {
final relaySession = _RecordingRelaySessionNotifier(
queryResults: [
[_event(id: 'history', createdAt: 10), _bounds()],
],
);
final container = _buildContainer(relaySession);
addTearDown(container.dispose);
container.read(channelMessagesProvider(_channelId));
await relaySession.subscribed;
await _pumpEventQueue();
final notifier = container.read(
channelMessagesProvider(_channelId).notifier,
);
notifier.addLocalMessage(_event(id: 'local', createdAt: 20));
expect(
container
.read(channelMessagesProvider(_channelId))
.value
?.map((event) => event.id),
['history', 'local'],
);
notifier.removeLocalMessage('local');
expect(
container
.read(channelMessagesProvider(_channelId))
.value
?.map((event) => event.id),
['history'],
);
});
test(
'live thread summary survives rolling back an unrelated local row',
() async {
final relaySession = _RecordingRelaySessionNotifier(
queryResults: [
[_event(id: 'root', createdAt: 10), _bounds()],
],
);
final container = _buildContainer(relaySession);
addTearDown(container.dispose);
container.read(channelMessagesProvider(_channelId));
await relaySession.subscribed;
await _pumpEventQueue();
final notifier = container.read(
channelMessagesProvider(_channelId).notifier,
);
notifier.addLocalMessage(_event(id: 'local', createdAt: 20));
relaySession.emit(_summary(rootId: 'root', replyCount: 2));
await _pumpEventQueue();
expect(notifier.threadSummaries['root']?.replyCount, 2);
notifier.removeLocalMessage('local');
expect(notifier.threadSummaries['root']?.replyCount, 2);
expect(
container
.read(channelMessagesProvider(_channelId))
.value
?.map((event) => event.id),
['root'],
);
},
);
test('reconnect hydration cannot retain a rolled-back local row', () async {
final relaySession = _RecordingRelaySessionNotifier(
queryResults: [
[_event(id: 'history', createdAt: 10), _bounds()],
[_event(id: 'history', createdAt: 10), _bounds()],
],
);
final container = _buildContainer(relaySession);
addTearDown(container.dispose);
container.read(channelMessagesProvider(_channelId));
await relaySession.subscribed;
await _pumpEventQueue();
final notifier = container.read(
channelMessagesProvider(_channelId).notifier,
);
notifier.addLocalMessage(_event(id: 'local', createdAt: 20));
relaySession.setConnected(false);
await _pumpEventQueue();
relaySession.setConnected(true);
await _pumpEventQueue();
expect(
container
.read(channelMessagesProvider(_channelId))
.value
?.map((event) => event.id),
['history', 'local'],
);
notifier.removeLocalMessage('local');
expect(
container
.read(channelMessagesProvider(_channelId))
.value
?.map((event) => event.id),
['history'],
);
});
test(
'thread replies are inserted, deduped, and rolled back locally',
() async {
final relaySession = _RecordingRelaySessionNotifier(
queryResults: [
[_event(id: 'history', createdAt: 10), _bounds()],
<NostrEvent>[],
[
_event(
id: 'reply',
createdAt: 20,
extraTags: const [
['e', 'root', '', 'reply'],
],
),
],
],
);
final container = _buildContainer(relaySession);
addTearDown(container.dispose);
container.read(channelMessagesProvider(_channelId));
await relaySession.subscribed;
await _pumpEventQueue();
const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root');
container.read(threadRepliesWithLocalProvider(args));
await _pumpEventQueue();
final notifier = container.read(
channelMessagesProvider(_channelId).notifier,
);
final reply = _event(
id: 'reply',
createdAt: 20,
extraTags: const [
['e', 'root', '', 'reply'],
],
);
notifier.addLocalMessage(reply);
expect(
container
.read(threadRepliesWithLocalProvider(args))
.value
?.map((event) => event.id),
['reply'],
);
expect(
container
.read(channelMessagesProvider(_channelId))
.value
?.map((event) => event.id),
['history'],
);
relaySession.emit(reply);
await container.read(threadRepliesProvider(args).future);
container.read(threadRepliesWithLocalProvider(args));
await _pumpEventQueue();
expect(
container
.read(threadRepliesWithLocalProvider(args))
.value
?.map((event) => event.id),
['reply'],
);
expect(container.read(threadLocalRepliesProvider(args)), isEmpty);
expect(container.read(pendingLocalMessagesProvider(_channelId)), isEmpty);
final rejected = _event(
id: 'rejected',
createdAt: 21,
extraTags: const [
['e', 'root', '', 'reply'],
],
);
notifier.addLocalMessage(rejected);
notifier.removeLocalMessage('rejected');
expect(
container
.read(threadRepliesWithLocalProvider(args))
.value
?.map((event) => event.id),
['reply'],
);
},
);
test(
'thread live echo keeps ownership until the authoritative refetch succeeds',
() async {
final relaySession = _RecordingRelaySessionNotifier(
queryResults: [
[_event(id: 'history', createdAt: 10), _bounds()],
<NostrEvent>[],
Exception('thread refetch failed'),
],
);
final container = _buildContainer(relaySession);
addTearDown(container.dispose);
container.read(channelMessagesProvider(_channelId));
await relaySession.subscribed;
await _pumpEventQueue();
const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root');
container.read(threadRepliesWithLocalProvider(args));
await _pumpEventQueue();
final notifier = container.read(
channelMessagesProvider(_channelId).notifier,
);
final reply = _event(
id: 'reply',
createdAt: 20,
extraTags: const [
['e', 'root', '', 'reply'],
],
);
notifier.addLocalMessage(reply);
relaySession.emit(reply);
await _pumpEventQueue();
expect(container.read(pendingLocalMessagesProvider(_channelId)).keys, [
'reply',
]);
expect(
container
.read(threadLocalRepliesProvider(args))
.map((event) => event.id),
['reply'],
);
expect(
container
.read(threadRepliesWithLocalProvider(args))
.value
?.map((event) => event.id),
['reply'],
);
},
);
test(
'successful never-echoed send releases ownership but keeps its row across reconnect',
() async {
final relaySession = _RecordingRelaySessionNotifier(
queryResults: [
[_event(id: 'history', createdAt: 10), _bounds()],
[_event(id: 'history', createdAt: 10), _bounds()],
],
);
final container = _buildContainer(relaySession);
addTearDown(container.dispose);
container.read(channelMessagesProvider(_channelId));
await relaySession.subscribed;
await _pumpEventQueue();
final notifier = container.read(
channelMessagesProvider(_channelId).notifier,
);
notifier.addLocalMessage(_event(id: 'local', createdAt: 20));
notifier.completeLocalMessage('local');
expect(container.read(pendingLocalMessagesProvider(_channelId)), isEmpty);
relaySession.setConnected(false);
await _pumpEventQueue();
relaySession.setConnected(true);
await _pumpEventQueue();
expect(container.read(pendingLocalMessagesProvider(_channelId)), isEmpty);
expect(
container
.read(channelMessagesProvider(_channelId))
.value
?.map((event) => event.id),
['history', 'local'],
);
},
);
test(
'window dedupes echoes and orders rapid equal-time local sends',
() async {
final relaySession = _RecordingRelaySessionNotifier(
queryResults: [
[_event(id: 'history', createdAt: 10), _bounds()],
],
);
final container = _buildContainer(relaySession);
addTearDown(container.dispose);
container.read(channelMessagesProvider(_channelId));
await relaySession.subscribed;
await _pumpEventQueue();
final notifier = container.read(
channelMessagesProvider(_channelId).notifier,
);
notifier.addLocalMessage(_event(id: 'z-local', createdAt: 20));
notifier.addLocalMessage(_event(id: 'a-local', createdAt: 20));
relaySession.emit(_event(id: 'z-local', createdAt: 20));
await _pumpEventQueue();
expect(container.read(pendingLocalMessagesProvider(_channelId)).keys, [
'a-local',
]);
expect(
container
.read(channelMessagesProvider(_channelId))
.value
?.map((event) => event.id),
['history', 'z-local', 'a-local'],
);
},
);
test(
'a live reply reaches the store so its parent badge can count it',
() async {
final relaySession = _RecordingRelaySessionNotifier(
queryResults: [
[_event(id: 'root', createdAt: 10), _bounds()],
],
);
final container = _buildContainer(relaySession);
addTearDown(container.dispose);
container.read(channelMessagesProvider(_channelId));
await relaySession.subscribed;
await _pumpEventQueue();
relaySession.emit(
_event(
id: 'reply',
createdAt: 20,
extraTags: const [
['e', 'root', '', 'reply'],
],
),
);
await _pumpEventQueue();
// The reply is retained as the local half of the summary merge. It is
// filtered out of the main timeline by `buildMainTimelineEntries`, which
// owns reply visibility.
expect(
container
.read(channelMessagesProvider(_channelId))
.value
?.map((event) => event.id),
['root', 'reply'],
);
expect(
buildMainTimelineEntries(
formatTimeline(
container.read(channelMessagesProvider(_channelId)).value!,
),
relaySummaries: container
.read(channelMessagesProvider(_channelId).notifier)
.threadSummaries,
).map((entry) => entry.message.id),
['root'],
);
},
);
test('a reply newer than the relay recount raises the badge', () async {
final relaySession = _RecordingRelaySessionNotifier(
queryResults: [
[_event(id: 'root', createdAt: 10), _bounds()],
],
);
final container = _buildContainer(relaySession);
addTearDown(container.dispose);
container.read(channelMessagesProvider(_channelId));
await relaySession.subscribed;
await _pumpEventQueue();
relaySession.emit(
_event(
id: 'reply-1',
createdAt: 20,
extraTags: const [
['e', 'root', '', 'reply'],
],
),
);
relaySession.emit(_summary(rootId: 'root', replyCount: 1, createdAt: 20));
// A second reply lands, and its recount is lost or still in flight.
relaySession.emit(
_event(
id: 'reply-2',
createdAt: 21,
extraTags: const [
['e', 'root', '', 'reply'],
],
),
);
await _pumpEventQueue();
final notifier = container.read(
channelMessagesProvider(_channelId).notifier,
);
expect(notifier.threadSummaries['root']?.replyCount, 1);
final entries = buildMainTimelineEntries(
formatTimeline(
container.read(channelMessagesProvider(_channelId)).value!,
),
relaySummaries: notifier.threadSummaries,
);
expect(entries.single.message.id, 'root');
expect(entries.single.summary!.replyCount, 2);
expect(entries.single.summary!.lastReplyAt, 21);
});
test('window pagination failures return false without exhausting', () async {
final relaySession = _RecordingRelaySessionNotifier(
queryResults: [
[
_event(id: 'head', createdAt: 20),
_bounds(hasMore: true, cursorCreatedAt: 20, cursorId: 'head'),
],
Exception('page failed'),
[
_event(id: 'older', createdAt: 10),
_bounds(dTag: '${_channelId.toLowerCase()}:20:head'),
],
],
);
final container = _buildContainer(relaySession);
addTearDown(container.dispose);
container.read(channelMessagesProvider(_channelId));
await relaySession.subscribed;
await _pumpEventQueue();
final notifier = container.read(
channelMessagesProvider(_channelId).notifier,
);
expect(notifier.reachedOldest, isFalse);
await expectLater(notifier.fetchOlder(), completion(isFalse));
expect(notifier.reachedOldest, isFalse);
await expectLater(notifier.fetchOlder(), completion(isTrue));
expect(notifier.reachedOldest, isTrue);
expect(
container
.read(channelMessagesProvider(_channelId))
.value
?.map((e) => e.id),
['older', 'head'],
);
});
}
const _channelId = '11111111-1111-4111-8111-111111111111';
ProviderContainer _buildContainer(_RecordingRelaySessionNotifier relaySession) {
return ProviderContainer(
overrides: [relaySessionProvider.overrideWith(() => relaySession)],
);
}
NostrEvent _event({
required String id,
required int createdAt,
List<List<String>> extraTags = const [],
}) {
return NostrEvent(
id: id,
pubkey: 'alice',
createdAt: createdAt,
kind: EventKind.streamMessageV2,
tags: [
['h', _channelId],
...extraTags,
],
content: id,
sig: 'sig',
);
}
NostrEvent _summary({
required String rootId,
required int replyCount,
int createdAt = 20,
}) {
return NostrEvent(
id: 'summary-$rootId-$createdAt-$replyCount',
pubkey: 'relay',
createdAt: createdAt,
kind: EventKind.channelThreadSummary,
tags: [
['h', _channelId],
['e', rootId],
],
content: jsonEncode({
'reply_count': replyCount,
'descendant_count': replyCount,
'last_reply_at': 20,
'participants': ['alice'],
}),
sig: 'sig',
);
}
NostrEvent _bounds({
bool hasMore = false,
int? cursorCreatedAt,
String? cursorId,
String? dTag,
}) {
return NostrEvent(
id: 'bounds-$hasMore-${cursorId ?? dTag ?? 'none'}',
pubkey: 'relay',
createdAt: 0,
kind: EventKind.channelWindowBounds,
tags: [
['d', dTag ?? '${_channelId.toLowerCase()}:head'],
],
content: jsonEncode({
'has_more': hasMore,
'next_cursor': hasMore
? {'created_at': cursorCreatedAt, 'id': cursorId}
: null,
}),
sig: 'sig',
);
}
Future<void> _pumpEventQueue() async {
await Future<void>.delayed(Duration.zero);
await Future<void>.delayed(Duration.zero);
}
class _RecordingRelaySessionNotifier extends RelaySessionNotifier {
final bool failSubscribe;
final Queue<Object> _queryResults;
final List<String> operations = [];
final List<NostrFilter> liveFilters = [];
final List<NostrFilter> historyFilters = [];
final List<NostrFilter> queryFilters = [];
final List<void Function(NostrEvent)> _listeners = [];
final Completer<void> _subscribed = Completer<void>();
final Completer<List<NostrEvent>> _history = Completer<List<NostrEvent>>();
final Queue<Completer<List<NostrEvent>>> _targetHistories = Queue();
_RecordingRelaySessionNotifier({
this.failSubscribe = false,
List<Object> queryResults = const [],
}) : _queryResults = Queue<Object>.of(queryResults);
Future<void> get subscribed => _subscribed.future;
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
void setConnected(bool connected) {
state = SessionState(
status: connected ? SessionStatus.connected : SessionStatus.disconnected,
);
}
@override
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) async {
operations.add('query');
queryFilters.addAll(filters);
if (_queryResults.isEmpty) throw Exception('unsupported');
final result = _queryResults.removeFirst();
if (result is Exception) throw result;
if (result is Future<List<NostrEvent>>) return await result;
return (result as List<NostrEvent>).toList();
}
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) {
operations.add('fetch');
historyFilters.add(filter);
if (filter.ids != null) {
final completer = Completer<List<NostrEvent>>();
_targetHistories.add(completer);
return completer.future;
}
return _history.future;
}
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) async {
operations.add('subscribe');
liveFilters.add(filter);
if (!_subscribed.isCompleted) {
_subscribed.complete();
}
if (failSubscribe) {
throw Exception('subscribe failed');
}
_listeners.add(onEvent);
return () {
_listeners.remove(onEvent);
};
}
void emit(NostrEvent event) {
for (final listener in List.of(_listeners)) {
listener(event);
}
}
void completeTargetHistory(List<NostrEvent> events) {
_targetHistories.removeFirst().complete(events);
}
void completeHistory(List<NostrEvent> events) {
if (!_history.isCompleted) {
_history.complete(events);
}
}
void failHistory(Object error) {
if (!_history.isCompleted) {
_history.completeError(error);
}
}
}
@@ -0,0 +1,407 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:buzz/features/channels/channel_sections/channel_sections_manager.dart';
import 'package:buzz/shared/relay/relay.dart';
void main() {
late SharedPreferences prefs;
late nostr.Keys keychain;
late ChannelSectionsCrypto crypto;
Future<void> setUpEnv() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
keychain = nostr.Keys.generate();
crypto = ChannelSectionsCrypto(keychain.nsec, keychain.public);
}
NostrEvent sectionsEvent({
required List<Map<String, dynamic>> sections,
Map<String, String> assignments = const {},
required int createdAt,
String id = 'remote-event',
}) {
final payload = jsonEncode({
'version': 1,
'sections': sections,
'assignments': assignments,
});
return NostrEvent(
id: id,
pubkey: keychain.public,
createdAt: createdAt,
kind: EventKind.readState,
tags: const [
['d', 'channel-sections'],
['t', 'channel-sections'],
],
content: crypto.encrypt(payload),
sig: 'sig',
);
}
ChannelSectionsManager buildManager({
required RelaySessionNotifier relaySession,
Duration startupRetryBaseDelay = const Duration(milliseconds: 5),
}) {
return ChannelSectionsManager(
pubkey: keychain.public,
prefs: prefs,
crypto: crypto,
relaySession: relaySession,
signedEventRelay: null,
remoteEnabled: true,
onChanged: () {},
startupRetryBaseDelay: startupRetryBaseDelay,
);
}
test('startup fetch rejected by relay rate limit retries until the remote '
'blob is adopted (cold-start regression)', () async {
await setUpEnv();
// Cold start: the relay rejects the first fetch AND the first live
// subscription with `rate-limited: quota exceeded` because the channel
// list fired dozens of REQs first. Pre-fix, both errors were swallowed
// and desktop-created groups never appeared until app data was cleared.
final relay = _RateLimitedRelaySession(
failuresBeforeSuccess: 2,
historyEvents: [
sectionsEvent(
sections: [
{'id': 's1', 'name': 'Desktop Group', 'order': 0},
],
createdAt: 100,
),
],
);
final manager = buildManager(
relaySession: relay,
startupRetryBaseDelay: const Duration(milliseconds: 10),
);
await manager.initialize();
expect(
manager.store.sections,
isEmpty,
reason: 'first fetch lost the rate-limit race',
);
// Wait for the backoff retries (10ms, 20ms, …) to win the race.
await _waitUntil(() => manager.store.sections.isNotEmpty);
expect(manager.store.sections.single.name, 'Desktop Group');
expect(
relay.subscribeCalls,
greaterThan(1),
reason: 'live subscription must be retried too',
);
manager.dispose(flushPending: false);
});
test(
'startup retry stops after fetch and subscription both succeed',
() async {
await setUpEnv();
final relay = _RateLimitedRelaySession(
failuresBeforeSuccess: 1,
historyEvents: [
sectionsEvent(
sections: [
{'id': 's1', 'name': 'Desktop Group', 'order': 0},
],
createdAt: 100,
),
],
);
final manager = buildManager(relaySession: relay);
await manager.initialize();
await _waitUntil(() => manager.store.sections.isNotEmpty);
final fetchCallsAfterSuccess = relay.fetchCalls;
final subscribeCallsAfterSuccess = relay.subscribeCalls;
await Future<void>.delayed(const Duration(milliseconds: 60));
expect(relay.fetchCalls, fetchCallsAfterSuccess);
expect(relay.subscribeCalls, subscribeCallsAfterSuccess);
manager.dispose(flushPending: false);
},
);
test('disposing the manager cancels pending startup retries', () async {
await setUpEnv();
final relay = _RateLimitedRelaySession(failuresBeforeSuccess: 1000);
final manager = buildManager(relaySession: relay);
await manager.initialize();
manager.dispose(flushPending: false);
final fetchCallsAtDispose = relay.fetchCalls;
await Future<void>.delayed(const Duration(milliseconds: 60));
expect(
relay.fetchCalls,
fetchCallsAtDispose,
reason: 'no retries may fire after dispose',
);
});
test('late relay CLOSED after subscribe() reported success retries and '
'eventually adopts remote state', () async {
await setUpEnv();
// Under cold-start load, subscribe() can time out its 500ms readiness
// wait and resolve successfully before the relay's rate-limit CLOSED
// lands. The rejection then arrives only via onClosed. Pre-fix the
// manager passed no onClosed, kept the dead subscription, and never
// retried.
final relay = _RateLimitedRelaySession(failuresBeforeSuccess: 0);
final manager = buildManager(relaySession: relay);
await manager.initialize();
expect(relay.subscribeCalls, 1);
// Late CLOSED lands after initialize() completed successfully.
relay.closeLiveSubscription('rate-limited: quota exceeded');
// The manager must drop the dead subscription and re-subscribe.
await _waitUntil(() => relay.subscribeCalls > 1);
// Remote state arriving on the NEW subscription must be adopted.
relay.emit(
sectionsEvent(
sections: [
{'id': 's1', 'name': 'Desktop Group', 'order': 0},
],
createdAt: 100,
),
);
await _waitUntil(() => manager.store.sections.isNotEmpty);
expect(manager.store.sections.single.name, 'Desktop Group');
// Exactly one replacement subscription — no duplicates.
await Future<void>.delayed(const Duration(milliseconds: 60));
expect(relay.subscribeCalls, 2);
expect(relay.activeListeners, 1);
manager.dispose(flushPending: false);
});
test('late CLOSED after dispose does not schedule retries', () async {
await setUpEnv();
final relay = _RateLimitedRelaySession(failuresBeforeSuccess: 0);
final manager = buildManager(relaySession: relay);
await manager.initialize();
manager.dispose(flushPending: false);
relay.closeLiveSubscription('rate-limited: quota exceeded');
await Future<void>.delayed(const Duration(milliseconds: 60));
expect(relay.subscribeCalls, 1, reason: 'no re-subscribe after dispose');
});
test(
'dispose while subscribe is pending closes the late subscription',
() async {
await setUpEnv();
final relay = _DelayedSubscribeRelaySession();
final manager = buildManager(relaySession: relay);
final initializing = manager.initialize();
await relay.subscribeStarted.future;
manager.dispose(flushPending: false);
relay.completeSubscribe();
await initializing;
expect(relay.activeListeners, 0);
expect(relay.unsubscribeCalls, 1);
},
);
test(
'a retry request during an in-flight sync does not overlap subscribe',
() async {
await setUpEnv();
final relay = _DelayedSubscribeRelaySession();
final manager = buildManager(relaySession: relay);
final initializing = manager.initialize();
await relay.subscribeStarted.future;
relay.closePendingSubscription('rate-limited: quota exceeded');
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(relay.subscribeCalls, 1);
relay.completeSubscribe();
await initializing;
await _waitUntil(() => relay.subscribeCalls == 2);
expect(relay.maxConcurrentSubscribes, 1);
expect(relay.activeListeners, 1);
manager.dispose(flushPending: false);
},
);
test('backoff resets after full recovery so later failures start from the '
'base delay', () async {
await setUpEnv();
// Climb the failure counter: 5 rate-limited attempts before success.
// With a 5ms base, attempt 5 would wait 5<<5 = 160ms if the counter
// were never reset.
final relay = _RateLimitedRelaySession(failuresBeforeSuccess: 5);
final manager = buildManager(relaySession: relay);
await manager.initialize();
await _waitUntil(() => relay.subscribeCalls > 5);
// Fully recovered. A later transient failure (late CLOSED) must retry
// from the base delay, not the climbed ceiling.
final subscribeCallsAfterRecovery = relay.subscribeCalls;
relay.closeLiveSubscription('rate-limited: quota exceeded');
final stopwatch = Stopwatch()..start();
await _waitUntil(() => relay.subscribeCalls > subscribeCallsAfterRecovery);
stopwatch.stop();
expect(
stopwatch.elapsedMilliseconds,
lessThan(100),
reason:
'retry after recovery must wait ~base delay (5ms), '
'not the pre-recovery backoff (160ms)',
);
manager.dispose(flushPending: false);
});
}
Future<void> _waitUntil(
bool Function() condition, {
Duration timeout = const Duration(seconds: 2),
}) async {
final deadline = DateTime.now().add(timeout);
while (!condition()) {
if (DateTime.now().isAfter(deadline)) {
fail('condition not met within $timeout');
}
await Future<void>.delayed(const Duration(milliseconds: 10));
}
}
class _DelayedSubscribeRelaySession extends RelaySessionNotifier {
final subscribeStarted = Completer<void>();
Completer<void Function()>? _pendingSubscribe;
void Function(String)? _pendingOnClosed;
int subscribeCalls = 0;
int concurrentSubscribes = 0;
int maxConcurrentSubscribes = 0;
int activeListeners = 0;
int unsubscribeCalls = 0;
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async => const [];
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) {
subscribeCalls++;
concurrentSubscribes++;
if (concurrentSubscribes > maxConcurrentSubscribes) {
maxConcurrentSubscribes = concurrentSubscribes;
}
if (!subscribeStarted.isCompleted) subscribeStarted.complete();
_pendingOnClosed = onClosed;
if (subscribeCalls > 1) {
concurrentSubscribes--;
activeListeners++;
return Future.value(_unsubscribe);
}
_pendingSubscribe = Completer<void Function()>();
return _pendingSubscribe!.future.whenComplete(() {
concurrentSubscribes--;
});
}
void closePendingSubscription(String message) =>
_pendingOnClosed?.call(message);
void completeSubscribe() {
activeListeners++;
_pendingSubscribe!.complete(_unsubscribe);
}
void _unsubscribe() {
activeListeners--;
unsubscribeCalls++;
}
}
/// Rejects the first [failuresBeforeSuccess] fetch and subscribe calls with
/// the relay's rate-limit error, then succeeds — the exact failure mode seen
/// on Android cold start where the channel-list REQ burst exhausts the
/// relay's per-connection quota before the sections manager gets a turn.
class _RateLimitedRelaySession extends RelaySessionNotifier {
_RateLimitedRelaySession({
required this.failuresBeforeSuccess,
this.historyEvents = const [],
});
final int failuresBeforeSuccess;
final List<NostrEvent> historyEvents;
int fetchCalls = 0;
int subscribeCalls = 0;
final List<void Function(NostrEvent)> _listeners = [];
final List<void Function(String)> _closedCallbacks = [];
int get activeListeners => _listeners.length;
void emit(NostrEvent event) {
for (final listener in List.of(_listeners)) {
listener(event);
}
}
/// Simulates a relay CLOSED landing after subscribe() already resolved —
/// the readiness-timeout window on a loaded cold start. Drops the live
/// subscription (as _handleClosed does) and invokes onClosed.
void closeLiveSubscription(String message) {
if (_listeners.isEmpty) return;
_listeners.removeAt(0);
final onClosed = _closedCallbacks.removeAt(0);
onClosed(message);
}
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
fetchCalls++;
if (fetchCalls <= failuresBeforeSuccess) {
throw Exception('rate-limited: quota exceeded; retry in 2s');
}
return historyEvents;
}
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) async {
subscribeCalls++;
if (subscribeCalls <= failuresBeforeSuccess) {
throw Exception('rate-limited: quota exceeded; retry in 1s');
}
_listeners.add(onEvent);
_closedCallbacks.add(onClosed ?? (_) {});
return () {
final index = _listeners.indexOf(onEvent);
if (index >= 0) {
_listeners.removeAt(index);
_closedCallbacks.removeAt(index);
}
};
}
}
@@ -0,0 +1,43 @@
import 'package:buzz/features/channels/channel_sections/channel_sections_storage.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('ChannelSection icon', () {
test('round-trips through fromJson and toJson', () {
const original = ChannelSection(
id: 'section-1',
name: 'Friends',
icon: ':party_parrot:',
order: 0,
);
final roundTrip = ChannelSection.fromJson(original.toJson());
expect(roundTrip.icon, ':party_parrot:');
expect(roundTrip.toJson(), original.toJson());
});
test('missing icon remains absent', () {
final section = ChannelSection.fromJson({
'id': 'section-1',
'name': 'Friends',
'order': 0,
});
expect(section.icon, isNull);
expect(section.toJson(), isNot(contains('icon')));
});
test('empty icon round-trips', () {
final section = ChannelSection.fromJson({
'id': 'section-1',
'name': 'Friends',
'icon': '',
'order': 0,
});
expect(section.icon, isEmpty);
expect(section.toJson()['icon'], '');
});
});
}
@@ -0,0 +1,377 @@
import 'dart:async';
import 'dart:convert';
import 'package:buzz/features/channels/channel_sort/channel_sort_manager.dart';
import 'package:buzz/features/channels/channel_sort/channel_sort_storage.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
void main() {
late SharedPreferences prefs;
late nostr.Keys keys;
late ChannelSortCrypto crypto;
setUp(() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
keys = nostr.Keys.generate();
crypto = ChannelSortCrypto(keys.nsec, keys.public);
});
NostrEvent event(
Map<String, String> groups,
int createdAt, {
String id = 'e',
}) => NostrEvent(
id: id,
pubkey: keys.public,
createdAt: createdAt,
kind: EventKind.readState,
tags: const [
['d', 'channel-sort'],
],
content: crypto.encrypt(jsonEncode({'version': 1, 'groups': groups})),
sig: 'sig',
);
ChannelSortManager manager(
_FakeRelaySession relay,
_RecordingSignedEventRelay signed, {
Duration retry = const Duration(milliseconds: 5),
}) => ChannelSortManager(
pubkey: keys.public,
relayUrl: 'wss://relay.example',
prefs: prefs,
crypto: crypto,
relaySession: relay,
signedEventRelay: signed,
remoteEnabled: true,
onChanged: () {},
startupRetryBaseDelay: retry,
publishDelay: const Duration(milliseconds: 5),
);
test(
'adopts desktop payload and defaults unspecified groups to alpha',
() async {
final relay = _FakeRelaySession()
..historyEvents = [
event({'channels': 'recent', 'section:abc': 'recent'}, 100),
];
final subject = manager(relay, _RecordingSignedEventRelay());
await subject.initialize();
expect(subject.sortModeFor('channels'), ChannelSortMode.recent);
expect(subject.sortModeFor('section:abc'), ChannelSortMode.recent);
expect(subject.sortModeFor('dms'), ChannelSortMode.alpha);
subject.dispose();
},
);
test('publishes local edit using desktop encrypted wire format', () async {
final relay = _FakeRelaySession();
final signed = _RecordingSignedEventRelay();
final subject = manager(relay, signed);
await subject.initialize();
subject.setSortModeFor('dms', ChannelSortMode.recent);
final submitted = await signed.submitted.future.timeout(
const Duration(seconds: 1),
);
expect(
submitted.tags.any((tag) => tag[0] == 'd' && tag[1] == 'channel-sort'),
isTrue,
);
final payload =
jsonDecode(crypto.decrypt(submitted.content)) as Map<String, dynamic>;
expect(payload, {
'version': 1,
'groups': {'dms': 'recent'},
});
subject.dispose();
});
test(
'failed publish preflight retries without submitting stale state',
() async {
final relay = _FakeRelaySession();
final signed = _RecordingSignedEventRelay();
final subject = manager(relay, signed);
await subject.initialize();
relay.fetchFailures = 1;
subject.setSortModeFor('dms', ChannelSortMode.recent);
await Future<void>.delayed(const Duration(milliseconds: 8));
expect(signed.submitCount, 0);
final submitted = await signed.submitted.future.timeout(
const Duration(seconds: 1),
);
expect(relay.fetchCount, greaterThanOrEqualTo(4));
final payload =
jsonDecode(crypto.decrypt(submitted.content)) as Map<String, dynamic>;
expect(payload['groups'], {'dms': 'recent'});
subject.dispose();
},
);
test(
'pending local edit survives manager rebuild and older relay state',
() async {
final firstRelay = _FakeRelaySession();
final first = manager(firstRelay, _RecordingSignedEventRelay());
await first.initialize();
first.setSortModeFor('channels', ChannelSortMode.recent);
first.dispose();
final secondRelay = _FakeRelaySession()
..historyEvents = [
event({'dms': 'recent'}, 100),
];
final signed = _RecordingSignedEventRelay();
final second = manager(secondRelay, signed);
await second.initialize();
expect(second.store.groups, {'channels': ChannelSortMode.recent});
final submitted = await signed.submitted.future.timeout(
const Duration(seconds: 1),
);
final payload =
jsonDecode(crypto.decrypt(submitted.content)) as Map<String, dynamic>;
expect(payload['groups'], {'channels': 'recent'});
second.dispose();
},
);
test('newer remote event cancels pending local whole-blob write', () async {
final relay = _FakeRelaySession();
final signed = _RecordingSignedEventRelay();
final subject = manager(relay, signed);
await subject.initialize();
subject.setSortModeFor('channels', ChannelSortMode.recent);
relay.emit(
event({
'dms': 'recent',
}, DateTime.now().millisecondsSinceEpoch ~/ 1000 + 1),
);
await Future<void>.delayed(const Duration(milliseconds: 30));
expect(subject.store.groups, {'dms': ChannelSortMode.recent});
expect(signed.submitted.isCompleted, isFalse);
subject.dispose();
});
test('same-second remote does not erase a pending local edit', () async {
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
ChannelSortStorage(prefs).writeSyncState(
keys.public,
'wss://relay.example',
ChannelSortSyncState(updatedAt: now - 1, eventId: 'remote'),
);
final relay = _FakeRelaySession();
final signed = _RecordingSignedEventRelay();
final subject = manager(relay, signed);
await subject.initialize();
subject.setSortModeFor('channels', ChannelSortMode.recent);
relay.emit(event({'dms': 'recent'}, now, id: 'a'));
expect(subject.store.groups, {'channels': ChannelSortMode.recent});
await signed.submitted.future.timeout(const Duration(seconds: 1));
subject.dispose();
});
test('publishing does not advance the persisted remote cursor', () async {
final storage = ChannelSortStorage(prefs);
storage.writeSyncState(
keys.public,
'wss://relay.example',
const ChannelSortSyncState(updatedAt: 100, eventId: 'remote'),
);
final signed = _RecordingSignedEventRelay();
final subject = manager(_FakeRelaySession(), signed);
await subject.initialize();
subject.setSortModeFor('channels', ChannelSortMode.recent);
await signed.submitted.future.timeout(const Duration(seconds: 1));
await Future<void>.delayed(Duration.zero);
final syncState = storage.readSyncState(keys.public, 'wss://relay.example');
expect(syncState.updatedAt, 100);
expect(syncState.eventId, 'remote');
expect(syncState.hasPendingLocalChanges, isFalse);
expect(syncState.pendingUpdatedAt, 0);
subject.dispose();
});
test('legacy pending state resets its ambiguous remote cursor', () {
final storage = ChannelSortStorage(prefs);
// Simulate the pre-migration JSON, which had no pendingUpdatedAt field.
prefs.setString(
'${channelSortKey(keys.public, 'wss://relay.example')}:sync',
jsonEncode({
'updatedAt': 4102444800,
'eventId': 'local-publication',
'hasPendingLocalChanges': true,
}),
);
final syncState = storage.readSyncState(keys.public, 'wss://relay.example');
expect(syncState.updatedAt, 0);
expect(syncState.eventId, isEmpty);
expect(syncState.pendingUpdatedAt, 4102444800);
});
test('lower event ID wins when remote timestamps tie', () async {
final relay = _FakeRelaySession()
..historyEvents = [
event({'channels': 'recent'}, 200, id: 'f'),
];
final subject = manager(relay, _RecordingSignedEventRelay());
await subject.initialize();
relay.emit(event({'dms': 'recent'}, 200, id: 'a'));
expect(subject.store.groups, {'dms': ChannelSortMode.recent});
relay.emit(event({'starred': 'recent'}, 200, id: 'z'));
expect(subject.store.groups, {'dms': ChannelSortMode.recent});
subject.dispose();
});
test('adopts a newer relay replacement after publishing', () async {
final relay = _FakeRelaySession();
final signed = _RecordingSignedEventRelay();
final subject = manager(relay, signed);
await subject.initialize();
subject.setSortModeFor('channels', ChannelSortMode.recent);
final submitted = await signed.submitted.future.timeout(
const Duration(seconds: 1),
);
relay.emit(event({'dms': 'recent'}, submitted.createdAt! + 1, id: 'a'));
expect(subject.store.groups, {'dms': ChannelSortMode.recent});
subject.dispose();
});
test(
'future-dated remote event is ignored and cannot wedge publishing',
() async {
final relay = _FakeRelaySession()
..historyEvents = [
event({'channels': 'recent'}, 4102444800),
];
final signed = _RecordingSignedEventRelay();
final subject = manager(relay, signed);
await subject.initialize();
expect(subject.sortModeFor('channels'), ChannelSortMode.alpha);
subject.setSortModeFor('dms', ChannelSortMode.recent);
await signed.submitted.future.timeout(const Duration(seconds: 1));
subject.dispose();
},
);
test('retries failed startup and closes fetch-subscribe gap', () async {
final relay = _FakeRelaySession()..fetchFailures = 1;
final subject = manager(relay, _RecordingSignedEventRelay());
await subject.initialize();
relay.historyEvents = [
event({'starred': 'recent'}, 300),
];
await Future<void>.delayed(const Duration(milliseconds: 40));
expect(subject.sortModeFor('starred'), ChannelSortMode.recent);
expect(relay.fetchCount, greaterThanOrEqualTo(3));
subject.dispose();
});
test('setSortModeFor prunes deleted custom section keys', () async {
final subject = manager(_FakeRelaySession(), _RecordingSignedEventRelay());
await subject.initialize();
subject.setSortModeFor('section:dead', ChannelSortMode.recent);
subject.setSortModeFor(
'channels',
ChannelSortMode.recent,
liveSectionIds: ['live'],
);
expect(subject.store.groups.keys, ['channels']);
subject.dispose();
});
}
class _SubmittedEvent {
final String content;
final List<List<String>> tags;
final int? createdAt;
const _SubmittedEvent(this.content, this.tags, this.createdAt);
}
class _RecordingSignedEventRelay implements SignedEventRelay {
final submitted = Completer<_SubmittedEvent>();
int submitCount = 0;
@override
String? get pubkey => null;
@override
Future<NostrEvent> submit({
required int kind,
required String content,
required List<List<String>> tags,
int? createdAt,
void Function(NostrEvent event)? onSigned,
}) async {
submitCount++;
onSigned?.call(
const NostrEvent(
id: 'signed-event',
pubkey: '',
createdAt: 0,
kind: 0,
tags: [],
content: '',
sig: '',
),
);
if (!submitted.isCompleted) {
submitted.complete(_SubmittedEvent(content, tags, createdAt));
}
return const NostrEvent(
id: 'ack',
pubkey: '',
createdAt: 0,
kind: 0,
tags: [],
content: '',
sig: '',
);
}
}
class _FakeRelaySession extends RelaySessionNotifier {
List<NostrEvent> historyEvents = [];
int fetchFailures = 0;
int fetchCount = 0;
void Function(NostrEvent)? _listener;
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
fetchCount++;
if (fetchFailures > 0) {
fetchFailures--;
throw Exception('rate limited');
}
return historyEvents;
}
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) async {
_listener = onEvent;
return () => _listener = null;
}
void emit(NostrEvent event) => _listener?.call(event);
}
@@ -0,0 +1,124 @@
import 'dart:convert';
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channel_sort/channel_sort_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
group('ChannelSortStore JSON', () {
test('round-trips desktop wire format and drops invalid modes', () {
final store = ChannelSortStore(
groups: {
'channels': ChannelSortMode.recent,
'dms': ChannelSortMode.alpha,
},
);
expect(store.toJson()['groups'], {'channels': 'recent', 'dms': 'alpha'});
expect(ChannelSortStore.fromJson(store.toJson()).groups, store.groups);
expect(
ChannelSortStore.fromJson({
'version': 1,
'groups': {'channels': 'recent', 'starred': 'bogus'},
}).groups,
{'channels': ChannelSortMode.recent},
);
});
});
group('ChannelSortStorage', () {
test('normalizes relay scope and isolates communities', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final storage = ChannelSortStorage(prefs);
final store = ChannelSortStore(
groups: {'channels': ChannelSortMode.recent},
);
storage.write('pk', ' WSS://Relay.Example/ ', store);
expect(storage.read('pk', 'wss://relay.example').groups, store.groups);
expect(storage.read('pk', 'wss://other.example').groups, isEmpty);
});
test('migrates legacy unscoped cache into the first relay scope', () async {
SharedPreferences.setMockInitialValues({
legacyChannelSortKey('pk'): jsonEncode({
'version': 1,
'groups': {'dms': 'recent'},
}),
});
final prefs = await SharedPreferences.getInstance();
final storage = ChannelSortStorage(prefs);
expect(storage.read('pk', 'wss://one').groups, {
'dms': ChannelSortMode.recent,
});
expect(prefs.getString(channelSortKey('pk', 'wss://one')), isNotNull);
expect(prefs.getString(legacyChannelSortKey('pk')), isNull);
expect(storage.read('pk', 'wss://two').groups, isEmpty);
});
test('ignores corrupt and unsupported payloads', () async {
SharedPreferences.setMockInitialValues({
channelSortKey('pk', 'wss://one'): 'nope',
channelSortKey('pk', 'wss://two'): '{"version":2,"groups":{}}',
});
final prefs = await SharedPreferences.getInstance();
final storage = ChannelSortStorage(prefs);
expect(storage.read('pk', 'wss://one').groups, isEmpty);
expect(storage.read('pk', 'wss://two').groups, isEmpty);
});
});
test('prunes orphaned section modes but keeps fixed groups', () {
final store = ChannelSortStore(
groups: {
'channels': ChannelSortMode.recent,
'section:live': ChannelSortMode.recent,
'section:dead': ChannelSortMode.alpha,
},
);
expect(stripOrphanedSectionModes(store, ['live']).groups.keys, [
'channels',
'section:live',
]);
});
group('sortChannelsForList', () {
Channel channel(String id, String name, {DateTime? lastMessageAt}) =>
Channel(
id: id,
name: name,
channelType: 'stream',
visibility: 'open',
description: '',
createdBy: 'pk',
createdAt: DateTime.utc(2026),
memberCount: 1,
lastMessageAt: lastMessageAt,
);
test('alpha matches desktop code-unit collation and id tie-break', () {
final sorted = sortChannelsForList([
channel('2', 'zeta'),
channel('3', 'Alpha'),
channel('1', 'alpha'),
channel('4', 'Éclair'),
], ChannelSortMode.alpha);
expect(sorted.map((c) => c.id), ['1', '3', '2', '4']);
});
test('recent puts newest first and quiet channels alpha last', () {
final now = DateTime.utc(2026, 7, 25);
final sorted = sortChannelsForList([
channel('a', 'quiet-z'),
channel(
'b',
'old',
lastMessageAt: now.subtract(const Duration(days: 2)),
),
channel('c', 'new', lastMessageAt: now),
channel('d', 'quiet-a'),
], ChannelSortMode.recent);
expect(sorted.map((c) => c.id), ['c', 'b', 'd', 'a']);
});
});
}
@@ -0,0 +1,114 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/features/channels/channel_stars/channel_stars_storage.dart';
/// Tests for [ChannelStarStore] parsing and [mergeStores] last-writer-wins.
void main() {
group('ChannelStarStore.fromJson', () {
test('parses a valid payload', () {
final store = ChannelStarStore.fromJson({
'version': 1,
'channels': {
'chan-1': {'starred': true, 'updatedAt': 1000},
'chan-2': {'starred': false, 'updatedAt': 2000},
},
});
expect(store.channels.length, 2);
expect(store.channels['chan-1']!.starred, isTrue);
expect(store.channels['chan-1']!.updatedAt, 1000);
expect(store.channels['chan-2']!.starred, isFalse);
});
test('drops malformed entries (missing/wrong-typed fields)', () {
final store = ChannelStarStore.fromJson({
'version': 1,
'channels': {
'no-starred': {'updatedAt': 1000},
'no-updated-at': {'starred': true},
'starred-wrong-type': {'starred': 'yes', 'updatedAt': 1000},
'updated-wrong-type': {'starred': true, 'updatedAt': 'now'},
'valid': {'starred': true, 'updatedAt': 500},
},
});
expect(store.channels.keys, ['valid']);
expect(store.channels['valid']!.updatedAt, 500);
});
test('empty / missing channels yields empty store', () {
expect(ChannelStarStore.fromJson({'version': 1}).channels, isEmpty);
expect(
ChannelStarStore.fromJson({'version': 1, 'channels': {}}).channels,
isEmpty,
);
});
test('round-trips through toJson', () {
const original = ChannelStarStore(
channels: {'c': ChannelStarEntry(starred: true, updatedAt: 42)},
);
final round = ChannelStarStore.fromJson(original.toJson());
expect(round.channels['c']!.starred, isTrue);
expect(round.channels['c']!.updatedAt, 42);
});
});
group('mergeStores (per-channel max-updatedAt)', () {
test('union of non-overlapping channels', () {
final merged = mergeStores(
const ChannelStarStore(
channels: {'a': ChannelStarEntry(starred: true, updatedAt: 100)},
),
const ChannelStarStore(
channels: {'b': ChannelStarEntry(starred: false, updatedAt: 200)},
),
);
expect(merged.channels.keys.toSet(), {'a', 'b'});
});
test('higher updatedAt wins (remote newer)', () {
final merged = mergeStores(
const ChannelStarStore(
channels: {'a': ChannelStarEntry(starred: false, updatedAt: 100)},
),
const ChannelStarStore(
channels: {'a': ChannelStarEntry(starred: true, updatedAt: 200)},
),
);
expect(merged.channels['a']!.starred, isTrue);
expect(merged.channels['a']!.updatedAt, 200);
});
test('higher updatedAt wins (local newer)', () {
final merged = mergeStores(
const ChannelStarStore(
channels: {'a': ChannelStarEntry(starred: true, updatedAt: 300)},
),
const ChannelStarStore(
channels: {'a': ChannelStarEntry(starred: false, updatedAt: 100)},
),
);
expect(merged.channels['a']!.starred, isTrue);
expect(merged.channels['a']!.updatedAt, 300);
});
test('unstar with higher updatedAt overrides star', () {
final merged = mergeStores(
const ChannelStarStore(
channels: {'a': ChannelStarEntry(starred: true, updatedAt: 100)},
),
const ChannelStarStore(
channels: {'a': ChannelStarEntry(starred: false, updatedAt: 999)},
),
);
expect(merged.channels['a']!.starred, isFalse);
expect(merged.channels['a']!.updatedAt, 999);
});
test('both empty yields empty', () {
final merged = mergeStores(
const ChannelStarStore(),
const ChannelStarStore(),
);
expect(merged.channels, isEmpty);
});
});
}
@@ -0,0 +1,244 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/features/channels/channel.dart';
void main() {
group('Channel.fromJson', () {
test('parses a full channel response', () {
final json = {
'id': 'abc-123',
'name': 'general',
'channel_type': 'stream',
'visibility': 'open',
'description': 'General discussion',
'topic': 'Welcome!',
'purpose': 'Team chat',
'created_by': 'deadbeef',
'created_at': '2025-01-01T00:00:00+00:00',
'member_count': 42,
'last_message_at': '2025-06-01T12:00:00+00:00',
'archived_at': null,
'participants': ['Alice', 'Bob'],
'participant_pubkeys': ['alice', 'bob'],
'is_member': true,
};
final channel = Channel.fromJson(json);
expect(channel.id, 'abc-123');
expect(channel.name, 'general');
expect(channel.channelType, 'stream');
expect(channel.visibility, 'open');
expect(channel.description, 'General discussion');
expect(channel.topic, 'Welcome!');
expect(channel.purpose, 'Team chat');
expect(channel.memberCount, 42);
expect(channel.participants, ['Alice', 'Bob']);
expect(channel.participantPubkeys, ['alice', 'bob']);
expect(channel.isMember, isTrue);
expect(channel.isStream, isTrue);
expect(channel.isForum, isFalse);
expect(channel.isDm, isFalse);
expect(channel.isPrivate, isFalse);
});
test('handles null optional fields', () {
final json = {
'id': 'abc-123',
'name': 'private-chat',
'channel_type': 'stream',
'visibility': 'private',
'description': null,
'topic': null,
'purpose': null,
'created_by': 'deadbeef',
'created_at': '2025-01-01T00:00:00+00:00',
'member_count': 2,
'last_message_at': null,
'archived_at': '2025-01-02T00:00:00+00:00',
'is_member': false,
};
final channel = Channel.fromJson(json);
expect(channel.description, '');
expect(channel.topic, isNull);
expect(channel.lastMessageAt, isNull);
expect(channel.isArchived, isTrue);
expect(channel.isMember, isFalse);
expect(channel.isPrivate, isTrue);
});
test('defaults is_member to false when missing', () {
final json = {
'id': 'abc-123',
'name': 'test',
'channel_type': 'forum',
'visibility': 'open',
'created_by': 'deadbeef',
'created_at': '2025-01-01T00:00:00+00:00',
'member_count': 0,
};
final channel = Channel.fromJson(json);
expect(channel.isMember, isFalse);
expect(channel.isForum, isTrue);
});
});
group('Channel.displayLabel', () {
Channel makeDm({
List<String> participants = const [],
List<String> participantPubkeys = const [],
}) => Channel(
id: '1',
name: 'dm-name',
channelType: 'dm',
visibility: 'open',
description: '',
createdBy: 'x',
createdAt: DateTime(2025),
memberCount: 2,
participants: participants,
participantPubkeys: participantPubkeys,
);
test('returns name for non-DM channels', () {
final channel = Channel(
id: '1',
name: 'general',
channelType: 'stream',
visibility: 'open',
description: '',
createdBy: 'x',
createdAt: DateTime(2025),
memberCount: 1,
);
expect(channel.displayLabel(), 'general');
expect(channel.displayLabel(currentPubkey: 'abc'), 'general');
});
test('returns name for DM with empty participants', () {
final channel = makeDm();
expect(channel.displayLabel(), 'dm-name');
});
test('returns all participants when no currentPubkey', () {
final channel = makeDm(
participants: ['Alice', 'Bob'],
participantPubkeys: ['aaa', 'bbb'],
);
expect(channel.displayLabel(), 'Alice, Bob');
});
test('filters out current user by pubkey', () {
final channel = makeDm(
participants: ['You', 'Alice'],
participantPubkeys: ['self', 'alice'],
);
expect(channel.displayLabel(currentPubkey: 'SELF'), 'Alice');
});
test('falls back to all participants when self is only participant', () {
final channel = makeDm(
participants: ['You'],
participantPubkeys: ['self'],
);
expect(channel.displayLabel(currentPubkey: 'self'), 'You');
});
test('handles mismatched participants and pubkeys lengths', () {
final channel = makeDm(
participants: ['Alice', 'Bob', 'Carol'],
participantPubkeys: ['alice'],
);
// Only index 0 has a pubkey; indexes 1-2 have no pubkey to match,
// so they always appear. Filtering out 'alice' leaves Bob and Carol.
expect(channel.displayLabel(currentPubkey: 'alice'), 'Bob, Carol');
});
});
group('Channel.copyWith', () {
final base = Channel(
id: '1',
name: 'test',
channelType: 'stream',
visibility: 'open',
description: '',
createdBy: 'x',
createdAt: DateTime(2025),
memberCount: 5,
archivedAt: DateTime(2025, 1, 2),
lastMessageAt: DateTime(2025, 6, 1),
isMember: true,
);
test('can explicitly null out archivedAt', () {
final updated = base.copyWith(archivedAt: null);
expect(updated.archivedAt, isNull);
});
test('can explicitly null out lastMessageAt', () {
final updated = base.copyWith(lastMessageAt: null);
expect(updated.lastMessageAt, isNull);
});
test('preserves archivedAt when not specified', () {
final updated = base.copyWith(memberCount: 10);
expect(updated.archivedAt, base.archivedAt);
expect(updated.memberCount, 10);
});
test('can set new archivedAt value', () {
final newDate = DateTime(2026);
final updated = base.copyWith(archivedAt: newDate);
expect(updated.archivedAt, newDate);
});
});
group('Channel.canAddMembers', () {
Channel make({required String channelType, required String visibility}) =>
Channel(
id: '1',
name: 'c',
channelType: channelType,
visibility: visibility,
description: '',
createdBy: 'x',
createdAt: DateTime(2025),
memberCount: 2,
);
test('open channels accept adds from anyone', () {
final channel = make(channelType: 'stream', visibility: 'open');
expect(channel.canAddMembers(null), isTrue);
expect(channel.canAddMembers('member'), isTrue);
});
test('private channels accept adds only from owners/admins', () {
final channel = make(channelType: 'stream', visibility: 'private');
expect(channel.canAddMembers('owner'), isTrue);
expect(channel.canAddMembers('admin'), isTrue);
expect(channel.canAddMembers('member'), isFalse);
expect(channel.canAddMembers('bot'), isFalse);
expect(channel.canAddMembers(null), isFalse);
});
test('DMs never accept adds', () {
expect(
make(channelType: 'dm', visibility: 'open').canAddMembers('owner'),
isFalse,
);
expect(
make(channelType: 'dm', visibility: 'private').canAddMembers('owner'),
isFalse,
);
});
test('unknown visibility fails closed for non-elevated callers', () {
final channel = make(channelType: 'stream', visibility: 'mystery');
expect(channel.canAddMembers('member'), isFalse);
expect(channel.canAddMembers('owner'), isTrue);
});
});
}
@@ -0,0 +1,523 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/features/channels/channel_window.dart';
import 'package:buzz/shared/relay/relay.dart';
void main() {
group('parseChannelWindowResponse', () {
test('requires exactly one matching bounds event', () {
expect(
() => parseChannelWindowResponse([_row('a')], _channelId, null),
throwsException,
);
expect(
() => parseChannelWindowResponse(
[_row('a'), _bounds(), _bounds(id: 'b2')],
_channelId,
null,
),
throwsException,
);
expect(
() => parseChannelWindowResponse(
[_row('a'), _bounds(dTag: 'wrong:head')],
_channelId,
null,
),
throwsException,
);
});
test('rejects hasMore and nextCursor disagreement', () {
expect(
() => parseChannelWindowResponse(
[
_row('a'),
_bounds(content: {'has_more': true, 'next_cursor': null}),
],
_channelId,
null,
),
throwsException,
);
});
test('attaches summaries and keeps overlays out of rows', () {
final page = parseChannelWindowResponse(
[
_row('root'),
_summary('root', replyCount: 3, participants: ['p1', 'p2']),
_reaction('reaction'),
_bounds(),
],
_channelId,
null,
);
expect(page.rows.map((row) => row.event.id), ['root']);
expect(page.rows.single.thread?.replyCount, 3);
expect(page.rows.single.thread?.participantPubkeys, ['p1', 'p2']);
expect(page.aux.map((event) => event.id), ['reaction']);
});
});
group('ChannelWindowStore', () {
test('rejects cursor interval and row order violations', () {
expect(
() => appendOlderChannelWindow(
replaceNewestChannelWindow(
const ChannelWindowStore.empty(),
_page(rows: [_row('head', createdAt: 20)], hasMore: true),
),
_page(
startCursor: const ChannelPageCursor(
createdAt: 20,
eventId: 'head',
),
rows: [_row('newer', createdAt: 21)],
),
),
throwsException,
);
expect(
() => replaceNewestChannelWindow(
const ChannelWindowStore.empty(),
_page(rows: [_row('b', createdAt: 9), _row('a', createdAt: 10)]),
),
throwsException,
);
});
test('rejects overlapping older pages and drops tail on head refresh', () {
final head = replaceNewestChannelWindow(
const ChannelWindowStore.empty(),
_page(rows: [_row('a', createdAt: 10)], hasMore: true),
);
final withOlder = appendOlderChannelWindow(
head,
_page(
startCursor: const ChannelPageCursor(createdAt: 10, eventId: 'a'),
rows: [_row('z', createdAt: 9)],
),
);
expect(withOlder.pages, hasLength(2));
expect(
() => appendOlderChannelWindow(
head,
_page(
startCursor: const ChannelPageCursor(createdAt: 10, eventId: 'a'),
rows: [_row('a', createdAt: 9)],
),
),
throwsException,
);
final refreshed = replaceNewestChannelWindow(
withOlder,
_page(rows: [_row('b', createdAt: 11)]),
);
expect(refreshed.pages, hasLength(1));
expect(refreshed.pages.single.rows.single.event.id, 'b');
});
test('drops live rows at or older than oldest loaded boundary', () {
final store = replaceNewestChannelWindow(
const ChannelWindowStore.empty(),
_page(rows: [_row('a', createdAt: 10), _row('m', createdAt: 9)]),
);
final ignored = mergeLiveChannelWindowEvent(
store,
_row('z', createdAt: 8),
isTimelineRow: true,
);
expect(identical(ignored, store), isTrue);
final merged = mergeLiveChannelWindowEvent(
store,
_row('live', createdAt: 11),
isTimelineRow: true,
);
expect(merged.liveOverlay.map((event) => event.id), ['live']);
});
});
group('live thread summaries', () {
test('a live summary gives a loaded row its reply count', () {
final store = replaceNewestChannelWindow(
const ChannelWindowStore.empty(),
_page(rows: [_row('root', createdAt: 10)]),
);
// No summary yet: the row was fetched before anyone replied.
expect(channelWindowThreadSummaries(store), isEmpty);
final merged = mergeLiveChannelWindowEvent(
store,
_summary('root', replyCount: 1, participants: ['p1']),
isTimelineRow: false,
);
expect(channelWindowThreadSummaries(merged)['root']?.replyCount, 1);
});
test('a live summary covers a root that is only in the overlay', () {
// A message you just sent has no page row yet, so its count has nowhere
// to live except beside the pages.
final store = mergeLiveChannelWindowEvent(
replaceNewestChannelWindow(
const ChannelWindowStore.empty(),
_page(rows: [_row('old', createdAt: 10)]),
),
_row('mine', createdAt: 11),
isTimelineRow: true,
);
final merged = mergeLiveChannelWindowEvent(
store,
_summary('mine', replyCount: 2, participants: ['p1', 'p2']),
isTimelineRow: false,
);
expect(channelWindowThreadSummaries(merged)['mine']?.replyCount, 2);
});
test('a later live summary replaces an earlier one', () {
var store = replaceNewestChannelWindow(
const ChannelWindowStore.empty(),
_page(rows: [_row('root', createdAt: 10)]),
);
store = mergeLiveChannelWindowEvent(
store,
_summary('root', replyCount: 1, participants: ['p1'], createdAt: 11),
isTimelineRow: false,
);
store = mergeLiveChannelWindowEvent(
store,
_summary(
'root',
replyCount: 2,
participants: ['p1', 'p2'],
createdAt: 12,
),
isTimelineRow: false,
);
expect(channelWindowThreadSummaries(store)['root']?.replyCount, 2);
});
test(
'an older live summary is ignored but a later same-second summary wins',
() {
var store = replaceNewestChannelWindow(
const ChannelWindowStore.empty(),
_page(rows: [_row('root', createdAt: 10)]),
);
store = mergeLiveChannelWindowEvent(
store,
_summary('root', replyCount: 3, participants: ['p1'], createdAt: 30),
isTimelineRow: false,
);
final afterOlder = mergeLiveChannelWindowEvent(
store,
_summary('root', replyCount: 1, participants: ['p1'], createdAt: 29),
isTimelineRow: false,
);
final afterSameSecond = mergeLiveChannelWindowEvent(
afterOlder,
_summary('root', replyCount: 2, participants: ['p1'], createdAt: 30),
isTimelineRow: false,
);
expect(identical(afterOlder, store), isTrue);
expect(
channelWindowThreadSummaries(afterSameSecond)['root']?.replyCount,
2,
);
},
);
test(
'a summary received during the initial query wins the page snapshot',
() {
var store = mergeLiveChannelWindowEvent(
const ChannelWindowStore.empty(),
_summary('root', replyCount: 2, participants: ['p1'], createdAt: 30),
isTimelineRow: false,
);
store = replaceNewestChannelWindow(
store,
ChannelWindowPage(
startCursor: null,
rows: [
ChannelWindowRow(
event: _row('root', createdAt: 10),
thread: const ChannelWindowThreadSummary(
replyCount: 1,
descendantCount: 1,
lastReplyAt: 12,
participantPubkeys: ['p1'],
),
// The page overlay can be signed after its query snapshot.
threadSummaryCreatedAt: 31,
),
],
aux: const [],
nextCursor: null,
hasMore: false,
),
retainLiveSummaryRootIds: const {'root'},
);
expect(channelWindowThreadSummaries(store)['root']?.replyCount, 2);
},
);
test('a replayed summary yields to a newer initial page snapshot', () {
var store = mergeLiveChannelWindowEvent(
const ChannelWindowStore.empty(),
_summary('root', replyCount: 2, participants: ['p1'], createdAt: 30),
isTimelineRow: false,
);
store = replaceNewestChannelWindow(
store,
ChannelWindowPage(
startCursor: null,
rows: [
ChannelWindowRow(
event: _row('root', createdAt: 10),
thread: const ChannelWindowThreadSummary(
replyCount: 1,
descendantCount: 1,
lastReplyAt: 12,
participantPubkeys: ['p1'],
),
threadSummaryCreatedAt: 31,
),
],
aux: const [],
nextCursor: null,
hasMore: false,
),
);
expect(channelWindowThreadSummaries(store)['root']?.replyCount, 1);
});
test('a newer live summary survives an appended older page', () {
var store = replaceNewestChannelWindow(
const ChannelWindowStore.empty(),
_page(rows: [_row('head', createdAt: 10)], hasMore: true),
);
store = mergeLiveChannelWindowEvent(
store,
_summary('older', replyCount: 2, participants: ['p1'], createdAt: 30),
isTimelineRow: false,
);
store = appendOlderChannelWindow(
store,
ChannelWindowPage(
startCursor: const ChannelPageCursor(createdAt: 10, eventId: 'head'),
rows: [
ChannelWindowRow(
event: _row('older', createdAt: 9),
thread: const ChannelWindowThreadSummary(
replyCount: 1,
descendantCount: 1,
lastReplyAt: 12,
participantPubkeys: ['p1'],
),
threadSummaryCreatedAt: 29,
),
],
aux: const [],
nextCursor: null,
hasMore: false,
),
);
expect(channelWindowThreadSummaries(store)['older']?.replyCount, 2);
});
test('a refetched row outranks the live entry it duplicates', () {
var store = replaceNewestChannelWindow(
const ChannelWindowStore.empty(),
_page(rows: [_row('root', createdAt: 10)]),
);
store = mergeLiveChannelWindowEvent(
store,
_summary('root', replyCount: 1, participants: ['p1']),
isTimelineRow: false,
);
// Refetching the head brings down the authoritative count with the row,
// so the stale live entry must not shadow it.
final refreshed = replaceNewestChannelWindow(
store,
ChannelWindowPage(
startCursor: null,
rows: [
ChannelWindowRow(
event: _row('root', createdAt: 10),
thread: const ChannelWindowThreadSummary(
replyCount: 5,
descendantCount: 5,
lastReplyAt: 12,
participantPubkeys: ['p1'],
),
threadSummaryCreatedAt: 20,
),
],
aux: const [],
nextCursor: null,
hasMore: false,
),
);
expect(channelWindowThreadSummaries(refreshed)['root']?.replyCount, 5);
});
test('a summary survives unrelated live events', () {
var store = replaceNewestChannelWindow(
const ChannelWindowStore.empty(),
_page(rows: [_row('root', createdAt: 10)]),
);
store = mergeLiveChannelWindowEvent(
store,
_summary('root', replyCount: 1, participants: ['p1']),
isTimelineRow: false,
);
store = mergeLiveChannelWindowEvent(
store,
_reaction('reaction'),
isTimelineRow: false,
);
store = mergeLiveChannelWindowEvent(
store,
_row('later', createdAt: 11),
isTimelineRow: true,
);
expect(channelWindowThreadSummaries(store)['root']?.replyCount, 1);
});
test('a summary with no root tag or bad content is ignored', () {
final store = replaceNewestChannelWindow(
const ChannelWindowStore.empty(),
_page(rows: [_row('root', createdAt: 10)]),
);
final untagged = mergeLiveChannelWindowEvent(
store,
_event(id: 'no-e', kind: EventKind.channelThreadSummary, content: '{}'),
isTimelineRow: false,
);
expect(identical(untagged, store), isTrue);
final malformed = mergeLiveChannelWindowEvent(
store,
_event(
id: 'bad',
kind: EventKind.channelThreadSummary,
tags: const [
['e', 'root'],
],
content: 'not json',
),
isTimelineRow: false,
);
expect(channelWindowThreadSummaries(malformed), isEmpty);
});
});
}
const _channelId = 'ABCDEF';
ChannelWindowPage _page({
ChannelPageCursor? startCursor,
required List<NostrEvent> rows,
bool hasMore = false,
}) {
return ChannelWindowPage(
startCursor: startCursor,
rows: [for (final row in rows) ChannelWindowRow(event: row)],
aux: const [],
nextCursor: hasMore
? ChannelPageCursor(
createdAt: rows.last.createdAt,
eventId: rows.last.id,
)
: null,
hasMore: hasMore,
);
}
NostrEvent _row(String id, {int createdAt = 10}) => _event(
id: id,
createdAt: createdAt,
kind: EventKind.streamMessageV2,
tags: const [
['h', _channelId],
],
);
NostrEvent _reaction(String id) => _event(
id: id,
kind: EventKind.reaction,
tags: const [
['e', 'root'],
],
);
NostrEvent _summary(
String rootId, {
required int replyCount,
required List<String> participants,
int createdAt = 10,
}) => _event(
id: 'summary-$rootId-$createdAt-$replyCount',
createdAt: createdAt,
kind: EventKind.channelThreadSummary,
tags: [
['e', rootId],
],
content: jsonEncode({
'reply_count': replyCount,
'descendant_count': replyCount,
'last_reply_at': 12,
'participants': participants,
}),
);
NostrEvent _bounds({
String id = 'bounds',
String? dTag,
Map<String, dynamic>? content,
}) => _event(
id: id,
kind: EventKind.channelWindowBounds,
tags: [
['d', dTag ?? '${_channelId.toLowerCase()}:head'],
],
content: jsonEncode(content ?? {'has_more': false, 'next_cursor': null}),
);
NostrEvent _event({
required String id,
int createdAt = 10,
required int kind,
List<List<String>> tags = const [],
String content = '',
}) {
return NostrEvent(
id: id,
pubkey: 'alice',
createdAt: createdAt,
kind: kind,
tags: tags,
content: content,
sig: 'sig',
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,730 @@
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;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,102 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/features/channels/date_formatters.dart';
/// Helper: build a unix-second timestamp from a local DateTime.
int _ts(DateTime local) => local.millisecondsSinceEpoch ~/ 1000;
void main() {
group('formatDayHeading', () {
// Fix "now" so tests are deterministic.
final now = DateTime(2026, 4, 23, 14, 30); // Apr 23 2026, 2:30 PM local
test('same day returns "Today"', () {
final morning = _ts(DateTime(2026, 4, 23, 8, 0));
expect(formatDayHeading(morning, now: now), 'Today');
});
test('yesterday returns "Yesterday"', () {
final yesterday = _ts(DateTime(2026, 4, 22, 18, 0));
expect(formatDayHeading(yesterday, now: now), 'Yesterday');
});
test('older date returns full formatted date', () {
final older = _ts(DateTime(2026, 3, 31, 12, 0));
expect(formatDayHeading(older, now: now), 'Tuesday, March 31, 2026');
});
test('midnight boundary: 11:59 PM today vs 12:01 AM tomorrow', () {
final lateTonight = _ts(DateTime(2026, 4, 23, 23, 59));
final earlyTomorrow = _ts(DateTime(2026, 4, 24, 0, 1));
expect(formatDayHeading(lateTonight, now: now), 'Today');
// Tomorrow relative to our fixed "now" is not today or yesterday.
expect(
formatDayHeading(earlyTomorrow, now: now),
'Friday, April 24, 2026',
);
});
test('cross-month boundary: April 1 → March 31 is yesterday', () {
final april1 = DateTime(2026, 4, 1, 10, 0);
final march31 = _ts(DateTime(2026, 3, 31, 20, 0));
expect(formatDayHeading(march31, now: april1), 'Yesterday');
});
});
group('isSameDay', () {
test('same calendar day returns true', () {
final a = _ts(DateTime(2026, 4, 23, 1, 0));
final b = _ts(DateTime(2026, 4, 23, 23, 59));
expect(isSameDay(a, b), isTrue);
});
test('different days returns false', () {
final a = _ts(DateTime(2026, 4, 23, 23, 59));
final b = _ts(DateTime(2026, 4, 24, 0, 1));
expect(isSameDay(a, b), isFalse);
});
});
group('formatThreadSummaryLastReplyTime', () {
const now = 2_000_000;
test('uses expanded relative units', () {
expect(
formatThreadSummaryLastReplyTime(now - 30, nowSeconds: now),
'just now',
);
expect(
formatThreadSummaryLastReplyTime(now - 60, nowSeconds: now),
'1 minute ago',
);
expect(
formatThreadSummaryLastReplyTime(now - 3 * 3600, nowSeconds: now),
'3 hours ago',
);
expect(
formatThreadSummaryLastReplyTime(now - 2 * 86400, nowSeconds: now),
'2 days ago',
);
});
test('uses a short month and ordinal for older replies', () {
final may19 = _ts(DateTime(2026, 5, 19, 12));
final may27 = _ts(DateTime(2026, 5, 27, 12));
expect(
formatThreadSummaryLastReplyTime(
may19,
nowSeconds: _ts(DateTime(2026, 5, 27, 12)),
),
'on May 19th',
);
expect(
formatThreadSummaryLastReplyTime(
may27,
nowSeconds: _ts(DateTime(2026, 6, 4, 12)),
),
'on May 27th',
);
});
});
}
@@ -0,0 +1,266 @@
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('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),
);
expect(destination.channel.id, 'channel-1');
expect(destination.link.messageId, 'message-2');
expect(destination.link.threadRootId, 'message-1');
});
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(
'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);
},
);
}
final _channel = Channel(
id: 'channel-1',
name: 'general',
channelType: 'stream',
visibility: 'open',
description: 'General discussion',
createdBy: 'creator',
createdAt: DateTime(2026),
memberCount: 2,
isMember: 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 _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 _CapturedDestination extends StatelessWidget {
const _CapturedDestination({required this.channel, required this.link});
final Channel channel;
final MessageDeepLink link;
@override
Widget build(BuildContext context) => const SizedBox();
}
@@ -0,0 +1,429 @@
import 'package:buzz/features/channels/emoji_picker.dart';
import 'package:buzz/features/channels/recent_emoji_provider.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart';
import 'package:buzz/shared/emoji/emoji_data.dart';
import 'package:buzz/shared/emoji/emoji_data_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../helpers/widget_helpers.dart';
EmojiEntry _entry(
String id, {
required String native,
required String categoryId,
String? name,
List<String> keywords = const [],
int skinIndex = 0,
}) => EmojiEntry(
id: id,
name: name ?? id,
keywords: keywords,
native: native,
categoryId: categoryId,
skinIndex: skinIndex,
);
/// A miniature stand-in for the generated asset — two categories so the rail
/// has something to switch between. `emoji_data_test.dart` covers the real one.
final _dataset = () {
final people = [
_entry('grinning', native: '\u{1F600}', categoryId: 'people'),
_entry(
'point_up',
native: '\u{261D}\u{FE0F}',
categoryId: 'people',
name: 'Index Pointing Up',
),
_entry(
'point_up',
native: '\u{261D}\u{1F3FD}',
categoryId: 'people',
name: 'Index Pointing Up',
skinIndex: 1,
),
];
final nature = [
_entry(
'fire',
native: '\u{1F525}',
categoryId: 'nature',
name: 'Fire',
keywords: const ['flame'],
),
];
final all = [...people, ...nature];
return EmojiDataset(
categories: [
EmojiCategory(id: 'people', emoji: people),
EmojiCategory(id: 'nature', emoji: nature),
],
all: all,
nativeToShortcode: {for (final entry in all) entry.native: ':${entry.id}:'},
);
}();
/// A dataset tall enough that the grid actually scrolls, so rail navigation has
/// somewhere to go. [_dataset] fits on one screen and clamps to offset 0.
final _tallDataset = () {
final people = [
for (var i = 0; i < 200; i++)
_entry('people_$i', native: '\u{1F600}', categoryId: 'people'),
];
// Nature is tall too, so the People target isn't clamped by the end of the
// list — this test is about landing on a header, not about the clamp.
final nature = [
_entry('fire', native: '\u{1F525}', categoryId: 'nature'),
for (var i = 0; i < 200; i++)
_entry('nature_$i', native: '\u{1F33F}', categoryId: 'nature'),
];
final all = [...people, ...nature];
return EmojiDataset(
categories: [
EmojiCategory(id: 'people', emoji: people),
EmojiCategory(id: 'nature', emoji: nature),
],
all: all,
nativeToShortcode: {for (final entry in all) entry.native: ':${entry.id}:'},
);
}();
const _customEmoji = [
CustomEmoji(shortcode: 'partyparrot', url: 'https://example.test/parrot.gif'),
];
Future<SharedPreferences> _prefs() {
SharedPreferences.setMockInitialValues({});
return SharedPreferences.getInstance();
}
Future<List<String>> _pumpPicker(
WidgetTester tester, {
required SharedPreferences prefs,
List<CustomEmoji> customEmoji = _customEmoji,
EmojiDataset? dataset,
}) async {
final selected = <String>[];
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
myPubkeyProvider.overrideWithValue('self'),
emojiDatasetOrEmptyProvider.overrideWithValue(dataset ?? _dataset),
customEmojiListProvider.overrideWithValue(customEmoji),
],
child: EmojiPickerSheet(onSelect: selected.add),
),
);
await tester.pumpAndSettle();
return selected;
}
void main() {
group('EmojiPickerSheet', () {
testWidgets('with no history there is no Frequently used section', (
tester,
) async {
await _pumpPicker(tester, prefs: await _prefs());
// An empty section in a continuous list is a labelled gap, and its rail
// entry would lead nowhere — so it is omitted until something is picked.
expect(find.byTooltip('Frequently used'), findsNothing);
expect(find.text('Frequently used'), findsNothing);
expect(find.byKey(const ValueKey('emoji-picker-grid')), findsOneWidget);
});
testWidgets('every section lives in one continuous scroll view', (
tester,
) async {
await _pumpPicker(tester, prefs: await _prefs());
// The old picker swapped the grid per tab, so only one category's emoji
// existed at a time. Now they are all in the same list — the rail is a
// shortcut into it, not a page switcher.
final grid = find.byKey(const ValueKey('emoji-picker-grid'));
expect(grid, findsOneWidget);
expect(
find.descendant(
of: grid,
matching: find.byKey(const ValueKey('emoji-tile-grinning')),
),
findsOneWidget,
);
expect(
find.descendant(
of: grid,
matching: find.byKey(const ValueKey('emoji-tile-fire')),
),
findsOneWidget,
);
expect(
find.descendant(
of: grid,
matching: find.byKey(const ValueKey('emoji-tile-custom-partyparrot')),
),
findsOneWidget,
);
});
testWidgets('the rail divides the full tray width evenly', (tester) async {
await _pumpPicker(tester, prefs: await _prefs());
// The rail used to be a short left-aligned strip. Every section now gets
// one evenly-sized slot across the same width the search field spans.
final searchField = tester.getRect(
find.byKey(const ValueKey('emoji-picker-search')),
);
final people = tester.getRect(find.byTooltip('Smileys & People'));
final nature = tester.getRect(find.byTooltip('Animals & Nature'));
final custom = tester.getRect(find.byTooltip('Custom'));
expect(nature.left, greaterThan(people.left));
expect(custom.left, greaterThan(nature.left));
expect(people.width, closeTo(nature.width, 0.5));
expect(people.width, closeTo(custom.width, 0.5));
// First slot starts and last slot ends on the search field's edges.
expect(people.left, closeTo(searchField.left, 0.5));
expect(custom.right, closeTo(searchField.right, 0.5));
});
testWidgets('tapping the rail scrolls the grid instead of replacing it', (
tester,
) async {
await _pumpPicker(tester, prefs: await _prefs(), dataset: _tallDataset);
final grid = find.byKey(const ValueKey('emoji-picker-grid'));
double offset() =>
tester.widget<CustomScrollView>(grid).controller!.offset;
expect(offset(), 0);
await tester.tap(find.byTooltip('Animals & Nature'));
await tester.pumpAndSettle();
// Same scroll view, moved — not a swapped-in second grid.
expect(grid, findsOneWidget);
expect(offset(), greaterThan(0));
// People has 200 emoji at 8 per row: 25 rows of 40px plus a 28px header.
expect(offset(), closeTo(28 + 25 * 40, 0.5));
});
testWidgets('the custom section only exists when the palette has emoji', (
tester,
) async {
await _pumpPicker(tester, prefs: await _prefs(), customEmoji: const []);
expect(find.byTooltip('Custom'), findsNothing);
expect(
find.byKey(const ValueKey('emoji-tile-custom-partyparrot')),
findsNothing,
);
await _pumpPicker(tester, prefs: await _prefs());
expect(find.byTooltip('Custom'), findsOneWidget);
expect(
find.byKey(const ValueKey('emoji-tile-custom-partyparrot')),
findsOneWidget,
);
});
testWidgets('custom emoji sit in the same cell as native glyphs', (
tester,
) async {
await _pumpPicker(tester, prefs: await _prefs());
// A community's own emoji used to get their own looser 6-per-row grid,
// which read as a different component bolted onto the sheet.
final native = tester.getRect(
find.byKey(const ValueKey('emoji-tile-fire')),
);
final custom = tester.getRect(
find.byKey(const ValueKey('emoji-tile-custom-partyparrot')),
);
expect(custom.width, closeTo(native.width, 0.5));
expect(custom.height, closeTo(native.height, 0.5));
});
testWidgets('typing filters across the standard and custom sets', (
tester,
) async {
await _pumpPicker(tester, prefs: await _prefs());
await tester.enterText(
find.byKey(const ValueKey('emoji-picker-search')),
'flame',
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('emoji-picker-search-results')),
findsOneWidget,
);
expect(find.byKey(const ValueKey('emoji-tile-fire')), findsOneWidget);
expect(find.byKey(const ValueKey('emoji-tile-grinning')), findsNothing);
// Searching hides the rail — the query is the navigation.
expect(find.byTooltip('Smileys & People'), findsNothing);
// Custom emoji rank in the same query, in their own section.
await tester.enterText(
find.byKey(const ValueKey('emoji-picker-search')),
'parrot',
);
await tester.pumpAndSettle();
expect(find.text('Custom'), findsOneWidget);
expect(
find.byKey(const ValueKey('emoji-tile-custom-partyparrot')),
findsOneWidget,
);
});
testWidgets('crosses the shortcode separator emoji-mart cannot', (
tester,
) async {
await _pumpPicker(tester, prefs: await _prefs());
await tester.enterText(
find.byKey(const ValueKey('emoji-picker-search')),
'pointup',
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('emoji-tile-point_up')), findsOneWidget);
});
testWidgets('reports no results rather than an empty grid', (tester) async {
await _pumpPicker(tester, prefs: await _prefs());
await tester.enterText(
find.byKey(const ValueKey('emoji-picker-search')),
'zzzzzz',
);
await tester.pumpAndSettle();
expect(find.text('No emoji found.'), findsOneWidget);
});
testWidgets('clear button restores browsing', (tester) async {
await _pumpPicker(tester, prefs: await _prefs());
await tester.enterText(
find.byKey(const ValueKey('emoji-picker-search')),
'fire',
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('emoji-picker-search-clear')));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('emoji-picker-search-results')),
findsNothing,
);
expect(find.byTooltip('Smileys & People'), findsOneWidget);
});
testWidgets('a standard emoji emits its glyph', (tester) async {
final selected = await _pumpPicker(tester, prefs: await _prefs());
await tester.tap(find.byKey(const ValueKey('emoji-tile-fire')));
await tester.pumpAndSettle();
expect(selected, ['\u{1F525}']);
});
testWidgets('a skin-tone variant is selectable', (tester) async {
final selected = await _pumpPicker(tester, prefs: await _prefs());
await tester.tap(find.byKey(const ValueKey('emoji-tile-point_up-1')));
await tester.pumpAndSettle();
expect(selected, ['\u{261D}\u{1F3FD}']);
});
testWidgets('a custom emoji emits :shortcode:', (tester) async {
final selected = await _pumpPicker(tester, prefs: await _prefs());
await tester.tap(
find.byKey(const ValueKey('emoji-tile-custom-partyparrot')),
);
await tester.pumpAndSettle();
expect(selected, [':partyparrot:']);
});
testWidgets('a non-reaction selection does not record a quick reaction', (
tester,
) async {
final prefs = await _prefs();
final selected = await _pumpPicker(tester, prefs: prefs);
await tester.tap(find.byKey(const ValueKey('emoji-tile-fire')));
await tester.pumpAndSettle();
expect(selected, ['\u{1F525}']);
expect(
prefs.getString(
'buzz.quick-reaction-emojis.v1:http://localhost:3000:self',
),
isNull,
);
});
testWidgets('shows a spinner while the dataset is still loading', (
tester,
) async {
final prefs = await _prefs();
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
myPubkeyProvider.overrideWithValue('self'),
emojiDatasetOrEmptyProvider.overrideWithValue(EmojiDataset.empty),
customEmojiListProvider.overrideWithValue(const []),
],
child: EmojiPickerSheet(onSelect: (_) {}),
),
);
// Not pumpAndSettle — the spinner animates forever.
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
});
group('recent emoji ranking', () {
test('promotes by use count, breaking ties on recency', () {
var entries = <RecentEmojiEntry>[];
entries = recordRecentEmoji(entries, 'a', now: 10);
entries = recordRecentEmoji(entries, 'b', now: 20);
entries = recordRecentEmoji(entries, 'b', now: 30);
entries = recordRecentEmoji(entries, 'c', now: 40);
expect(entries.map((entry) => entry.emoji), ['b', 'c', 'a']);
expect(entries.first.count, 2);
});
test('tops the quick row up with the defaults', () {
final entries = recordRecentEmoji(const [], '\u{1F525}', now: 1);
final row = quickReactionEmoji(entries, customShortcodes: const {});
expect(row.first, '\u{1F525}');
expect(row, hasLength(5));
expect(row, containsAll(defaultQuickEmojis.take(4)));
});
test('drops custom emoji no longer in the palette', () {
final entries = recordRecentEmoji(const [], ':gone:', now: 1);
expect(
quickReactionEmoji(entries, customShortcodes: const {}),
defaultQuickEmojis,
);
expect(
quickReactionEmoji(entries, customShortcodes: const {'gone'}).first,
':gone:',
);
});
});
}
@@ -0,0 +1,284 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/features/channels/mentions/mention_candidates.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
final userPubkey = 'a' * 64;
final memberPubkey = 'b' * 64;
final agentPubkey = 'c' * 64;
final ownerPubkey = 'd' * 64;
ChannelMember member(String pubkey, {String role = 'member'}) {
return ChannelMember(
pubkey: pubkey,
role: role,
joinedAt: DateTime(2024),
displayName: 'Member',
);
}
void main() {
test('role-only agent mentions fall back to a pubkey prefix label', () {
const pubkey = 'deadbeef0123456789';
expect(
mentionNamesWithDirectoryLabels(
mentionPubkeys: const [pubkey],
profileMentionNames: const {},
directoryDisplayNames: const {},
agentMentionPubkeys: const {pubkey},
),
const {pubkey: 'deadbeef'},
);
});
group('agentIsSharedWithUser', () {
test('anyone-mode agent is shared when a channel overlaps', () {
final agent = AgentDirectoryEntry(
pubkey: agentPubkey,
respondTo: 'anyone',
channelIds: const ['chan-1'],
);
expect(agentIsSharedWithUser(agent, {'chan-1'}, userPubkey), isTrue);
expect(agentIsSharedWithUser(agent, {'chan-2'}, userPubkey), isFalse);
});
test('allowlist-mode agent requires the user on the allowlist', () {
final agent = AgentDirectoryEntry(
pubkey: agentPubkey,
respondTo: 'allowlist',
respondToAllowlist: [userPubkey],
channelIds: const ['chan-1'],
);
expect(agentIsSharedWithUser(agent, {'chan-1'}, userPubkey), isTrue);
expect(agentIsSharedWithUser(agent, {'chan-1'}, 'e' * 64), isFalse);
});
});
group('formatOwnerLabel', () {
test('returns "you" for the current user', () {
expect(formatOwnerLabel(userPubkey, userPubkey, const {}), 'you');
});
test('prefers display name, then handle, then pubkey prefix', () {
final profiles = {
ownerPubkey: UserProfile(pubkey: ownerPubkey, displayName: 'Wes'),
};
expect(formatOwnerLabel(ownerPubkey, userPubkey, profiles), 'Wes');
expect(
formatOwnerLabel(ownerPubkey, userPubkey, const {}),
'${'d' * 8}\u2026',
);
});
test('returns null without an owner', () {
expect(formatOwnerLabel(null, userPubkey, const {}), isNull);
});
});
group('buildMentionCandidates', () {
test('members come first; eligible non-member agents follow', () {
final candidates = buildMentionCandidates(
members: [member(memberPubkey), member(userPubkey)],
relayAgents: [
AgentDirectoryEntry(
pubkey: agentPubkey,
displayName: 'Helper',
respondTo: 'anyone',
channelIds: const ['chan-1'],
),
],
sharedChannelIds: {'chan-1'},
userCache: const {},
ownerByAgentPubkey: {agentPubkey: ownerPubkey},
currentPubkey: userPubkey,
);
expect(candidates.map((c) => c.pubkey).toList(), [
memberPubkey,
userPubkey,
agentPubkey,
]);
expect(candidates.last.isAgent, isTrue);
expect(candidates.last.isMember, isFalse);
expect(candidates.last.ownerPubkey, ownerPubkey);
});
test('includes the current user (desktop parity)', () {
final candidates = buildMentionCandidates(
members: [member(userPubkey)],
relayAgents: const [],
sharedChannelIds: const {},
userCache: const {},
ownerByAgentPubkey: const {},
currentPubkey: userPubkey,
);
expect(candidates.map((c) => c.pubkey), contains(userPubkey));
});
test('excludes non-shared agents and deduplicates member agents', () {
final candidates = buildMentionCandidates(
members: [member(agentPubkey, role: 'bot')],
relayAgents: [
AgentDirectoryEntry(
pubkey: agentPubkey,
respondTo: 'anyone',
channelIds: const ['chan-1'],
),
AgentDirectoryEntry(
pubkey: 'f' * 64,
respondTo: 'anyone',
channelIds: const ['chan-9'],
),
],
sharedChannelIds: {'chan-1'},
userCache: const {},
ownerByAgentPubkey: const {},
currentPubkey: userPubkey,
);
expect(candidates, hasLength(1));
expect(candidates.single.pubkey, agentPubkey);
expect(candidates.single.isMember, isTrue);
expect(candidates.single.isAgent, isTrue);
});
test('search results add non-member humans, ungated', () {
final humanPubkey = '1' * 64;
final candidates = buildMentionCandidates(
members: [member(memberPubkey)],
relayAgents: const [],
sharedChannelIds: const {},
userCache: const {},
ownerByAgentPubkey: const {},
searchResults: [
UserProfile(pubkey: humanPubkey, displayName: 'Wes Outside'),
],
currentPubkey: userPubkey,
);
expect(candidates.map((c) => c.pubkey), [memberPubkey, humanPubkey]);
final human = candidates.last;
expect(human.isAgent, isFalse);
expect(human.isMember, isFalse);
expect(human.displayName, 'Wes Outside');
});
test('search results show agents owned by the current user', () {
final ownedAgent = '2' * 64;
final candidates = buildMentionCandidates(
members: const [],
relayAgents: const [],
sharedChannelIds: const {},
userCache: const {},
ownerByAgentPubkey: const {},
searchResults: [
UserProfile(
pubkey: ownedAgent,
displayName: 'raccoon',
ownerPubkey: userPubkey,
),
],
currentPubkey: userPubkey,
);
expect(candidates, hasLength(1));
expect(candidates.single.isAgent, isTrue);
expect(candidates.single.ownerPubkey, userPubkey);
});
test('search results hide non-shared agents owned by someone else', () {
final foreignAgent = '3' * 64;
final candidates = buildMentionCandidates(
members: const [],
relayAgents: const [],
sharedChannelIds: const {},
userCache: const {},
ownerByAgentPubkey: const {},
searchResults: [
UserProfile(
pubkey: foreignAgent,
displayName: 'stranger-bot',
ownerPubkey: ownerPubkey,
),
],
currentPubkey: userPubkey,
);
expect(candidates, isEmpty);
});
test('search results show non-owned agents shared via the directory', () {
final candidates = buildMentionCandidates(
members: const [],
relayAgents: [
AgentDirectoryEntry(
pubkey: agentPubkey,
respondTo: 'anyone',
channelIds: const ['chan-1'],
),
],
sharedChannelIds: {'chan-1'},
userCache: const {},
ownerByAgentPubkey: const {},
searchResults: [
UserProfile(
pubkey: agentPubkey,
displayName: 'Helper',
ownerPubkey: ownerPubkey,
),
],
currentPubkey: userPubkey,
);
// Already surfaced by the directory pass; the search pass must not
// duplicate it.
expect(candidates, hasLength(1));
expect(candidates.single.pubkey, agentPubkey);
expect(candidates.single.isAgent, isTrue);
});
test('directory-listed agents in search results are agents even without '
'a verified owner', () {
final candidates = buildMentionCandidates(
members: const [],
relayAgents: [
AgentDirectoryEntry(
pubkey: agentPubkey,
respondTo: 'anyone',
channelIds: const ['chan-9'],
),
],
// Not shared with the user → directory pass skips it; the search
// pass must still classify it as an agent and hide it.
sharedChannelIds: const {},
userCache: const {},
ownerByAgentPubkey: const {},
searchResults: [
UserProfile(pubkey: agentPubkey, displayName: 'Helper'),
],
currentPubkey: userPubkey,
);
expect(candidates, isEmpty);
});
test('search results never duplicate channel members', () {
final candidates = buildMentionCandidates(
members: [member(memberPubkey)],
relayAgents: const [],
sharedChannelIds: const {},
userCache: const {},
ownerByAgentPubkey: const {},
searchResults: [
UserProfile(pubkey: memberPubkey, displayName: 'Member Dup'),
],
currentPubkey: userPubkey,
);
expect(candidates, hasLength(1));
expect(candidates.single.isMember, isTrue);
});
});
}
@@ -0,0 +1,114 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/features/channels/mentions/mention_ranking.dart';
// Ported from desktop/src/features/messages/lib/mentionRanking.test.mjs
// (persona cases omitted — personas are desktop-only).
final channelBrainPubkey = '1' * 64;
final otherBrainPubkey = '2' * 64;
MentionCandidate candidate({
String? displayName = 'Brain',
String? secondaryLabel,
bool isAgent = false,
bool isMember = false,
String? pubkey,
}) {
return MentionCandidate(
pubkey: pubkey ?? otherBrainPubkey,
displayName: displayName,
secondaryLabel: secondaryLabel,
isAgent: isAgent,
isMember: isMember,
);
}
List<String> rankedPubkeys(
List<MentionCandidate> candidates, [
String query = 'brain',
]) {
return [
for (final ranked in rankMentionCandidates(candidates, query))
ranked.pubkey,
];
}
void main() {
test('channel members outrank people and other agents', () {
final remoteAgent = candidate(isAgent: true, pubkey: otherBrainPubkey);
final person = candidate(pubkey: '6' * 64);
final channelMember = candidate(
isAgent: true,
isMember: true,
pubkey: channelBrainPubkey,
);
expect(rankedPubkeys([remoteAgent, person, channelMember]), [
channelBrainPubkey,
'6' * 64,
otherBrainPubkey,
]);
});
test('exact and prefix quality sort within the channel-member group', () {
final wordPrefixMember = candidate(
displayName: 'The Brain',
isMember: true,
pubkey: '3' * 64,
);
final exactMember = candidate(
displayName: 'Brain',
isMember: true,
pubkey: channelBrainPubkey,
);
final prefixMember = candidate(
displayName: 'Brainiac',
isMember: true,
pubkey: '4' * 64,
);
expect(rankedPubkeys([wordPrefixMember, exactMember, prefixMember]), [
channelBrainPubkey,
'4' * 64,
'3' * 64,
]);
});
test('matching secondary labels participate in ranking', () {
final memberByHandle = candidate(
displayName: 'Acme Bot',
secondaryLabel: 'brain@example.com',
isMember: true,
pubkey: channelBrainPubkey,
);
final nonMemberName = candidate(
displayName: 'Brain',
pubkey: otherBrainPubkey,
);
expect(rankedPubkeys([nonMemberName, memberByHandle]), [
channelBrainPubkey,
otherBrainPubkey,
]);
});
test('non-matching candidates are dropped', () {
final match = candidate(displayName: 'Brain', pubkey: channelBrainPubkey);
final noMatch = candidate(displayName: 'Pinky', pubkey: '7' * 64);
expect(rankedPubkeys([match, noMatch]), [channelBrainPubkey]);
});
test('pubkey prefix matches when no label matches', () {
final byPubkey = candidate(displayName: 'Pinky', pubkey: 'abc${'0' * 61}');
expect(rankedPubkeys([byPubkey], 'abc'), ['abc${'0' * 61}']);
});
test('empty query keeps stable order within groups', () {
final second = candidate(displayName: 'Beta', pubkey: '9' * 64);
final first = candidate(displayName: 'Alpha', pubkey: '8' * 64);
expect(rankedPubkeys([second, first], ''), ['9' * 64, '8' * 64]);
});
}
@@ -0,0 +1,580 @@
import 'package:buzz/features/channels/message_actions.dart';
import 'package:buzz/shared/read_state/read_state_provider.dart';
import 'package:buzz/features/channels/thread_follows/thread_follows_provider.dart';
import 'package:buzz/features/channels/timeline_message.dart';
import 'package:buzz/shared/reminders/reminder_service.dart';
import 'package:buzz/shared/l10n/l10n.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
const _channelId = 'chan-1';
TimelineMessage _message({
String id = 'msg-1',
String pubkey = 'alice',
int createdAt = 1000,
bool isSystem = false,
String? rootId,
}) => TimelineMessage(
id: id,
pubkey: pubkey,
createdAt: createdAt,
content: 'hello world',
isSystem: isSystem,
rootId: rootId,
);
class _FakeReadStateNotifier extends ReadStateNotifier {
final ReadStateState _initialState;
final Map<String, int> markedRead = {};
final List<String> markedUnread = [];
_FakeReadStateNotifier(this._initialState);
@override
ReadStateState build() => _initialState;
@override
void markContextRead(
String contextId,
int unixTimestamp, {
bool clearForcedMessages = false,
}) {
markedRead[contextId] = unixTimestamp;
var forced = {
for (final entry in state.forcedUnreadContexts.entries)
if (entry.key != contextId) entry.key: entry.value,
};
if (clearForcedMessages) {
forced = {
for (final entry in forced.entries)
if (entry.value != contextId) entry.key: entry.value,
};
}
state = _withForced(forced).copyWithContext(contextId, unixTimestamp);
}
@override
void markContextUnread(String contextId, {required String channelId}) {
markedUnread.add(contextId);
state = _withForced({...state.forcedUnreadContexts, contextId: channelId});
}
ReadStateState _withForced(Map<String, String> forced) => ReadStateState(
isReady: state.isReady,
pubkey: state.pubkey,
contexts: state.contexts,
version: state.version + 1,
forcedUnreadContexts: Map.unmodifiable(forced),
);
}
ReadStateState _readState(Map<String, int> contexts, {bool isReady = true}) =>
ReadStateState(
isReady: isReady,
pubkey: 'self',
contexts: contexts,
version: 1,
);
Future<SharedPreferences> _mockPrefs() async {
SharedPreferences.setMockInitialValues({});
return SharedPreferences.getInstance();
}
/// A [ReminderService] whose constructor dependencies are inert until used —
/// enough for visibility checks on the "Remind me" fast action.
ReminderService _stubReminderService() {
final keys = nostr.Keys.generate();
return ReminderService(
signedEventRelay: SignedEventRelay(
session: RelaySessionNotifier(),
nsec: keys.nsec,
),
crypto: ReminderCrypto(keys.nsec, keys.public),
);
}
Future<void> _pumpSheet(
WidgetTester tester, {
required TimelineMessage message,
required SharedPreferences prefs,
ReadStateNotifier Function()? readStateOverride,
bool canManageMessage = false,
List<TimelineMessage>? allMessages,
ReminderService? reminderService,
}) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
myPubkeyProvider.overrideWithValue('self'),
readStateProvider.overrideWith(
readStateOverride ??
() => _FakeReadStateNotifier(
_readState(const {_channelId: 100000}),
),
),
// No signing identity → "Remind me" hidden by default; individual
// tests opt in by passing a stub service.
reminderServiceProvider.overrideWithValue(reminderService),
],
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
theme: AppTheme.light(),
home: Scaffold(
body: Consumer(
builder: (context, ref, _) => TextButton(
onPressed: () => showMessageActions(
context: context,
ref: ref,
message: message,
channelId: _channelId,
canManageMessage: canManageMessage,
allMessages: allMessages,
currentPubkey: 'self',
isMember: true,
),
child: const Text('open'),
),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
}
Future<void> _pumpImageSheet(
WidgetTester tester, {
required TimelineMessage message,
bool canManageMessage = false,
}) async {
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
theme: AppTheme.light(),
home: Scaffold(
body: Consumer(
builder: (context, ref, _) => TextButton(
onPressed: () => showImageActions(
context: context,
ref: ref,
message: message,
channelId: _channelId,
imageUrl: 'https://example.com/photo.png',
canManageMessage: canManageMessage,
),
child: const Text('open image actions'),
),
),
),
),
),
);
await tester.tap(find.text('open image actions'));
await tester.pumpAndSettle();
}
void main() {
group('showMessageActions', () {
testWidgets('shows parity actions for a regular message', (tester) async {
final prefs = await _mockPrefs();
await _pumpSheet(tester, message: _message(), prefs: prefs);
expect(find.text('Copy text'), findsOneWidget);
expect(find.text('Copy link'), findsOneWidget);
expect(find.text('Mark unread'), findsOneWidget);
expect(find.text('Follow thread'), findsOneWidget);
// No thread context → no Reply fast action.
expect(find.text('Reply'), findsNothing);
// No signing identity → no reminders; no manage rights → no edit/delete.
expect(find.text('Remind me'), findsNothing);
expect(find.text('Edit message'), findsNothing);
expect(find.text('Delete message'), findsNothing);
expect(find.byTooltip('Close sheet'), findsNothing);
expect(find.byKey(const ValueKey('quick-reaction-more')), findsOneWidget);
expect(
find.byWidgetPredicate(
(widget) =>
widget.key is ValueKey<String> &&
(widget.key! as ValueKey<String>).value.startsWith(
'quick-reaction-',
),
),
findsNWidgets(6),
);
expect(
tester.getSize(find.byKey(const ValueKey('quick-reaction-\u{1F44D}'))),
const Size.square(52),
);
});
testWidgets('promotes Reply, Copy link, and Remind me to the fast-actions '
'row', (tester) async {
final prefs = await _mockPrefs();
await _pumpSheet(
tester,
message: _message(),
prefs: prefs,
allMessages: [_message()],
reminderService: _stubReminderService(),
);
expect(find.text('Reply'), findsOneWidget);
expect(find.text('Copy link'), findsOneWidget);
expect(find.text('Remind me'), findsOneWidget);
// Promoted actions no longer appear under their old list-row labels.
expect(find.text('Reply in thread'), findsNothing);
expect(find.text('Remind me later'), findsNothing);
});
testWidgets('hides utility actions for system messages', (tester) async {
final prefs = await _mockPrefs();
await _pumpSheet(tester, message: _message(isSystem: true), prefs: prefs);
expect(find.text('Copy text'), findsNothing);
expect(find.text('Copy link'), findsNothing);
expect(find.text('Mark unread'), findsNothing);
expect(find.text('Follow thread'), findsNothing);
expect(find.text('Reply'), findsNothing);
expect(find.text('Remind me'), findsNothing);
expect(find.byTooltip('Close sheet'), findsNothing);
expect(
find.byWidgetPredicate(
(widget) =>
widget.key is ValueKey<String> &&
(widget.key! as ValueKey<String>).value.startsWith(
'quick-reaction-',
),
),
findsNWidgets(6),
);
});
testWidgets('keeps six reaction targets within a narrow phone', (
tester,
) async {
tester.view.physicalSize = const Size(375, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final prefs = await _mockPrefs();
await _pumpSheet(tester, message: _message(), prefs: prefs);
expect(
find.byWidgetPredicate(
(widget) =>
widget.key is ValueKey<String> &&
(widget.key! as ValueKey<String>).value.startsWith(
'quick-reaction-',
),
),
findsNWidgets(6),
);
expect(
tester
.getSize(find.byKey(const ValueKey('quick-reaction-\u{1F44D}')))
.width,
inInclusiveRange(44, 52),
);
expect(tester.takeException(), isNull);
});
testWidgets('keeps the reaction popover on-screen when neither side fits', (
tester,
) async {
tester.view.physicalSize = const Size(320, 240);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
const anchorRect = Rect.fromLTWH(40, 60, 240, 100);
const safeTop = 24.0;
const visibleBottom = 200.0;
const trayHeight = 68.0;
const trayGap = Grid.xxs;
final prefs = await _mockPrefs();
expect(anchorRect.top - trayGap - trayHeight, lessThan(safeTop));
expect(
anchorRect.bottom + trayGap + trayHeight,
greaterThan(visibleBottom),
);
await tester.pumpWidget(
ProviderScope(
overrides: [savedPrefsProvider.overrideWithValue(prefs)],
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
theme: AppTheme.light(),
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(
padding: const EdgeInsets.only(top: safeTop),
viewInsets: const EdgeInsets.only(bottom: 40),
),
child: child!,
),
home: Scaffold(
body: Consumer(
builder: (context, ref, _) => TextButton(
onPressed: () => showMessageActions(
context: context,
ref: ref,
message: _message(isSystem: true),
channelId: _channelId,
canManageMessage: false,
anchorRect: anchorRect,
),
child: const Text('open popover'),
),
),
),
),
),
);
await tester.tap(find.text('open popover'));
await tester.pumpAndSettle();
final trayRect = tester.getRect(
find.byKey(const ValueKey('reaction-popover-tray')),
);
expect(trayRect.top, greaterThanOrEqualTo(safeTop));
expect(trayRect.bottom, lessThanOrEqualTo(visibleBottom));
});
testWidgets('shows Edit/Delete only with manage rights', (tester) async {
final prefs = await _mockPrefs();
await _pumpSheet(
tester,
message: _message(),
prefs: prefs,
canManageMessage: true,
);
expect(find.text('Edit message'), findsOneWidget);
expect(find.text('Delete message'), findsOneWidget);
});
testWidgets('Mark read appears for unread messages and advances the '
'message marker', (tester) async {
final prefs = await _mockPrefs();
final notifier = _FakeReadStateNotifier(
_readState(const {_channelId: 500}),
);
await _pumpSheet(
tester,
message: _message(createdAt: 900),
prefs: prefs,
readStateOverride: () => notifier,
);
expect(find.text('Mark read'), findsOneWidget);
await tester.tap(find.text('Mark read'));
await tester.pumpAndSettle();
expect(notifier.markedRead, {'msg:msg-1': 900});
});
testWidgets('Mark unread forces the message unread', (tester) async {
final prefs = await _mockPrefs();
final notifier = _FakeReadStateNotifier(
_readState(const {_channelId: 2000}),
);
await _pumpSheet(
tester,
message: _message(createdAt: 900),
prefs: prefs,
readStateOverride: () => notifier,
);
expect(find.text('Mark unread'), findsOneWidget);
await tester.tap(find.text('Mark unread'));
await tester.pumpAndSettle();
// The force flag is message-scoped, mapped to its channel so tiles and
// badges surface it.
expect(notifier.markedUnread, ['msg:msg-1']);
expect(notifier.state.forcedUnreadContexts, {'msg:msg-1': _channelId});
expect(notifier.state.locallyForcedChannelIds, {_channelId});
});
testWidgets('Mark read after a forced unread clears the force flag so '
'the toggle round-trips', (tester) async {
final prefs = await _mockPrefs();
final notifier = _FakeReadStateNotifier(
_readState(const {_channelId: 2000}),
);
await _pumpSheet(
tester,
message: _message(createdAt: 900),
prefs: prefs,
readStateOverride: () => notifier,
);
// Force the message unread; the row flips to Mark read.
await tester.tap(find.text('Mark unread'));
await tester.pumpAndSettle();
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(find.text('Mark read'), findsOneWidget);
// Mark read must clear the message's own force flag — otherwise the
// row sticks on "Mark read".
await tester.tap(find.text('Mark read'));
await tester.pumpAndSettle();
final container = ProviderScope.containerOf(
tester.element(find.text('open')),
);
expect(container.read(readStateProvider).forcedUnreadContexts, isEmpty);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(find.text('Mark unread'), findsOneWidget);
expect(find.text('Mark read'), findsNothing);
});
testWidgets('message-level Mark read leaves a channel-level forced '
'unread untouched', (tester) async {
final prefs = await _mockPrefs();
final notifier = _FakeReadStateNotifier(
ReadStateState(
isReady: true,
pubkey: 'self',
contexts: const {_channelId: 2000},
version: 1,
// Channel forced unread from the channel tile, message forced
// unread from this sheet.
forcedUnreadContexts: const {
_channelId: _channelId,
'msg:msg-1': _channelId,
},
),
);
await _pumpSheet(
tester,
message: _message(createdAt: 900),
prefs: prefs,
readStateOverride: () => notifier,
);
expect(find.text('Mark read'), findsOneWidget);
await tester.tap(find.text('Mark read'));
await tester.pumpAndSettle();
// The message's flag is gone; the user's channel-level choice stays.
final readState = notifier.state;
expect(readState.forcedUnreadContexts, {_channelId: _channelId});
expect(readState.locallyForcedChannelIds, {_channelId});
});
testWidgets('hides read-state row while read state is not ready', (
tester,
) async {
final prefs = await _mockPrefs();
await _pumpSheet(
tester,
message: _message(),
prefs: prefs,
readStateOverride: () =>
_FakeReadStateNotifier(_readState(const {}, isReady: false)),
);
expect(find.text('Mark unread'), findsNothing);
expect(find.text('Mark read'), findsNothing);
});
testWidgets('Follow thread toggles the effective root id', (tester) async {
final prefs = await _mockPrefs();
await _pumpSheet(
tester,
message: _message(rootId: 'root-9'),
prefs: prefs,
);
await tester.tap(find.text('Follow thread'));
await tester.pumpAndSettle();
final container = ProviderScope.containerOf(
tester.element(find.text('open')),
);
expect(container.read(threadFollowsProvider).followedRootIds, {'root-9'});
// Re-open: the row now offers Unfollow.
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(find.text('Unfollow thread'), findsOneWidget);
await tester.tap(find.text('Unfollow thread'));
await tester.pumpAndSettle();
expect(container.read(threadFollowsProvider).followedRootIds, isEmpty);
});
});
group('showImageActions', () {
testWidgets('labels the destructive action as deleting the message', (
tester,
) async {
await _pumpImageSheet(
tester,
message: _message(),
canManageMessage: true,
);
expect(find.text('Delete message'), findsOneWidget);
expect(find.text('Delete upload'), findsNothing);
});
});
group('downloadedImageFilename', () {
test('preserves gif file extensions', () {
expect(
downloadedImageFilename('https://example.com/animation.gif', null),
'animation.gif',
);
});
test('uses gif extension for gif content types', () {
expect(
downloadedImageFilename(
'https://example.com/download',
'image/gif; charset=binary',
),
matches(RegExp(r'^buzz-\d+\.gif$')),
);
});
});
group('messageLinkFor', () {
test('builds a canonical link with thread context', () {
expect(
messageLinkFor(
message: _message(rootId: 'root-1'),
channelId: _channelId,
),
'buzz://message?channel=chan-1&id=msg-1&thread=root-1',
);
});
test('omits thread for top-level messages', () {
expect(
messageLinkFor(message: _message(), channelId: _channelId),
'buzz://message?channel=chan-1&id=msg-1',
);
});
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/features/channels/message_media.dart';
void main() {
group('classifyMediaUrl', () {
test('treats only mp4 URLs as video fallback', () {
expect(
classifyMediaUrl('https://example.com/media/clip.mp4'),
MessageMediaKind.video,
);
expect(classifyMediaUrl('https://example.com/media/clip.mov'), isNull);
expect(classifyMediaUrl('https://example.com/media/clip.webm'), isNull);
});
test('uses explicit video mimetypes for the video UI', () {
expect(
classifyMediaUrl(
'https://example.com/media/clip.mov',
imeta: const ImetaEntry(
url: 'https://example.com/media/clip.mov',
mimeType: 'video/quicktime',
),
),
MessageMediaKind.video,
);
expect(
classifyMediaUrl(
'https://example.com/media/clip.mp4',
imeta: const ImetaEntry(
url: 'https://example.com/media/clip.mp4',
mimeType: 'video/mp4',
),
),
MessageMediaKind.video,
);
});
});
}
@@ -0,0 +1,390 @@
import 'package:buzz/features/channels/reaction_row.dart';
import 'package:buzz/features/channels/timeline_message.dart';
import 'package:buzz/shared/emoji/emoji_burst.dart';
import 'package:buzz/shared/emoji/emoji_data.dart';
import 'package:buzz/shared/emoji/emoji_data_provider.dart';
import 'package:buzz/features/profile/user_cache_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../helpers/widget_helpers.dart';
const _fire = '\u{1F525}';
/// 👀 — a real reaction that is deliberately not in the positive-burst set.
const _eyes = '\u{1F440}';
TimelineReaction _reaction({
String emoji = _fire,
int count = 1,
bool reactedByCurrentUser = false,
List<String> userPubkeys = const ['alice'],
}) => TimelineReaction(
emoji: emoji,
count: count,
reactedByCurrentUser: reactedByCurrentUser,
userPubkeys: userPubkeys,
);
final _dataset = EmojiDataset(
categories: const [],
all: const [
EmojiEntry(
id: 'fire',
name: 'Fire',
keywords: [],
native: _fire,
categoryId: 'nature',
),
],
nativeToShortcode: const {_fire: ':fire:'},
);
const _messageId = 'msg-1';
/// Pumps the row and hands back the enclosing container, so tests can arm a
/// pending burst or inspect the particle controller.
///
/// Re-pumping keeps the same `ProviderScope` state, which is what lets a test
/// swap the reactions list — standing in for the relay echo — without losing
/// the pending burst that was armed before it.
Future<ProviderContainer> _pumpRow(
WidgetTester tester, {
required List<TimelineReaction> reactions,
void Function(String emoji)? onToggle,
bool showAddButton = false,
VoidCallback? onAddReaction,
String messageId = _messageId,
}) async {
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
emojiDatasetOrEmptyProvider.overrideWithValue(_dataset),
userCacheProvider.overrideWith(
() => _FakeUserCacheNotifier({
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
}),
),
],
child: ReactionRow(
messageId: messageId,
reactions: reactions,
onToggle: onToggle ?? (_) {},
showAddButton: showAddButton,
onAddReaction: onAddReaction,
),
),
);
await tester.pumpAndSettle();
return ProviderScope.containerOf(tester.element(find.byType(ReactionRow)));
}
class _FakeUserCacheNotifier extends UserCacheNotifier {
final Map<String, UserProfile> _profiles;
_FakeUserCacheNotifier(this._profiles);
@override
Map<String, UserProfile> build() => _profiles;
@override
Future<void> preload(Iterable<String> pubkeys) async {}
}
void main() {
group('ReactionRow', () {
testWidgets('shows the count even at one, matching desktop', (
tester,
) async {
await _pumpRow(tester, reactions: [_reaction()]);
expect(
find.byKey(const ValueKey('reaction-pill-$_fire')),
findsOneWidget,
);
expect(find.text('1'), findsOneWidget);
});
testWidgets('collapses entirely when there is nothing to show', (
tester,
) async {
await _pumpRow(tester, reactions: const []);
expect(find.byType(Wrap), findsNothing);
expect(find.byKey(const ValueKey('add-reaction-pill')), findsNothing);
});
testWidgets('renders the + pill on an empty row when asked', (
tester,
) async {
var taps = 0;
await _pumpRow(
tester,
reactions: const [],
showAddButton: true,
onAddReaction: () => taps++,
);
final addPill = find.byKey(const ValueKey('add-reaction-pill'));
expect(addPill, findsOneWidget);
await tester.tap(addPill);
expect(taps, 1);
});
testWidgets('+ pill trails the existing reactions', (tester) async {
await _pumpRow(
tester,
reactions: [
_reaction(),
_reaction(emoji: '\u{1F44D}', count: 3),
],
showAddButton: true,
onAddReaction: () {},
);
final firstPill = tester.getRect(
find.byKey(const ValueKey('reaction-pill-$_fire')),
);
final lastPill = tester.getRect(
find.byKey(const ValueKey('reaction-pill-\u{1F44D}')),
);
final addPill = tester.getRect(
find.byKey(const ValueKey('add-reaction-pill')),
);
// One row, in order, with the + last.
expect(lastPill.left, greaterThan(firstPill.left));
expect(addPill.left, greaterThan(lastPill.left));
expect(addPill.top, firstPill.top);
// Pills hug their content. A `Container.alignment` here would silently
// stretch each one to the full row width and stack them vertically.
expect(firstPill.height, 28);
expect(firstPill.width, greaterThanOrEqualTo(48));
expect(firstPill.width, lessThan(120));
expect(addPill.width, 40);
});
testWidgets('the + pill stays hidden without a handler', (tester) async {
await _pumpRow(tester, reactions: [_reaction()], showAddButton: true);
expect(find.byKey(const ValueKey('add-reaction-pill')), findsNothing);
});
testWidgets('tapping a pill toggles that emoji', (tester) async {
final toggled = <String>[];
await _pumpRow(tester, reactions: [_reaction()], onToggle: toggled.add);
await tester.tap(find.byKey(const ValueKey('reaction-pill-$_fire')));
expect(toggled, [_fire]);
});
testWidgets('long-press opens the detail sheet named from the dataset', (
tester,
) async {
await _pumpRow(
tester,
reactions: [
_reaction(count: 1, userPubkeys: const ['alice']),
],
);
await tester.longPress(
find.byKey(const ValueKey('reaction-pill-$_fire')),
);
await tester.pumpAndSettle();
expect(find.text(':fire:'), findsOneWidget);
expect(find.text('Alice'), findsOneWidget);
});
});
group('reaction bursts', () {
testWidgets('adding a positive reaction bursts from the pill', (
tester,
) async {
final container = await _pumpRow(tester, reactions: [_reaction()]);
final controller = container.read(emojiBurstControllerProvider);
expect(controller.hasParticles, isFalse);
await tester.tap(find.byKey(const ValueKey('reaction-pill-$_fire')));
await tester.pump();
expect(controller.hasParticles, isTrue);
});
testWidgets('a non-positive reaction does not burst', (tester) async {
final container = await _pumpRow(
tester,
reactions: [_reaction(emoji: _eyes)],
);
final controller = container.read(emojiBurstControllerProvider);
await tester.tap(find.byKey(const ValueKey('reaction-pill-$_eyes')));
await tester.pump();
expect(controller.hasParticles, isFalse);
});
testWidgets('removing your own reaction does not burst', (tester) async {
final container = await _pumpRow(
tester,
reactions: [_reaction(reactedByCurrentUser: true)],
);
final controller = container.read(emojiBurstControllerProvider);
await tester.tap(find.byKey(const ValueKey('reaction-pill-$_fire')));
await tester.pump();
expect(controller.hasParticles, isFalse);
});
testWidgets('a pending burst fires when the new pill arrives', (
tester,
) async {
// Reacting from the quick row or the picker arms a burst, then the pill
// only appears once the relay echoes the reaction back.
final container = await _pumpRow(tester, reactions: const []);
final controller = container.read(emojiBurstControllerProvider);
container
.read(pendingReactionBurstProvider.notifier)
.arm(_messageId, _fire);
await _pumpRow(
tester,
reactions: [_reaction(reactedByCurrentUser: true)],
);
await tester.pump();
expect(controller.hasParticles, isTrue);
// Claimed, so a second row showing the same message can't double-burst.
expect(container.read(pendingReactionBurstProvider), isNull);
});
testWidgets('a pending burst waits for our own reaction, not anyone\'s', (
tester,
) async {
final container = await _pumpRow(tester, reactions: const []);
final controller = container.read(emojiBurstControllerProvider);
container
.read(pendingReactionBurstProvider.notifier)
.arm(_messageId, _fire);
// Someone else's reaction lands first.
await _pumpRow(tester, reactions: [_reaction()]);
await tester.pump();
expect(controller.hasParticles, isFalse);
expect(container.read(pendingReactionBurstProvider), isNotNull);
});
testWidgets('a pending burst armed for another message is left alone', (
tester,
) async {
final container = await _pumpRow(tester, reactions: const []);
final controller = container.read(emojiBurstControllerProvider);
container
.read(pendingReactionBurstProvider.notifier)
.arm('some-other-message', _fire);
await _pumpRow(
tester,
reactions: [_reaction(reactedByCurrentUser: true)],
);
await tester.pump();
expect(controller.hasParticles, isFalse);
expect(container.read(pendingReactionBurstProvider), isNotNull);
});
testWidgets(
'a pill under a pushed route leaves the burst for the top one',
(tester) async {
// The channel timeline stays mounted under a pushed thread page and
// rebuilds first, so without a route check it claimed the burst and the
// user saw nothing until they popped back.
final navigatorKey = GlobalKey<NavigatorState>();
late ProviderContainer container;
await tester.pumpWidget(
ProviderScope(
overrides: [
emojiDatasetOrEmptyProvider.overrideWithValue(_dataset),
userCacheProvider.overrideWith(
() => _FakeUserCacheNotifier(const {}),
),
],
child: MaterialApp(
navigatorKey: navigatorKey,
home: Scaffold(
body: ReactionRow(
messageId: _messageId,
reactions: [_reaction(reactedByCurrentUser: true)],
onToggle: (_) {},
),
),
),
),
);
await tester.pumpAndSettle();
container = ProviderScope.containerOf(
tester.element(find.byType(ReactionRow)),
);
final controller = container.read(emojiBurstControllerProvider);
// Push a route over it, then arm and let the covered pill rebuild.
navigatorKey.currentState!.push(
MaterialPageRoute<void>(
builder: (_) => const Scaffold(body: Text('thread')),
),
);
await tester.pumpAndSettle();
container
.read(pendingReactionBurstProvider.notifier)
.arm(_messageId, _fire);
await tester.pump();
expect(controller.hasParticles, isFalse);
// Still armed, so the pill on the visible route can claim it.
expect(container.read(pendingReactionBurstProvider), isNotNull);
},
);
testWidgets('reduced motion suppresses the burst', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
emojiDatasetOrEmptyProvider.overrideWithValue(_dataset),
userCacheProvider.overrideWith(
() => _FakeUserCacheNotifier(const {}),
),
],
child: MediaQuery(
data: const MediaQueryData(disableAnimations: true),
child: MaterialApp(
home: Scaffold(
body: ReactionRow(
messageId: _messageId,
reactions: [_reaction()],
onToggle: (_) {},
),
),
),
),
),
);
await tester.pumpAndSettle();
final container = ProviderScope.containerOf(
tester.element(find.byType(ReactionRow)),
);
final controller = container.read(emojiBurstControllerProvider);
await tester.tap(find.byKey(const ValueKey('reaction-pill-$_fire')));
await tester.pump();
expect(controller.hasParticles, isFalse);
});
});
}
@@ -0,0 +1,151 @@
import 'package:buzz/shared/read_state/message_read_state.dart';
import 'package:buzz/shared/read_state/read_state_provider.dart';
import 'package:flutter_test/flutter_test.dart';
ReadStateState _state(
Map<String, int> contexts, {
Map<String, String> forced = const {},
}) => ReadStateState(
isReady: true,
pubkey: 'pk',
contexts: contexts,
version: 1,
forcedUnreadContexts: forced,
);
void main() {
const channelId = 'chan-1';
const messageId = 'msg-1';
group('effectiveMessageReadAt', () {
test('is null with no markers', () {
expect(
effectiveMessageReadAt(
_state(const {}),
channelId: channelId,
messageId: messageId,
),
isNull,
);
});
test('takes the newest of channel, message, and thread markers', () {
final readState = _state(const {
channelId: 100,
'msg:$messageId': 300,
'thread:root-1': 200,
});
expect(
effectiveMessageReadAt(
readState,
channelId: channelId,
messageId: messageId,
threadRootId: 'root-1',
),
300,
);
});
test('ignores thread marker when no root is given', () {
final readState = _state(const {'thread:root-1': 500});
expect(
effectiveMessageReadAt(
readState,
channelId: channelId,
messageId: messageId,
),
isNull,
);
});
});
group('isMessageUnread', () {
test('unread when created after all markers', () {
final readState = _state(const {channelId: 100});
expect(
isMessageUnread(
readState,
channelId: channelId,
messageId: messageId,
createdAt: 150,
),
isTrue,
);
});
test('read when covered by the channel marker', () {
final readState = _state(const {channelId: 200});
expect(
isMessageUnread(
readState,
channelId: channelId,
messageId: messageId,
createdAt: 150,
),
isFalse,
);
});
test('read when covered by its own msg marker', () {
final readState = _state(const {'msg:$messageId': 150});
expect(
isMessageUnread(
readState,
channelId: channelId,
messageId: messageId,
createdAt: 150,
),
isFalse,
);
});
test('thread reply covered by thread marker', () {
final readState = _state(const {'thread:root-1': 400});
expect(
isMessageUnread(
readState,
channelId: channelId,
messageId: messageId,
createdAt: 350,
threadRootId: 'root-1',
),
isFalse,
);
});
test('forced-unread message wins over markers', () {
final readState = _state(
const {channelId: 1000},
forced: const {'msg:$messageId': channelId},
);
expect(
isMessageUnread(
readState,
channelId: channelId,
messageId: messageId,
createdAt: 150,
),
isTrue,
);
});
test('channel-level forced unread does not leak into message state', () {
// A channel forced unread from the channel tile is a channel-scoped
// choice — an already-read message inside it still shows as read, so
// its own Mark read/unread toggle round-trips independently.
final readState = _state(
const {channelId: 1000},
forced: const {channelId: channelId},
);
expect(
isMessageUnread(
readState,
channelId: channelId,
messageId: messageId,
createdAt: 150,
),
isFalse,
);
});
});
}
@@ -0,0 +1,164 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/read_state/read_state_format.dart';
import 'package:buzz/shared/relay/nostr_models.dart';
void main() {
group('read state event validation', () {
test('requires exactly one valid d tag and one read-state t tag', () {
final plaintext = jsonEncode({
'v': 1,
'client_id': 'client-a',
'contexts': {'channel-a': 1},
});
expect(
decodeReadStateEvent(
_event(tags: const []),
pubkey: 'user-pubkey',
decrypt: (_) => plaintext,
),
isNull,
);
expect(
decodeReadStateEvent(
_event(
tags: const [
['d', 'read-state:slot-a'],
['d', 'read-state:slot-b'],
['t', 'read-state'],
],
),
pubkey: 'user-pubkey',
decrypt: (_) => plaintext,
),
isNull,
);
expect(
decodeReadStateEvent(
_event(
tags: const [
['d', 'read-state:slót'],
['t', 'read-state'],
],
),
pubkey: 'user-pubkey',
decrypt: (_) => plaintext,
),
isNull,
);
expect(
decodeReadStateEvent(
_event(
tags: const [
['d', 'read-state:slot-a'],
],
),
pubkey: 'user-pubkey',
decrypt: (_) => plaintext,
),
isNull,
);
expect(
decodeReadStateEvent(
_event(
tags: const [
['d', 'read-state:slot-a'],
['t', 'read-state'],
['t', 'read-state'],
],
),
pubkey: 'user-pubkey',
decrypt: (_) => plaintext,
),
isNull,
);
});
test('decrypts and sanitizes a valid read-state blob', () {
final longContextId = 'x' * 257;
final plaintext = jsonEncode({
'v': 1,
'client_id': 'client-a',
'contexts': {
'channel-a': 10,
'string-value': '10',
'double-value': 10.5,
'negative': -1,
'too-large': 4294967296,
longContextId: 20,
},
});
final decoded = decodeReadStateEvent(
_event(),
pubkey: 'user-pubkey',
decrypt: (_) => plaintext,
);
expect(decoded, isNotNull);
expect(decoded!.dTag, 'read-state:slot-a');
expect(decoded.blob.clientId, 'client-a');
expect(decoded.blob.contexts, {'channel-a': 10});
});
test('rejects malformed blobs', () {
expect(decodeReadStateBlob('not json'), isNull);
expect(
decodeReadStateBlob(
jsonEncode({
'v': 2,
'client_id': 'client-a',
'contexts': <String, int>{},
}),
),
isNull,
);
expect(
decodeReadStateBlob(
jsonEncode({'v': 1, 'client_id': '', 'contexts': <String, int>{}}),
),
isNull,
);
expect(
decodeReadStateBlob(
jsonEncode({
'v': 1,
'client_id': 'client-a',
'contexts': List.filled(1, 'not-a-map'),
}),
),
isNull,
);
});
});
test('mergeReadStateContexts keeps the maximum timestamp per context', () {
expect(
mergeReadStateContexts([
{'channel-a': 10, 'channel-b': 5},
{'channel-a': 7, 'channel-c': 1},
{'channel-b': 12},
]),
{'channel-a': 10, 'channel-b': 12, 'channel-c': 1},
);
});
}
NostrEvent _event({List<List<String>>? tags}) {
return NostrEvent(
id: 'event-id',
pubkey: 'user-pubkey',
createdAt: 100,
kind: EventKind.readState,
tags:
tags ??
const [
['d', 'read-state:slot-a'],
['t', 'read-state'],
],
content: 'ciphertext',
sig: 'sig',
);
}
@@ -0,0 +1,304 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:buzz/shared/read_state/read_state_format.dart';
import 'package:buzz/shared/read_state/read_state_manager.dart';
import 'package:buzz/shared/relay/relay.dart';
void main() {
test('dispose flushes a pending publish after marking disposed', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final keychain = nostr.Keys.generate();
final nsec = keychain.nsec;
final crypto = ReadStateCrypto.tryCreate(
nsec: nsec,
pubkey: keychain.public,
);
final relay = _FakeSignedEventRelay();
final manager = ReadStateManager(
pubkey: keychain.public,
prefs: prefs,
crypto: crypto!,
relaySession: null,
signedEventRelay: relay,
remoteEnabled: true,
onChanged: () {},
);
manager.markContextRead('channel-1', 42);
manager.dispose();
final submitted = await relay.submitted.future.timeout(
const Duration(seconds: 1),
);
expect(submitted.kind, EventKind.readState);
expect(
submitted.tags.any(
(tag) => tag.length == 2 && tag[0] == 't' && tag[1] == 'read-state',
),
isTrue,
);
});
test('disables remote sync after relay rejects read-state kind', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final keychain = nostr.Keys.generate();
final nsec = keychain.nsec;
final crypto = ReadStateCrypto.tryCreate(
nsec: nsec,
pubkey: keychain.public,
);
final relay = _UnsupportedKindSignedEventRelay();
final manager = ReadStateManager(
pubkey: keychain.public,
prefs: prefs,
crypto: crypto!,
relaySession: null,
signedEventRelay: relay,
remoteEnabled: true,
onChanged: () {},
);
manager.markContextRead('channel-1', 42);
await manager.flush();
manager.markContextRead('channel-2', 43);
await manager.flush();
expect(relay.submitCount, 1);
expect(manager.getEffectiveTimestamp('channel-2'), 43);
});
test(
'disables remote sync after token permanently lacks write scope',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final keychain = nostr.Keys.generate();
final nsec = keychain.nsec;
final crypto = ReadStateCrypto.tryCreate(
nsec: nsec,
pubkey: keychain.public,
);
final relay = _MissingScopeSignedEventRelay();
final manager = ReadStateManager(
pubkey: keychain.public,
prefs: prefs,
crypto: crypto!,
relaySession: null,
signedEventRelay: relay,
remoteEnabled: true,
onChanged: () {},
);
manager.markContextRead('channel-1', 42);
await manager.flush();
manager.markContextRead('channel-2', 43);
await manager.flush();
expect(relay.submitCount, 1);
expect(manager.getEffectiveTimestamp('channel-2'), 43);
},
);
test('disables remote sync after an oversized local blob', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final keychain = nostr.Keys.generate();
final crypto = ReadStateCrypto.tryCreate(
nsec: keychain.nsec,
pubkey: keychain.public,
)!;
final relay = _FakeSignedEventRelay();
final manager = ReadStateManager(
pubkey: keychain.public,
prefs: prefs,
crypto: crypto,
relaySession: null,
signedEventRelay: relay,
remoteEnabled: true,
onChanged: () {},
);
for (var index = 0; index < 1400; index++) {
manager.markContextRead(
'channel-${index.toString().padLeft(4, '0')}-${'x' * 48}',
index + 1,
);
}
await manager.flush();
manager.markContextRead('channel-new', 2000);
await manager.flush();
expect(relay.submitCount, 0);
expect(manager.getEffectiveTimestamp('channel-0000-${'x' * 48}'), 1);
expect(manager.getEffectiveTimestamp('channel-new'), 2000);
});
test('remote read-state rollback is ignored', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final keychain = nostr.Keys.generate();
final crypto = ReadStateCrypto.tryCreate(
nsec: keychain.nsec,
pubkey: keychain.public,
);
final relay = _FakeRelaySession();
final manager = ReadStateManager(
pubkey: keychain.public,
prefs: prefs,
crypto: crypto!,
relaySession: relay,
signedEventRelay: _FakeSignedEventRelay(),
remoteEnabled: true,
onChanged: () {},
);
relay.historyEvents = [
_readStateEvent(
pubkey: keychain.public,
crypto: crypto,
clientId: 'remote-client',
slotId: 'remote-slot',
contexts: {'channel-1': 100},
createdAt: 100,
),
_readStateEvent(
pubkey: keychain.public,
crypto: crypto,
clientId: 'remote-client',
slotId: 'remote-slot',
contexts: {'channel-1': 50},
createdAt: 110,
),
];
await manager.initialize();
expect(manager.getEffectiveTimestamp('channel-1'), 100);
});
}
class _SubmittedEvent {
final int kind;
final List<List<String>> tags;
const _SubmittedEvent({required this.kind, required this.tags});
}
/// Build a stub NostrEvent for tests that just need a "ack" return value.
NostrEvent _stubAckEvent() => const NostrEvent(
id: 'stub',
pubkey: '',
createdAt: 0,
kind: 0,
tags: [],
content: '',
sig: '',
);
class _FakeSignedEventRelay implements SignedEventRelay {
final Completer<_SubmittedEvent> submitted = Completer<_SubmittedEvent>();
int submitCount = 0;
@override
String? get pubkey => null;
@override
Future<NostrEvent> submit({
required int kind,
required String content,
required List<List<String>> tags,
int? createdAt,
void Function(NostrEvent event)? onSigned,
}) async {
submitCount++;
submitted.complete(_SubmittedEvent(kind: kind, tags: tags));
return _stubAckEvent();
}
}
class _UnsupportedKindSignedEventRelay implements SignedEventRelay {
int submitCount = 0;
@override
String? get pubkey => null;
@override
Future<NostrEvent> submit({
required int kind,
required String content,
required List<List<String>> tags,
int? createdAt,
void Function(NostrEvent event)? onSigned,
}) async {
submitCount++;
throw Exception('restricted: unknown event kind');
}
}
class _MissingScopeSignedEventRelay implements SignedEventRelay {
int submitCount = 0;
@override
String? get pubkey => null;
@override
Future<NostrEvent> submit({
required int kind,
required String content,
required List<List<String>> tags,
int? createdAt,
void Function(NostrEvent event)? onSigned,
}) async {
submitCount++;
throw Exception('missing users:write');
}
}
NostrEvent _readStateEvent({
required String pubkey,
required ReadStateCrypto crypto,
required String clientId,
required String slotId,
required Map<String, int> contexts,
required int createdAt,
}) {
final blob = ReadStateBlob(clientId: clientId, contexts: contexts);
return NostrEvent(
id: 'event-$clientId-$createdAt',
pubkey: pubkey,
createdAt: createdAt,
kind: EventKind.readState,
tags: [
['d', '$readStateDTagPrefix$slotId'],
['t', 'read-state'],
],
content: crypto.encrypt(jsonEncode(blob.toJson())),
sig: 'sig',
);
}
class _FakeRelaySession extends RelaySessionNotifier {
List<NostrEvent> historyEvents = [];
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async => historyEvents;
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) async => () {};
}
@@ -0,0 +1,159 @@
import 'package:buzz/shared/read_state/read_state_format.dart';
import 'package:buzz/shared/read_state/read_state_provider.dart';
import 'package:buzz/shared/community/community_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme_provider.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
/// Drives the production [ReadStateNotifier] (real bookkeeping, real
/// [ReadStateManager]) against an inert fake relay session, so a defect in
/// the forced-unread map removal or the manager-emission path fails here
/// instead of being masked by a fake notifier.
void main() {
const channelId = 'chan-1';
const otherChannelId = 'chan-2';
final msgKey = msgContextKey('msg-1');
final otherMsgKey = msgContextKey('msg-2');
late ProviderContainer container;
Future<ReadStateNotifier> pumpNotifier() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final nsec = nostr.Keys.generate().nsec;
container = ProviderContainer(
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
relayConfigProvider.overrideWith(() => _FakeRelayConfig(nsec)),
relaySessionProvider.overrideWith(_FakeRelaySession.new),
activeCommunityProvider.overrideWith((ref) async => null),
appLifecycleProvider.overrideWith(_FakeAppLifecycle.new),
],
);
addTearDown(container.dispose);
final notifier = container.read(readStateProvider.notifier);
// Let the async activeCommunity resolution, the resulting rebuild, and
// the manager initialize() microtasks run.
await container.read(activeCommunityProvider.future);
for (var i = 0; i < 10 && !container.read(readStateProvider).isReady; i++) {
await Future<void>.delayed(Duration.zero);
}
expect(container.read(readStateProvider).isReady, isTrue);
return notifier;
}
ReadStateState state() => container.read(readStateProvider);
test('message unread → read → unread round-trips through the real '
'notifier', () async {
final notifier = await pumpNotifier();
// Force the message unread.
notifier.markContextUnread(msgKey, channelId: channelId);
expect(state().isForcedUnread(msgKey), isTrue);
expect(state().locallyForcedChannelIds, {channelId});
// Mark read: the force flag must drop out of the production map AND the
// marker must land in the manager's effective contexts.
notifier.markContextRead(msgKey, 1000);
expect(state().isForcedUnread(msgKey), isFalse);
expect(state().forcedUnreadContexts, isEmpty);
expect(state().effectiveTimestamp(msgKey), 1000);
// Force unread again: read markers are monotonic, so the flag is the
// only mechanism — it must win even though the marker persists.
notifier.markContextUnread(msgKey, channelId: channelId);
expect(state().isForcedUnread(msgKey), isTrue);
expect(state().effectiveTimestamp(msgKey), 1000);
});
test('explicit channel-level Mark read clears forced message entries in '
'that channel only', () async {
final notifier = await pumpNotifier();
notifier.markContextUnread(msgKey, channelId: channelId);
notifier.markContextUnread(otherMsgKey, channelId: otherChannelId);
// Channel tile "Mark as read" passes clearForcedMessages: true.
notifier.markContextRead(channelId, 2000, clearForcedMessages: true);
expect(state().isForcedUnread(msgKey), isFalse);
expect(state().isForcedUnread(otherMsgKey), isTrue);
expect(state().locallyForcedChannelIds, {otherChannelId});
expect(state().effectiveTimestamp(channelId), 2000);
});
test(
'automatic channel-open read preserves forced message entries',
() async {
final notifier = await pumpNotifier();
notifier.markContextUnread(msgKey, channelId: channelId);
// Channel open marks the channel read without clearForcedMessages.
notifier.markContextRead(channelId, 3000);
expect(state().isForcedUnread(msgKey), isTrue);
expect(state().locallyForcedChannelIds, {channelId});
expect(state().effectiveTimestamp(channelId), 3000);
},
);
test('automatic channel-open read still clears a channel-level force for '
'the same channel', () async {
final notifier = await pumpNotifier();
// Channel forced unread from the tile, message forced from the sheet.
notifier.markContextUnread(channelId, channelId: channelId);
notifier.markContextUnread(msgKey, channelId: channelId);
notifier.markContextRead(channelId, 4000);
// Opening the channel satisfies the channel-scoped force, but the
// deliberate message-level force survives until acted on.
expect(state().isForcedUnread(channelId), isFalse);
expect(state().isForcedUnread(msgKey), isTrue);
expect(state().locallyForcedChannelIds, {channelId});
});
}
class _FakeRelayConfig extends RelayConfigNotifier {
final String nsec;
_FakeRelayConfig(this.nsec);
@override
RelayConfig build() => RelayConfig(baseUrl: 'http://localhost:1', nsec: nsec);
}
/// Relay session that never connects and returns no history, keeping the
/// manager fully local while exercising its real code paths.
class _FakeRelaySession extends RelaySessionNotifier {
@override
SessionState build() =>
const SessionState(status: SessionStatus.disconnected);
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async => [];
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) async => () {};
}
class _FakeAppLifecycle extends AppLifecycleNotifier {
@override
AppLifecycleState build() => AppLifecycleState.resumed;
}
@@ -0,0 +1,25 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/read_state/read_state_time.dart';
void main() {
test('dateTimeToUnixSeconds converts DateTime values and nulls', () {
final value = DateTime.utc(2026, 4, 29, 4, 30, 45, 900);
expect(dateTimeToUnixSeconds(value), 1777437045);
expect(dateTimeToUnixSeconds(null), isNull);
});
test('isoToUnixSeconds parses stored ISO timestamps defensively', () {
expect(isoToUnixSeconds('1970-01-01T00:00:42.000Z'), 42);
expect(isoToUnixSeconds(''), isNull);
expect(isoToUnixSeconds('not-a-date'), isNull);
expect(isoToUnixSeconds(42), isNull);
});
test('unixSecondsToDateTime writes UTC ISO-compatible values', () {
expect(
unixSecondsToDateTime(1700000000).toIso8601String(),
'2023-11-14T22:13:20.000Z',
);
});
}
@@ -0,0 +1,121 @@
import 'dart:async';
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/send_message_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
void main() {
test(
'adds the signed message locally before relay acknowledgement',
() async {
final session = _PendingPublishRelaySession();
final localMessages = <NostrEvent>[];
final removedIds = <String>[];
final completedIds = <String>[];
final send = SendMessage(
signedEventRelay: SignedEventRelay(
session: session,
nsec: nostr.Keys.generate().nsec,
),
fetchMembers: (_) async => const [],
readUserCache: () => const {},
addLocalMessage: (_, event) => localMessages.add(event),
completeLocalMessage: (_, eventId) => completedIds.add(eventId),
removeLocalMessage: (_, eventId) => removedIds.add(eventId),
);
final result = send(channelId: _channelId, content: 'hello');
await session.published;
expect(localMessages, hasLength(1));
expect(localMessages.single.id, session.event.id);
expect(localMessages.single.content, 'hello');
expect(localMessages.single.channelId, _channelId);
expect(removedIds, isEmpty);
session.accept();
await result;
expect(completedIds, [localMessages.single.id]);
expect(removedIds, isEmpty);
},
);
test('rolls back the signed local message when publish fails', () async {
final session = _PendingPublishRelaySession();
final localMessages = <NostrEvent>[];
final completedIds = <String>[];
final removedIds = <String>[];
final send = SendMessage(
signedEventRelay: SignedEventRelay(
session: session,
nsec: nostr.Keys.generate().nsec,
),
fetchMembers: (_) async => const [],
readUserCache: () => const {},
addLocalMessage: (_, event) => localMessages.add(event),
completeLocalMessage: (_, eventId) => completedIds.add(eventId),
removeLocalMessage: (_, eventId) => removedIds.add(eventId),
);
final result = send(channelId: _channelId, content: 'hello');
await session.published;
session.reject();
await expectLater(result, throwsException);
expect(completedIds, isEmpty);
expect(removedIds, [localMessages.single.id]);
});
test('cancels delivery after the active community changes', () async {
final container = ProviderContainer();
addTearDown(container.dispose);
container
.read(relayConfigProvider.notifier)
.update(baseUrl: 'https://first.example');
final send = container.read(sendMessageProvider);
container
.read(relayConfigProvider.notifier)
.update(baseUrl: 'https://second.example');
await expectLater(
send(channelId: _channelId, content: 'old community draft'),
throwsA(
isA<StateError>().having(
(error) => error.message,
'message',
contains('active community changed'),
),
),
);
});
}
const _channelId = '11111111-1111-4111-8111-111111111111';
class _PendingPublishRelaySession extends RelaySessionNotifier {
final Completer<NostrEvent> _result = Completer<NostrEvent>();
final Completer<void> _published = Completer<void>();
late NostrEvent event;
Future<void> get published => _published.future;
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<NostrEvent> publish(
NostrEvent event, {
Duration timeout = const Duration(seconds: 8),
}) {
this.event = event;
_published.complete();
return _result.future;
}
void accept() => _result.complete(event);
void reject() => _result.completeError(Exception('relay rejected event'));
}
@@ -0,0 +1,60 @@
import 'package:buzz/features/channels/thread_follows/thread_follows_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
Future<SharedPreferences> _prefs() async {
SharedPreferences.setMockInitialValues({});
return SharedPreferences.getInstance();
}
void main() {
const pubkey = 'abc123';
test('round-trips entries per pubkey', () async {
final storage = ThreadFollowsStorage(await _prefs());
storage.write(pubkey, const [
ThreadFollowEntry(rootId: 'root-1', followedAt: 100),
ThreadFollowEntry(rootId: 'root-2', followedAt: 200),
]);
final entries = storage.read(pubkey);
expect(entries.map((e) => e.rootId), ['root-1', 'root-2']);
expect(entries.map((e) => e.followedAt), [100, 200]);
expect(storage.read('other-pubkey'), isEmpty);
});
test('ignores malformed stored payloads', () async {
SharedPreferences.setMockInitialValues({
threadFollowsKey(pubkey): '{"not":"a list"}',
});
final storage = ThreadFollowsStorage(await SharedPreferences.getInstance());
expect(storage.read(pubkey), isEmpty);
});
test('drops malformed entries but keeps valid ones', () async {
SharedPreferences.setMockInitialValues({
threadFollowsKey(pubkey):
'[{"rootId":"good","followedAt":5},{"rootId":42},"junk"]',
});
final storage = ThreadFollowsStorage(await SharedPreferences.getInstance());
final entries = storage.read(pubkey);
expect(entries, hasLength(1));
expect(entries.single.rootId, 'good');
});
test('capThreadFollowEntries evicts oldest first', () {
final entries = [
for (var i = 0; i < threadFollowsMaxEntries + 10; i++)
ThreadFollowEntry(rootId: 'root-$i', followedAt: i),
];
final capped = capThreadFollowEntries(entries);
expect(capped, hasLength(threadFollowsMaxEntries));
expect(capped.first.rootId, 'root-10');
expect(capped.last.rootId, 'root-${threadFollowsMaxEntries + 9}');
});
test('capThreadFollowEntries keeps small lists untouched', () {
const entries = [ThreadFollowEntry(rootId: 'a', followedAt: 1)];
expect(capThreadFollowEntries(entries), same(entries));
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,480 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/shared/read_state/read_state_provider.dart';
import 'package:buzz/features/channels/unread_badge/observed_unread_event.dart';
import 'package:buzz/features/channels/unread_badge/unread_badge_provider.dart';
/// Unit tests for [unreadBadgeProvider].
///
/// Strategy: override [channelsProvider] with a [_StubbedChannelsNotifier] that
/// returns a pre-built channel list and allows seeding [latestHighPriorityByChannel],
/// and override [readStateProvider] with a [_ReadStateNotifier] that holds a
/// fixed [ReadStateState]. This avoids standing up any relay connection and
/// exercises only the badge-computation logic.
///
/// Because [channelsProvider] is an [AsyncNotifierProvider], the provider starts
/// in [AsyncLoading] — tests that check computed badge values must await
/// [channelsProvider.future] to let the notifier resolve before reading the badge.
void main() {
// Fixed epoch timestamps (Unix seconds).
const t10 = 10; // older
const t20 = 20; // newer — any channel with lastMessageAt == t20 has a message
const t30 = 30; // even newer — used for high-priority events
Channel makeChannel({
required String id,
String channelType = 'stream',
bool isMember = true,
bool isArchived = false,
int? lastMessageAtSeconds,
}) {
return Channel(
id: id,
name: id,
channelType: channelType,
visibility: 'open',
description: '',
createdBy: 'creator',
createdAt: DateTime.utc(2024),
memberCount: 1,
isMember: isMember,
archivedAt: isArchived ? DateTime.utc(2024) : null,
lastMessageAt: lastMessageAtSeconds != null
? DateTime.fromMillisecondsSinceEpoch(
lastMessageAtSeconds * 1000,
isUtc: true,
)
: null,
);
}
ProviderContainer buildContainer({
required List<Channel> channels,
Map<String, int> readContexts = const {},
Map<String, String> forcedUnreadContexts = const {},
bool readStateReady = true,
Map<String, int> highPriorityMap = const {},
Map<String, List<ObservedUnreadEvent>>? observedEventsByChannel,
}) {
final notifier = _StubbedChannelsNotifier(
channels: channels,
observedEventsByChannel:
observedEventsByChannel ??
_defaultObservedEvents(channels, highPriorityMap),
);
return ProviderContainer(
overrides: [
channelsProvider.overrideWith(() => notifier),
readStateProvider.overrideWith(
() => _ReadStateNotifier(
ReadStateState(
isReady: readStateReady,
pubkey: 'me',
contexts: readContexts,
version: 1,
forcedUnreadContexts: forcedUnreadContexts,
),
),
),
],
);
}
test('all channels read returns (0, 0)', () async {
final container = buildContainer(
channels: [
makeChannel(id: 'ch-a', lastMessageAtSeconds: t20),
makeChannel(id: 'ch-b', lastMessageAtSeconds: t20),
],
// Read at t30, which is after every message.
readContexts: {'ch-a': t30, 'ch-b': t30},
);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
final badge = container.read(unreadBadgeProvider);
expect(badge.highPriorityCount, 0);
expect(badge.generalUnreadCount, 0);
});
test(
'one unread non-DM channel with no high-priority event → (0, 1) general only',
() async {
final container = buildContainer(
channels: [makeChannel(id: 'ch-a', lastMessageAtSeconds: t20)],
readContexts: {},
highPriorityMap: {},
);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
final badge = container.read(unreadBadgeProvider);
expect(badge.highPriorityCount, 0);
expect(badge.generalUnreadCount, 1);
},
);
test('one unread DM channel → (1, 0) high priority', () async {
final container = buildContainer(
channels: [
makeChannel(id: 'dm-a', channelType: 'dm', lastMessageAtSeconds: t20),
],
readContexts: {},
);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
final badge = container.read(unreadBadgeProvider);
expect(badge.highPriorityCount, 1);
expect(badge.generalUnreadCount, 0);
});
test(
'one unread non-DM with unread high-priority @mention → (1, 0) high priority',
() async {
const channelId = 'ch-a';
final container = buildContainer(
channels: [makeChannel(id: channelId, lastMessageAtSeconds: t20)],
// Read up through t10; both the message (t20) and @mention (t30) are
// newer than the read marker.
readContexts: {channelId: t10},
highPriorityMap: {channelId: t30},
);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
final badge = container.read(unreadBadgeProvider);
expect(badge.highPriorityCount, 1);
expect(badge.generalUnreadCount, 0);
},
);
test(
'mixed: 1 unread DM + 2 unread non-DMs (no high-priority) → (1, 2)',
() async {
final container = buildContainer(
channels: [
makeChannel(id: 'dm-a', channelType: 'dm', lastMessageAtSeconds: t20),
makeChannel(id: 'ch-b', lastMessageAtSeconds: t20),
makeChannel(id: 'ch-c', lastMessageAtSeconds: t20),
],
readContexts: {},
highPriorityMap: {},
);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
final badge = container.read(unreadBadgeProvider);
expect(badge.highPriorityCount, 1);
expect(badge.generalUnreadCount, 2);
},
);
test('archived channel is excluded even if unread', () async {
final container = buildContainer(
channels: [
makeChannel(
id: 'ch-archived',
isArchived: true,
lastMessageAtSeconds: t20,
),
makeChannel(id: 'ch-active', lastMessageAtSeconds: t20),
],
readContexts: {},
);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
final badge = container.read(unreadBadgeProvider);
// Archived channel must not count; active one does.
expect(badge.highPriorityCount, 0);
expect(badge.generalUnreadCount, 1);
});
test('non-member channel is excluded even if unread', () async {
final container = buildContainer(
channels: [
makeChannel(
id: 'ch-nonmember',
isMember: false,
lastMessageAtSeconds: t20,
),
makeChannel(id: 'ch-member', lastMessageAtSeconds: t20),
],
readContexts: {},
);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
final badge = container.read(unreadBadgeProvider);
expect(badge.highPriorityCount, 0);
expect(badge.generalUnreadCount, 1);
});
test('channel with lastMessageAt == null is excluded', () async {
final container = buildContainer(
channels: [
// No lastMessageAt — provider treats this as having no messages.
makeChannel(id: 'ch-nomsg'),
makeChannel(id: 'ch-withmsg', lastMessageAtSeconds: t20),
],
readContexts: {},
);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
final badge = container.read(unreadBadgeProvider);
expect(badge.highPriorityCount, 0);
expect(badge.generalUnreadCount, 1);
});
test(
'read state not ready (isReady: false) does not suppress unread counts',
() async {
// The provider does not gate on isReady — it computes from whatever
// timestamps are in contexts. With an empty context map and readStateReady=false,
// readAt is null → channel is unread → (0, 1).
final container = buildContainer(
channels: [makeChannel(id: 'ch-a', lastMessageAtSeconds: t20)],
readContexts: {},
readStateReady: false,
);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
final badge = container.read(unreadBadgeProvider);
expect(badge.highPriorityCount, 0);
expect(badge.generalUnreadCount, 1);
},
);
test(
'locally forced channel counts unread without publishing rollback',
() async {
final container = buildContainer(
channels: [makeChannel(id: 'ch-a', lastMessageAtSeconds: t20)],
readContexts: {'ch-a': t30},
forcedUnreadContexts: {'ch-a': 'ch-a'},
);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
final badge = container.read(unreadBadgeProvider);
expect(badge.highPriorityCount, 0);
expect(badge.generalUnreadCount, 1);
},
);
test('thread marker clears only replies in that thread context', () async {
const channelId = 'ch-a';
final container = buildContainer(
channels: [makeChannel(id: channelId, lastMessageAtSeconds: t30)],
readContexts: {'thread:root-1': t30},
observedEventsByChannel: {
channelId: [
_observed(
id: 'reply-1',
createdAt: t20,
rootId: 'root-1',
isThreadedReply: true,
),
_observed(
id: 'reply-2',
createdAt: t20,
rootId: 'root-2',
isThreadedReply: true,
),
],
},
);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
final badge = container.read(unreadBadgeProvider);
expect(badge.highPriorityCount, 0);
expect(badge.generalUnreadCount, 1);
});
test('message marker clears only that observed message', () async {
const channelId = 'ch-a';
final container = buildContainer(
channels: [makeChannel(id: channelId, lastMessageAtSeconds: t30)],
readContexts: {'msg:reply-1': t30},
observedEventsByChannel: {
channelId: [
_observed(
id: 'reply-1',
createdAt: t20,
rootId: 'root-1',
isThreadedReply: true,
),
_observed(
id: 'reply-2',
createdAt: t20,
rootId: 'root-1',
isThreadedReply: true,
),
],
},
);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
final badge = container.read(unreadBadgeProvider);
expect(badge.highPriorityCount, 0);
expect(badge.generalUnreadCount, 1);
});
test('channelsProvider in loading state returns (0, 0)', () {
// The provider returns const UnreadBadgeState() while channels are loading.
// We intentionally do NOT await the future here — the channels notifier
// never resolves (Completer never completes) so the state stays AsyncLoading.
final container = ProviderContainer(
overrides: [
channelsProvider.overrideWith(() => _LoadingChannelsNotifier()),
readStateProvider.overrideWith(
() => _ReadStateNotifier(
const ReadStateState(
isReady: true,
pubkey: 'me',
contexts: {},
version: 1,
),
),
),
],
);
addTearDown(container.dispose);
final badge = container.read(unreadBadgeProvider);
expect(badge.highPriorityCount, 0);
expect(badge.generalUnreadCount, 0);
});
test(
'high-priority event older than read marker falls through to general bucket',
() async {
// @mention arrived at t10, user read at t20 (after the mention),
// then a new general message arrived at t30. Channel is unread but
// the mention is already read → falls through to general.
const channelId = 'ch-a';
final container = buildContainer(
channels: [makeChannel(id: channelId, lastMessageAtSeconds: t30)],
readContexts: {channelId: t20},
observedEventsByChannel: {
channelId: [
_observed(id: 'mention', createdAt: t10, highPriority: true),
_observed(id: 'general', createdAt: t30),
],
},
);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
final badge = container.read(unreadBadgeProvider);
expect(badge.highPriorityCount, 0);
expect(badge.generalUnreadCount, 1);
},
);
}
ObservedUnreadEvent _observed({
required String id,
required int createdAt,
String? rootId,
bool highPriority = false,
bool isThreadedReply = false,
String channelType = 'stream',
}) => makeObservedUnreadEvent(
id: id,
createdAt: createdAt,
rootId: rootId,
highPriority: highPriority,
channelType: channelType,
isThreadedReply: isThreadedReply,
);
Map<String, List<ObservedUnreadEvent>> _defaultObservedEvents(
List<Channel> channels,
Map<String, int> highPriorityMap,
) {
return {
for (final channel in channels)
if (channel.lastMessageAt != null)
channel.id: [
_observed(
id: '${channel.id}-latest',
createdAt: channel.lastMessageAt!.millisecondsSinceEpoch ~/ 1000,
highPriority:
channel.isDm || highPriorityMap.containsKey(channel.id),
channelType: channel.channelType,
),
],
};
}
/// A [ChannelsNotifier] that immediately resolves to a canned [channels] list
/// and exposes pre-seeded observed unread events.
///
/// Extends [ChannelsNotifier] so [ref.read(channelsProvider.notifier)] returns
/// an instance whose observed-event getters work correctly.
class _StubbedChannelsNotifier extends ChannelsNotifier {
_StubbedChannelsNotifier({
required List<Channel> channels,
Map<String, List<ObservedUnreadEvent>> observedEventsByChannel = const {},
}) : _channels = channels,
_observedEventsByChannel =
Map<String, Map<String, ObservedUnreadEvent>>.unmodifiable({
for (final entry in observedEventsByChannel.entries)
entry.key: Map<String, ObservedUnreadEvent>.unmodifiable({
for (final event in entry.value) event.id: event,
}),
});
final List<Channel> _channels;
final Map<String, Map<String, ObservedUnreadEvent>> _observedEventsByChannel;
@override
Future<List<Channel>> build() async => _channels;
@override
Map<String, int> get latestObservedByChannel => {
for (final entry in _observedEventsByChannel.entries)
if (entry.value.isNotEmpty)
entry.key: entry.value.values
.map((event) => event.createdAt)
.reduce((left, right) => left > right ? left : right),
};
@override
Map<String, Map<String, ObservedUnreadEvent>>
get observedUnreadEventsByChannel => _observedEventsByChannel;
}
/// A [ChannelsNotifier] that stays in the loading state indefinitely.
class _LoadingChannelsNotifier extends ChannelsNotifier {
@override
Future<List<Channel>> build() => Completer<List<Channel>>().future;
@override
Map<String, int> get latestObservedByChannel => const {};
@override
Map<String, Map<String, ObservedUnreadEvent>>
get observedUnreadEventsByChannel => const {};
}
/// A [ReadStateNotifier] that returns a fixed [ReadStateState].
class _ReadStateNotifier extends ReadStateNotifier {
_ReadStateNotifier(this._fixedState);
final ReadStateState _fixedState;
@override
ReadStateState build() => _fixedState;
}