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
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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user