import 'dart:async'; 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/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 {} } 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'); }); }); 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('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', summary: 'Tomate', offerStatus: OfferStatus.shared, category: 'Solanaceae', ), ShareableLot( lotId: 'lot-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 is a no-op offline or with no area', () async { final offline = OffersCubit(null); expect( await offline.publishLots( const [ ShareableLot( lotId: 'x', 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', 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', summary: 'Tomate', offerStatus: OfferStatus.shared), ShareableLot( lotId: 'lot-2', summary: 'Judía', offerStatus: OfferStatus.exchange), ShareableLot( lotId: 'lot-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(); }); }); }