tane/apps/app_seeds/test/services/social_connection_test.dart
vjrj 8a7dc8d3dc fix(market): recover by itself when the shared connection comes up
Fresh installs could sit on 'can't reach the servers' forever: the offers
cubit captured the transport once at build time, the shared connection only
retried on a connectivity CHANGE, and a silently-filtered relay could stall
the pool for minutes.

- SocialConnection: retry with backoff after a failed attempt while started
  and not knowingly offline (injectable schedule for tests)
- OffersCubit: follow connection.sessions, re-attach the transport and re-run
  the last discovery on (re)connect; announce drops via connectionEpoch
- market _init: record the wanted area on the cubit even while offline
- NostrOfferTransport.discoverPage: sort a copy (channel lists may be
  unmodifiable)
2026-07-22 16:08:34 +02:00

184 lines
6.2 KiB
Dart

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<Event> subscribe(Filter filter) => const Stream.empty();
@override
Future<List<Event>> reqOnce(Filter filter) async => const [];
@override
Future<void> 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<FakeChannel> opened,
Stream<bool>? online,
bool Function()? fail,
List<Duration>? 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 = <FakeChannel>[];
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 = <FakeChannel>[];
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 = <FakeChannel>[];
final online = StreamController<bool>.broadcast();
final conn = make(opened: opened, online: online.stream);
final emitted = <SocialSession?>[];
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<void>.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<void>.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 = <FakeChannel>[];
var down = true;
final conn = make(
opened: opened,
online: const Stream.empty(), // connectivity never speaks
fail: () => down,
retrySchedule: const [Duration(milliseconds: 5)],
);
final emitted = <SocialSession?>[];
conn.sessions.listen(emitted.add);
conn.start();
await Future<void>.delayed(Duration.zero);
expect(conn.current, isNull); // first attempt failed
down = false; // network path recovers
await Future<void>.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 = <FakeChannel>[];
final conn = make(
opened: opened,
fail: () => true,
retrySchedule: const [Duration(milliseconds: 5)],
);
expect(await conn.session(), isNull);
await Future<void>.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 = <FakeChannel>[];
var down = true;
final conn = make(
opened: opened,
online: const Stream.empty(),
fail: () => down,
retrySchedule: const [Duration(milliseconds: 20)],
);
conn.start();
await Future<void>.delayed(Duration.zero); // first attempt fails
await conn.dispose(); // cancels the scheduled retry
down = false;
await Future<void>.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 = <FakeChannel>[];
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();
});
}