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:tane/services/offer_outbox.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) { 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 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) { 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 retract(String offerId) async => _offers.removeWhere((o) => o.id == offerId); @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('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 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(); }); }); }