import 'dart:async'; import 'package:commons_core/commons_core.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:nostr/nostr.dart'; import 'package:tane/services/social_connection.dart'; import 'package:tane/services/social_service.dart'; import 'package:tane/services/social_settings.dart'; import '../support/test_support.dart'; /// A no-op [NostrChannel] that only tracks whether it was closed — enough to /// build a [SocialSession] and assert the connection's lifecycle. class FakeChannel implements NostrChannel { bool closed = false; @override String get privateKeyHex => '00' * 32; @override String get publicKeyHex => 'ab' * 32; @override Future<({bool accepted, String message})> publish(Event event) async => (accepted: true, message: ''); @override Stream subscribe(Filter filter) => const Stream.empty(); @override Future> reqOnce(Filter filter) async => const []; @override Future close() async => closed = true; } void main() { const seedHex = '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f'; late SocialService social; late SocialSettings settings; // relayUrls() falls back to defaults (non-empty) setUp(() async { social = await SocialService.fromRootSeedHex(seedHex); settings = SocialSettings(InMemorySecretStore()); }); SocialConnection make({ required List opened, Stream? online, bool Function()? fail, List? retrySchedule, }) => SocialConnection( social: social, settings: settings, online: online, retrySchedule: retrySchedule, open: (_) async { if (fail?.call() ?? false) throw StateError('unreachable'); final ch = FakeChannel(); opened.add(ch); return SocialSession(ch); }, ); test('connects once and reuses the shared session', () async { final opened = []; final conn = make(opened: opened); final a = await conn.session(); final b = await conn.session(); expect(a, isNotNull); expect(identical(a, b), isTrue); // same shared instance expect(opened, hasLength(1)); // only one connection opened await conn.dispose(); }); test('concurrent callers share a single connect', () async { final opened = []; final conn = make(opened: opened); final results = await Future.wait([conn.session(), conn.session()]); expect(identical(results[0], results[1]), isTrue); expect(opened, hasLength(1)); await conn.dispose(); }); test('drops when offline and reconnects when back online', () async { final opened = []; final online = StreamController.broadcast(); final conn = make(opened: opened, online: online.stream); final emitted = []; conn.sessions.listen(emitted.add); conn.start(); // watch connectivity + initial connect final first = await conn.session(); expect(first, isNotNull); expect(opened, hasLength(1)); online.add(false); // network lost await Future.delayed(Duration.zero); expect(conn.current, isNull); expect(opened.first.closed, isTrue); // old session closed expect(emitted.last, isNull); // announced the drop online.add(true); // network back await Future.delayed(Duration.zero); expect(conn.current, isNotNull); expect(opened, hasLength(2)); // reconnected expect(identical(emitted.last, conn.current), isTrue); await conn.dispose(); await online.close(); }); test( 'started connection retries by itself after a failed first attempt, ' 'without any connectivity event', () async { // The fresh-install case: device online the whole time, but the very first // connect fails (cold DNS/TLS). The connectivity stream never fires, so // only the backoff retry can bring the market up without a manual Retry. final opened = []; var down = true; final conn = make( opened: opened, online: const Stream.empty(), // connectivity never speaks fail: () => down, retrySchedule: const [Duration(milliseconds: 5)], ); final emitted = []; conn.sessions.listen(emitted.add); conn.start(); await Future.delayed(Duration.zero); expect(conn.current, isNull); // first attempt failed down = false; // network path recovers await Future.delayed(const Duration(milliseconds: 100)); expect(conn.current, isNotNull, reason: 'backoff retry reconnected'); expect(opened, hasLength(1)); expect(emitted.last, isNotNull, reason: 'recovery announced on sessions'); await conn.dispose(); }); test('a plain session() failure schedules no retry when never started', () async { // Widget tests build cubits against an un-started connection; a failed // one-shot session() must not leave a pending retry timer behind. final opened = []; final conn = make( opened: opened, fail: () => true, retrySchedule: const [Duration(milliseconds: 5)], ); expect(await conn.session(), isNull); await Future.delayed(const Duration(milliseconds: 50)); expect(opened, isEmpty); // no background retry fired await conn.dispose(); }); test('dispose cancels a pending backoff retry', () async { final opened = []; var down = true; final conn = make( opened: opened, online: const Stream.empty(), fail: () => down, retrySchedule: const [Duration(milliseconds: 20)], ); conn.start(); await Future.delayed(Duration.zero); // first attempt fails await conn.dispose(); // cancels the scheduled retry down = false; await Future.delayed(const Duration(milliseconds: 100)); expect(opened, isEmpty, reason: 'no reconnect after dispose'); }); test('returns null when the relay is unreachable, and retries later', () async { final opened = []; var down = true; final conn = make(opened: opened, fail: () => down); expect(await conn.session(), isNull); // unreachable now down = false; expect(await conn.session(), isNotNull); // succeeds on retry await conn.dispose(); }); test('start is idempotent: a second call adds no second connect', () async { // Joining sharing calls start() while bootstrap may already have, so a // repeat must not stack another connectivity subscription or dial again. final opened = []; final online = StreamController.broadcast(); final conn = make(opened: opened, online: online.stream); conn.start(); await conn.session(); conn.start(); await Future.delayed(Duration.zero); expect(opened, hasLength(1)); // One subscription, so one drop — not two competing reactions. online.add(false); await Future.delayed(Duration.zero); expect(conn.current, isNull); expect(opened.first.closed, isTrue); await conn.dispose(); await online.close(); }); test('stop goes offline now, not at the next launch', () async { // Turning sharing off used to leave the live session open until restart. final opened = []; final online = StreamController.broadcast(); final conn = make(opened: opened, online: online.stream); final emitted = []; conn.sessions.listen(emitted.add); conn.start(); expect(await conn.session(), isNotNull); await conn.stop(); await Future.delayed(Duration.zero); // let the drop be announced expect(conn.current, isNull); expect(opened.single.closed, isTrue); expect(emitted.last, isNull); // And it stays off: a connectivity event must not resurrect it. online.add(true); await Future.delayed(Duration.zero); expect(conn.current, isNull); expect(opened, hasLength(1)); await conn.dispose(); await online.close(); }); test('stop leaves the connection usable: start brings it back', () async { final opened = []; final online = StreamController.broadcast(); final conn = make(opened: opened, online: online.stream); conn.start(); await conn.session(); await conn.stop(); conn.start(); await Future.delayed(Duration.zero); expect(conn.current, isNotNull); expect(opened, hasLength(2)); await conn.dispose(); await online.close(); }); }