tane/apps/app_seeds/test/services/inbox_service_test.dart
vjrj bb4ee2fd89 refactor(social): one shared relay connection per identity
Every feature (offers, messaging, trust, profile, the inbox listener) used
to open its OWN RelayPool via social.openSession — several sockets to the
same relays, more battery, and reconnection logic living only in the inbox
listener.

Add SocialConnection: ONE shared session per identity, lazily connected and
reused by all. It watches connectivity — dropping the session when the
network goes and reconnecting when it returns, announcing each change on a
sessions stream so the inbox listener re-subscribes automatically. Callers
get the session via connection.session() and never close it (the connection
owns its lifecycle); recreated/disposed on an identity switch.

- InboxService now consumes the shared connection (subscribes on its
  sessions stream) instead of owning its own socket + connectivity code.
- createOffersCubit + the chat/market/profile screens use the shared
  connection; dead createMessagesCubit/createTrustCubit factories removed.
- DI registers SocialConnection per identity; Bootstrap starts it (after
  the inbox subscribes); switchSocialAccount disposes + recreates it.

Tests: SocialConnection lifecycle (reuse, concurrent connect, offline drop +
reconnect, unreachable retry) with a fake opener + online stream; inbox test
updated. nostr added as a dev_dependency for the channel fake.
2026-07-10 21:50:34 +02:00

129 lines
4.4 KiB
Dart

import 'package:commons_core/commons_core.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/services/inbox_service.dart';
import 'package:tane/services/message_store.dart';
import 'package:tane/services/notification_service.dart';
import 'package:tane/services/social_connection.dart';
import 'package:tane/services/social_service.dart';
import 'package:tane/services/social_settings.dart';
import 'package:tane/services/unread_service.dart';
import '../support/test_support.dart';
/// Records notification calls instead of touching the OS plugin.
class _RecordingNotifications extends NotificationService {
_RecordingNotifications() : super(supported: false);
final titles = <String>[];
final peers = <String>[];
@override
Future<void> showMessage({
required String peerPubkey,
required String title,
}) async {
peers.add(peerPubkey);
titles.add(title);
}
}
/// The app-wide inbox listener persists incoming messages, updates unread and
/// notifies — even when the specific chat isn't open. Driven through the
/// [InboxService.ingest] seam so no relay/network is involved.
void main() {
const seedHex =
'000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f';
// A connection that never actually opens — ingest doesn't use it.
Future<SocialConnection> offlineConnection() async => SocialConnection(
social: await SocialService.fromRootSeedHex(seedHex),
settings: SocialSettings(InMemorySecretStore()),
open: (_) async => throw StateError('unused in ingest tests'),
online: const Stream.empty(),
);
late MessageStore store;
late InboxService inbox;
setUp(() async {
store = MessageStore(InMemorySecretStore());
inbox = InboxService(
connection: await offlineConnection(),
selfPubkey: 'me',
store: store,
);
});
tearDown(() => inbox.stop());
PrivateMessage msg(String from, String text, int atMs) => PrivateMessage(
fromPubkey: from,
text: text,
at: DateTime.fromMillisecondsSinceEpoch(atMs));
test('an incoming message is persisted into its conversation', () async {
await inbox.ingest(msg('alice', 'got seeds?', 1000));
final convos = await store.conversations();
expect(convos.single.peerPubkey, 'alice');
expect(convos.single.lastText, 'got seeds?');
});
test('a new message announces a change; a duplicate stays silent', () async {
final changes = <void>[];
final sub = inbox.changes.listen(changes.add);
await inbox.ingest(msg('alice', 'hola', 1000));
await inbox.ingest(msg('alice', 'hola', 1000)); // relay re-delivery
await Future<void>.delayed(Duration.zero); // let the broadcast flush
expect(changes, hasLength(1)); // only the first, new one fired
expect(await store.history('alice'), hasLength(1));
await sub.cancel();
});
group('unread + notification hooks', () {
late UnreadService unread;
late _RecordingNotifications notifications;
const myPubkey = 'me';
setUp(() async {
unread = UnreadService(store, InMemorySecretStore());
notifications = _RecordingNotifications();
inbox = InboxService(
connection: await offlineConnection(),
selfPubkey: myPubkey,
store: store,
unread: unread,
notifications: notifications,
);
});
tearDown(() => unread.dispose());
test('a new peer message updates unread and notifies', () async {
await inbox.ingest(msg('alice', 'hola', 1000));
expect(await unread.unreadCount('alice'), 1);
expect(notifications.peers, ['alice']);
});
test('a duplicate re-delivery neither counts nor notifies', () async {
await inbox.ingest(msg('alice', 'hola', 1000));
await inbox.ingest(msg('alice', 'hola', 1000));
expect(await unread.unreadCount('alice'), 1);
expect(notifications.peers, hasLength(1));
});
test('a message authored by me is never notified', () async {
// NIP-17 doesn't loop your own gift wrap back; the guard is defensive.
await inbox.ingest(msg(myPubkey, 'echo', 1000));
expect(notifications.peers, isEmpty);
});
test('the open chat is not notified and stays read', () async {
unread.activePeer = 'alice';
await inbox.ingest(msg('alice', 'while open', 1000));
expect(await unread.unreadCount('alice'), 0);
expect(notifications.peers, isEmpty);
});
});
}