import 'dart:async'; import 'dart:typed_data'; import 'package:commons_core/commons_core.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tane/data/variety_repository.dart'; import 'package:tane/db/enums.dart'; import 'package:tane/services/discovery_area.dart'; import 'package:tane/services/offer_mapper.dart'; import 'package:nostr/nostr.dart'; import 'package:tane/services/offer_outbox.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/state/offers_cubit.dart'; import '../support/test_support.dart'; /// In-memory [OfferTransport] for tests: addressable by id, discover matches by /// geohash prefix. No relay, no network. class FakeOfferTransport implements OfferTransport { final List _offers = []; @override Future publish(Offer offer) async { _offers.removeWhere((o) => o.id == offer.id); _offers.add(offer); return PublishResult(accepted: true, transportRef: offer.id); } @override Stream discover(DiscoveryQuery query, {int? since}) { final controller = StreamController(); for (final o in _offers) { if (o.approxGeohash.startsWith(query.geohashPrefix)) controller.add(o); } return controller.stream; // left open (live), like a real subscription } @override Future discoverPage(DiscoveryQuery query) async { final matches = [ for (final o in _offers) if (o.approxGeohash.startsWith(query.geohashPrefix)) o, ]; return OfferPage(offers: matches); // short page → nothing older to load } @override Future retract(String offerId) async => _offers.removeWhere((o) => o.id == offerId); @override Future close() async {} } /// Emits every published offer TWICE on discover — the way a relay legitimately /// resends an addressable event (a stored copy plus a live echo). Used to prove /// the cubit dedups instead of doubling the listing. class DuplicatingOfferTransport implements OfferTransport { final List _offers = []; @override Future publish(Offer offer) async { _offers.add(offer); return PublishResult(accepted: true, transportRef: offer.id); } @override Stream discover(DiscoveryQuery query, {int? since}) { final controller = StreamController(); for (final o in _offers) { if (o.approxGeohash.startsWith(query.geohashPrefix)) { controller.add(o); controller.add(o); // the duplicate } } return controller.stream; } @override Future discoverPage(DiscoveryQuery query) async { // The stored copy (deduped at relay level); the live echo (the duplicate) // still arrives via [discover], so the cubit's dedup is what's under test. final matches = [ for (final o in _offers) if (o.approxGeohash.startsWith(query.geohashPrefix)) o, ]; return OfferPage(offers: matches); } @override Future retract(String offerId) async => _offers.removeWhere((o) => o.id == offerId); @override Future close() async {} } /// A transport that serves offers in `until`-cursored pages (like a relay's /// stored events), so the cubit's pagination and memory cap can be exercised. /// Each seeded offer gets a descending createdAt; [discover] (live) is empty. class PaginatingOfferTransport implements OfferTransport { PaginatingOfferTransport(int count, {this.geohash = 'sp3e9'}) { // Newest (highest createdAt) first: offer 0 is newest. for (var i = 0; i < count; i++) { _byCreatedAt.add(( createdAt: 1000000 - i, offer: Offer( id: 'o$i', authorPubkeyHex: 'ab' * 32, summary: 'offer $i', type: OfferType.gift, approxGeohash: geohash, ), )); } } final String geohash; final List<({int createdAt, Offer offer})> _byCreatedAt = []; @override Stream discover(DiscoveryQuery query, {int? since}) => const Stream.empty(); @override Future discoverPage(DiscoveryQuery query) async { final matched = _byCreatedAt .where((e) => e.offer.approxGeohash.startsWith(query.geohashPrefix)) .where((e) => query.until == null || e.createdAt <= query.until!) .toList() ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); final page = matched.take(query.limit).toList(); final nextCursor = page.length >= query.limit && page.isNotEmpty ? page.last.createdAt - 1 : null; return OfferPage( offers: [for (final e in page) e.offer], nextCursor: nextCursor, ); } @override Future publish(Offer offer) async => PublishResult(accepted: true, transportRef: offer.id); @override Future retract(String offerId) async {} @override Future close() async {} } void main() { group('OfferMapper', () { test('maps local sharing intent to the network offer type', () { expect(OfferMapper.offerTypeFor(OfferStatus.shared), OfferType.gift); expect(OfferMapper.offerTypeFor(OfferStatus.exchange), OfferType.exchange); expect(OfferMapper.offerTypeFor(OfferStatus.sell), OfferType.sale); }); test('never publishes a private lot', () { expect(() => OfferMapper.offerTypeFor(OfferStatus.private), throwsArgumentError); }); test('builds an offer, keeping price only for a sale', () { final gift = OfferMapper.fromSharedLot( lotId: 'lot-1', authorPubkeyHex: 'ab' * 32, summary: 'Tomate rosa', sharing: OfferStatus.shared, areaGeohash: 'sp3e9', priceAmount: 5, // should be dropped for a gift ); expect(gift.type, OfferType.gift); expect(gift.id, 'lot-1'); expect(gift.priceAmount, isNull); final sale = OfferMapper.fromSharedLot( lotId: 'lot-2', authorPubkeyHex: 'ab' * 32, summary: 'Judía', sharing: OfferStatus.sell, areaGeohash: 'sp3e9', priceAmount: 3, priceCurrency: 'G1', ); expect(sale.type, OfferType.sale); expect(sale.priceAmount, 3); expect(sale.priceCurrency, 'G1'); }); test('carries the organic (eco) flag through to the offer', () { final eco = OfferMapper.fromSharedLot( lotId: 'lot-1', authorPubkeyHex: 'ab' * 32, summary: 'Lechuga', sharing: OfferStatus.shared, areaGeohash: 'sp3e9', isOrganic: true, ); expect(eco.isOrganic, isTrue); final plain = OfferMapper.fromSharedLot( lotId: 'lot-2', authorPubkeyHex: 'ab' * 32, summary: 'Lechuga', sharing: OfferStatus.shared, areaGeohash: 'sp3e9', ); expect(plain.isOrganic, isFalse); }); }); group('OffersCubit', () { test('discovers published offers by geohash prefix', () async { final transport = FakeOfferTransport(); await transport.publish(OfferMapper.fromSharedLot( lotId: 'lot-1', authorPubkeyHex: 'ab' * 32, summary: 'Tomate rosa de Barbastro', sharing: OfferStatus.shared, areaGeohash: 'sp3e9', )); final cubit = OffersCubit(transport); await cubit.discover('sp3'); await pumpEventQueue(); expect(cubit.state.hasSearched, isTrue); expect(cubit.state.searching, isFalse); expect(cubit.state.offers, hasLength(1)); expect(cubit.state.offers.single.summary, 'Tomate rosa de Barbastro'); await cubit.close(); }); test('search narrows visible offers by summary, keeping the full list', () async { final transport = FakeOfferTransport(); for (final s in ['Tomate rosa', 'Judía verde', 'Tomate rojo']) { await transport.publish(OfferMapper.fromSharedLot( lotId: s, authorPubkeyHex: 'ab' * 32, summary: s, sharing: OfferStatus.shared, areaGeohash: 'sp3e9', )); } final cubit = OffersCubit(transport); await cubit.discover('sp3'); await pumpEventQueue(); expect(cubit.state.offers, hasLength(3)); cubit.search('tomate'); // case-insensitive expect(cubit.state.visibleOffers.map((o) => o.summary), containsAll(['Tomate rosa', 'Tomate rojo'])); expect(cubit.state.visibleOffers, hasLength(2)); expect(cubit.state.offers, hasLength(3)); // underlying list untouched cubit.search(''); // clearing shows everything again expect(cubit.state.visibleOffers, hasLength(3)); await cubit.close(); }); test('blocked authors disappear from the visible list and can resurface', () async { final transport = FakeOfferTransport(); await transport.publish(OfferMapper.fromSharedLot( lotId: 'lot-good', authorPubkeyHex: 'ab' * 32, summary: 'Tomate rosa', sharing: OfferStatus.shared, areaGeohash: 'sp3e9', )); await transport.publish(OfferMapper.fromSharedLot( lotId: 'lot-spam', authorPubkeyHex: 'cd' * 32, summary: 'Spam total', sharing: OfferStatus.shared, areaGeohash: 'sp3e9', )); final cubit = OffersCubit(transport); await cubit.discover('sp3'); await pumpEventQueue(); expect(cubit.state.visibleOffers, hasLength(2)); cubit.setBlockedAuthors({'cd' * 32}); expect(cubit.state.visibleOffers.map((o) => o.summary), ['Tomate rosa']); expect(cubit.state.offers, hasLength(2)); // discoveries kept underneath // The blocklist survives a refresh (re-discovery resets results only). await cubit.discover('sp3'); await pumpEventQueue(); expect(cubit.state.visibleOffers.map((o) => o.summary), ['Tomate rosa']); cubit.setBlockedAuthors(const {}); // unblock resurfaces expect(cubit.state.visibleOffers, hasLength(2)); await cubit.close(); }); test('a hidden (reported) offer disappears without touching its author', () async { final transport = FakeOfferTransport(); for (final id in ['lot-1', 'lot-2']) { await transport.publish(OfferMapper.fromSharedLot( lotId: id, authorPubkeyHex: 'ab' * 32, summary: 'Oferta $id', sharing: OfferStatus.shared, areaGeohash: 'sp3e9', )); } final cubit = OffersCubit(transport); await cubit.discover('sp3'); await pumpEventQueue(); expect(cubit.state.visibleOffers, hasLength(2)); cubit.setHiddenOffers({'${'ab' * 32}:lot-1'}); expect(cubit.state.visibleOffers.map((o) => o.id), ['lot-2']); await cubit.close(); }); test('the search filter survives a refresh (re-discovery)', () async { final transport = FakeOfferTransport(); await transport.publish(OfferMapper.fromSharedLot( lotId: 'lot-1', authorPubkeyHex: 'ab' * 32, summary: 'Pimiento', sharing: OfferStatus.shared, areaGeohash: 'sp3e9', )); final cubit = OffersCubit(transport); await cubit.discover('sp3'); await pumpEventQueue(); cubit.search('pim'); expect(cubit.state.query, 'pim'); await cubit.discover('sp3'); // pull-to-refresh await pumpEventQueue(); expect(cubit.state.query, 'pim'); // kept across the refresh expect(cubit.state.visibleOffers, hasLength(1)); await cubit.close(); }); test('a neighbour in an adjacent cell is found via the coarsened prefix', () async { // Regression: A publishes at 'sp3e9', B lives one ±2.4 km cell over at // 'sp3e8'. Searching B's full 5-char area misses it; searching the // precision-4 prefix ('sp3e') finds it, because publish emits the ladder. final transport = FakeOfferTransport(); await transport.publish(OfferMapper.fromSharedLot( lotId: 'lot-1', authorPubkeyHex: 'ab' * 32, summary: 'Tomate rosa', sharing: OfferStatus.shared, areaGeohash: 'sp3e9', )); final cubit = OffersCubit(transport); await cubit.discover(searchPrefix('sp3e8', 4)); // 'sp3e' await pumpEventQueue(); expect(cubit.state.offers, hasLength(1)); await cubit.discover('sp3e8'); // full precision → the old, broken behaviour await pumpEventQueue(); expect(cubit.state.offers, isEmpty); await cubit.close(); }); test('a different area finds nothing', () async { final transport = FakeOfferTransport(); await transport.publish(OfferMapper.fromSharedLot( lotId: 'lot-1', authorPubkeyHex: 'ab' * 32, summary: 'Pimiento', sharing: OfferStatus.shared, areaGeohash: 'sp3e9', )); final cubit = OffersCubit(transport); await cubit.discover('u09'); await pumpEventQueue(); expect(cubit.state.offers, isEmpty); await cubit.close(); }); test('publish reports the transport verdict', () async { final cubit = OffersCubit(FakeOfferTransport()); final result = await cubit.publish(OfferMapper.fromSharedLot( lotId: 'lot-9', authorPubkeyHex: 'ab' * 32, summary: 'Calabaza', sharing: OfferStatus.exchange, areaGeohash: 'sp3e9', exchangeTerms: 'a cambio de legumbre', )); expect(result.accepted, isTrue); expect(cubit.state.publishing, isFalse); await cubit.close(); }); test('publishLots publishes each shared lot and counts acceptances', () async { final transport = FakeOfferTransport(); final cubit = OffersCubit(transport); final count = await cubit.publishLots( const [ ShareableLot( lotId: 'lot-1', varietyId: 'var-1', summary: 'Tomate', offerStatus: OfferStatus.shared, category: 'Solanaceae', ), ShareableLot( lotId: 'lot-2', varietyId: 'var-2', summary: 'Judía', offerStatus: OfferStatus.exchange, ), ], authorPubkeyHex: 'ab' * 32, areaGeohash: 'sp3e9', ); expect(count, 2); expect(cubit.state.publishing, isFalse); await cubit.discover('sp3'); await pumpEventQueue(); expect(cubit.state.offers, hasLength(2)); await cubit.close(); }); test('publishLots carries the lot asking price on sale offers', () async { final transport = FakeOfferTransport(); final cubit = OffersCubit(transport); await cubit.publishLots( const [ ShareableLot( lotId: 'lot-sale', varietyId: 'var-1', summary: 'Tomate', offerStatus: OfferStatus.sell, priceAmount: 3.5, priceCurrency: 'Ğ1', ), // "To be agreed": sale without a figure publishes without a price. ShareableLot( lotId: 'lot-negotiable', varietyId: 'var-2', summary: 'Judía', offerStatus: OfferStatus.sell, ), ], authorPubkeyHex: 'ab' * 32, areaGeohash: 'sp3e9', ); await cubit.discover('sp3'); await pumpEventQueue(); final sale = cubit.state.offers.singleWhere((o) => o.id == 'lot-sale'); expect(sale.type, OfferType.sale); expect(sale.priceAmount, 3.5); expect(sale.priceCurrency, 'Ğ1'); final negotiable = cubit.state.offers.singleWhere((o) => o.id == 'lot-negotiable'); expect(negotiable.priceAmount, isNull); await cubit.close(); }); test('publishLots embeds an inline thumbnail data-URI on the offer', () async { final transport = FakeOfferTransport(); var thumbnailed = 0; final cubit = OffersCubit( transport, coverPhoto: (id) async => Uint8List.fromList([1, 2, 3]), thumbnail: (bytes) { thumbnailed++; return 'data:image/jpeg;base64,AAAA'; }, ); await cubit.publishLots( const [ ShareableLot( lotId: 'lot-1', varietyId: 'var-1', summary: 'Tomate', offerStatus: OfferStatus.shared, ), ], authorPubkeyHex: 'ab' * 32, areaGeohash: 'sp3e9', ); await cubit.discover('sp3'); await pumpEventQueue(); expect(thumbnailed, 1); expect(cubit.state.offers.single.imageUrl, 'data:image/jpeg;base64,AAAA'); await cubit.close(); }); test('publishLots still publishes (without image) when thumbnailing fails', () async { final transport = FakeOfferTransport(); final cubit = OffersCubit( transport, coverPhoto: (id) async => Uint8List.fromList([1, 2, 3]), thumbnail: (bytes) => throw Exception('boom'), ); final count = await cubit.publishLots( const [ ShareableLot( lotId: 'lot-1', varietyId: 'var-1', summary: 'Tomate', offerStatus: OfferStatus.shared, ), ], authorPubkeyHex: 'ab' * 32, areaGeohash: 'sp3e9', ); expect(count, 1); // the offer still went out await cubit.discover('sp3'); await pumpEventQueue(); expect(cubit.state.offers.single.imageUrl, isNull); await cubit.close(); }); test('publishLots without a thumbnailer publishes no image', () async { final transport = FakeOfferTransport(); final cubit = OffersCubit(transport); // no thumbnail, no coverPhoto await cubit.publishLots( const [ ShareableLot( lotId: 'lot-1', varietyId: 'var-1', summary: 'Tomate', offerStatus: OfferStatus.shared, ), ], authorPubkeyHex: 'ab' * 32, areaGeohash: 'sp3e9', ); await cubit.discover('sp3'); await pumpEventQueue(); expect(cubit.state.offers.single.imageUrl, isNull); await cubit.close(); }); test('publishLots is a no-op offline or with no area', () async { final offline = OffersCubit(null); expect( await offline.publishLots( const [ ShareableLot( lotId: 'x', varietyId: 'vx', summary: 'x', offerStatus: OfferStatus.shared, ), ], authorPubkeyHex: 'ab' * 32, areaGeohash: 'sp3e9', ), 0, ); await offline.close(); final noArea = OffersCubit(FakeOfferTransport()); expect( await noArea.publishLots( const [ ShareableLot( lotId: 'x', varietyId: 'vx', summary: 'x', offerStatus: OfferStatus.shared, ), ], authorPubkeyHex: 'ab' * 32, areaGeohash: '', ), 0, ); await noArea.close(); }); test('flushOutbox publishes queued lots, then clears them', () async { final outbox = OfferOutbox(InMemorySecretStore()); await outbox.enqueue(['lot-1', 'lot-2']); final cubit = OffersCubit(FakeOfferTransport()); final count = await flushOutbox( outbox: outbox, cubit: cubit, shareableLots: const [ ShareableLot( lotId: 'lot-1', varietyId: 'var-1', summary: 'Tomate', offerStatus: OfferStatus.shared), ShareableLot( lotId: 'lot-2', varietyId: 'var-2', summary: 'Judía', offerStatus: OfferStatus.exchange), ShareableLot( lotId: 'lot-3', varietyId: 'var-3', summary: 'Otro', offerStatus: OfferStatus.shared), ], authorPubkeyHex: 'ab' * 32, areaGeohash: 'sp3e9', ); expect(count, 2); expect(await outbox.pending(), isEmpty); await cubit.close(); }); test('flushOutbox offline keeps the queue for later', () async { final outbox = OfferOutbox(InMemorySecretStore()); await outbox.enqueue(['lot-1']); final cubit = OffersCubit(null); // offline final count = await flushOutbox( outbox: outbox, cubit: cubit, shareableLots: const [], authorPubkeyHex: 'ab' * 32, areaGeohash: 'sp3e9', ); expect(count, 0); expect(await outbox.pending(), {'lot-1'}); await cubit.close(); }); test('flushOutbox drops queued lots that no longer exist', () async { final outbox = OfferOutbox(InMemorySecretStore()); await outbox.enqueue(['gone']); final cubit = OffersCubit(FakeOfferTransport()); final count = await flushOutbox( outbox: outbox, cubit: cubit, shareableLots: const [], authorPubkeyHex: 'ab' * 32, areaGeohash: 'sp3e9', ); expect(count, 0); expect(await outbox.pending(), isEmpty); await cubit.close(); }); test('offline (no transport): degrades gracefully, never throws', () async { final cubit = OffersCubit(null); expect(cubit.isOnline, isFalse); await cubit.discover('sp3'); expect(cubit.state.error, 'offline'); final result = await cubit.publish(OfferMapper.fromSharedLot( lotId: 'x', authorPubkeyHex: 'ab' * 32, summary: 'x', sharing: OfferStatus.shared, areaGeohash: 'sp3e9', )); expect(result.accepted, isFalse); await cubit.close(); }); test('a resent offer is not duplicated in the list', () async { // Regression: publishing an inventory lot showed up twice because the // relay resends the addressable event; the cubit must dedup by author+id. final transport = DuplicatingOfferTransport(); await transport.publish(OfferMapper.fromSharedLot( lotId: 'lot-1', authorPubkeyHex: 'ab' * 32, summary: 'Tomate rosa', sharing: OfferStatus.shared, areaGeohash: 'sp3e9', )); final cubit = OffersCubit(transport); await cubit.discover('sp3'); await pumpEventQueue(); expect(cubit.state.offers, hasLength(1)); await cubit.close(); }); test('two authors reusing the same lot id are kept apart', () async { // A non-deduping transport, so both authors' same-id offers reach discover. final transport = DuplicatingOfferTransport(); for (final author in ['ab' * 32, 'cd' * 32]) { await transport.publish(Offer( id: 'lot-1', // same d-tag, different author authorPubkeyHex: author, summary: 'Tomate', type: OfferType.gift, approxGeohash: 'sp3e9', )); } final cubit = OffersCubit(transport); await cubit.discover('sp3'); await pumpEventQueue(); expect(cubit.state.offers, hasLength(2)); await cubit.close(); }); }); group('OffersCubit filters', () { Offer offer(String id, OfferType type, {String? category, bool organic = false}) => Offer( id: id, authorPubkeyHex: 'ab' * 32, summary: id, type: type, category: category, approxGeohash: 'sp3e9', isOrganic: organic, ); Future seeded(List offers) async { final transport = FakeOfferTransport(); for (final o in offers) { await transport.publish(o); } final cubit = OffersCubit(transport); await cubit.discover('sp3'); await pumpEventQueue(); return cubit; } test('type, category and eco chips narrow the list (ANDed with search)', () async { final cubit = await seeded([ offer('tomate-eco', OfferType.gift, category: 'Solanaceae', organic: true), offer('tomate-sale', OfferType.sale, category: 'Solanaceae'), offer('judia-eco', OfferType.gift, category: 'Fabaceae', organic: true), ]); cubit.toggleType(OfferType.gift); expect(cubit.state.visibleOffers.map((o) => o.id), containsAll(['tomate-eco', 'judia-eco'])); expect(cubit.state.visibleOffers, hasLength(2)); cubit.toggleCategory('Solanaceae'); expect(cubit.state.visibleOffers.single.id, 'tomate-eco'); cubit.toggleOrganicOnly(); expect(cubit.state.visibleOffers.single.id, 'tomate-eco'); cubit.clearFilters(); expect(cubit.state.visibleOffers, hasLength(3)); expect(cubit.state.hasActiveFilter, isFalse); await cubit.close(); }); test('exposes categories and hasOrganic from the discovered offers', () async { final cubit = await seeded([ offer('a', OfferType.gift, category: 'Solanaceae', organic: true), offer('b', OfferType.gift, category: 'Fabaceae'), offer('c', OfferType.gift), ]); expect(cubit.state.categories, ['Fabaceae', 'Solanaceae']); // sorted expect(cubit.state.hasOrganic, isTrue); await cubit.close(); }); test('chip filters survive a refresh (re-discovery)', () async { final cubit = await seeded([ offer('gift', OfferType.gift), offer('sale', OfferType.sale), ]); cubit.toggleType(OfferType.gift); await cubit.discover('sp3'); // pull-to-refresh await pumpEventQueue(); expect(cubit.state.typeFilter, {OfferType.gift}); expect(cubit.state.visibleOffers.single.id, 'gift'); await cubit.close(); }); }); group('OffersCubit pagination', () { test('discover loads the first page and exposes a next-page cursor', () async { final cubit = OffersCubit(PaginatingOfferTransport(250)); await cubit.discover('sp3'); await pumpEventQueue(); // One page (default limit 100) — not the whole 250 — is kept in memory. expect(cubit.state.offers, hasLength(100)); expect(cubit.state.canLoadMore, isTrue); expect(cubit.state.searching, isFalse); await cubit.close(); }); test('loadNextPage accumulates older offers until the source is exhausted', () async { final cubit = OffersCubit(PaginatingOfferTransport(250)); await cubit.discover('sp3'); await pumpEventQueue(); await cubit.loadNextPage(); expect(cubit.state.offers, hasLength(200)); expect(cubit.state.canLoadMore, isTrue); await cubit.loadNextPage(); // Only 250 exist: the third page is short, so there is nothing older. expect(cubit.state.offers, hasLength(250)); expect(cubit.state.canLoadMore, isFalse); // Paging past the end is a harmless no-op. await cubit.loadNextPage(); expect(cubit.state.offers, hasLength(250)); await cubit.close(); }); test('the in-memory list is capped and paging stops at the cap', () async { final cubit = OffersCubit(PaginatingOfferTransport(600)); await cubit.discover('sp3'); await pumpEventQueue(); // Keep paging until the cubit says there is no more. var guard = 0; while (cubit.state.canLoadMore && guard++ < 20) { await cubit.loadNextPage(); } expect(cubit.state.offers, hasLength(OffersCubit.maxOffersKept)); expect(cubit.state.canLoadMore, isFalse); await cubit.close(); }); test('offers are de-duplicated across pages by (author, id)', () async { // Two pages that overlap on the boundary id must not double it. final cubit = OffersCubit(PaginatingOfferTransport(150)); await cubit.discover('sp3'); await pumpEventQueue(); await cubit.loadNextPage(); final ids = cubit.state.offers.map((o) => o.id).toList(); expect(ids.toSet(), hasLength(ids.length)); // no duplicates expect(cubit.state.offers, hasLength(150)); await cubit.close(); }); }); group('connection recovery (fresh-install fix)', () { const seedHex = '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f'; late SocialService social; late SocialSettings settings; setUp(() async { social = await SocialService.fromRootSeedHex(seedHex); settings = SocialSettings(InMemorySecretStore()); }); SocialConnection makeConnection({ required bool Function() down, Stream? online, }) => SocialConnection( social: social, settings: settings, online: online ?? const Stream.empty(), retrySchedule: const [Duration(milliseconds: 5)], open: (_) async { if (down()) throw StateError('unreachable'); return SocialSession(_NoopChannel()); }, ); test('comes online by itself when the shared connection connects later', () async { var down = true; final conn = makeConnection(down: () => down); final cubit = await createOffersCubit(conn); expect(cubit.isOnline, isFalse); // built while unreachable final epochBefore = cubit.state.connectionEpoch; conn.start(); down = false; // network path recovers; backoff retry reconnects await Future.delayed(const Duration(milliseconds: 100)); expect(cubit.isOnline, isTrue, reason: 'no manual Retry needed'); expect(cubit.state.connectionEpoch, greaterThan(epochBefore), reason: 'a state was emitted so the UI re-reads isOnline'); await cubit.close(); await conn.dispose(); }); test('re-runs the last discovery when the connection recovers', () async { var down = true; final conn = makeConnection(down: () => down); final cubit = await createOffersCubit(conn); await cubit.discover('sp3e9'); // offline → error, but the wish is kept expect(cubit.state.error, 'offline'); conn.start(); down = false; await Future.delayed(const Duration(milliseconds: 100)); expect(cubit.isOnline, isTrue); expect(cubit.state.areaGeohash, 'sp3e9', reason: 'discovery re-ran'); expect(cubit.state.hasSearched, isTrue); expect(cubit.state.error, isNull, reason: 'offline error cleared'); await cubit.close(); await conn.dispose(); }); test('goes offline (and tells the UI) when the session drops', () async { final online = StreamController.broadcast(); final conn = makeConnection(down: () => false, online: online.stream); conn.start(); await pumpEventQueue(); final cubit = await createOffersCubit(conn); expect(cubit.isOnline, isTrue); final epochBefore = cubit.state.connectionEpoch; online.add(false); // network lost await pumpEventQueue(); expect(cubit.isOnline, isFalse); expect(cubit.state.connectionEpoch, greaterThan(epochBefore)); await cubit.close(); await conn.dispose(); await online.close(); }); }); } /// A no-op [NostrChannel]: just enough to build a [SocialSession] whose offer /// transport answers with empty results. class _NoopChannel implements NostrChannel { @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 {} }