feat(block2): offers logic layer — OffersCubit + OfferMapper (no UI yet)
The testable core of the offers UI slice, transport-agnostic: - OfferMapper: pure app-sharing-intent (OfferStatus) -> network Offer/OfferType mapping; keeps price only for sales, refuses to publish private lots. - OffersCubit: discover(geohash) streams results; publish() reports the verdict; depends on the OfferTransport interface (fake in tests) and degrades gracefully offline (null transport = social layer unavailable, never throws). 8 tests green (fake in-memory transport, no relay). Screen/routing/i18n come next — they need two product decisions (coarse-area capture, relay list).
This commit is contained in:
parent
70905a0578
commit
7fd87e6e62
3 changed files with 319 additions and 0 deletions
148
apps/app_seeds/test/state/offers_cubit_test.dart
Normal file
148
apps/app_seeds/test/state/offers_cubit_test.dart
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tane/db/enums.dart';
|
||||
import 'package:tane/services/offer_mapper.dart';
|
||||
import 'package:tane/state/offers_cubit.dart';
|
||||
|
||||
/// In-memory [OfferTransport] for tests: addressable by id, discover matches by
|
||||
/// geohash prefix. No relay, no network.
|
||||
class FakeOfferTransport implements OfferTransport {
|
||||
final List<Offer> _offers = [];
|
||||
|
||||
@override
|
||||
Future<PublishResult> publish(Offer offer) async {
|
||||
_offers.removeWhere((o) => o.id == offer.id);
|
||||
_offers.add(offer);
|
||||
return PublishResult(accepted: true, transportRef: offer.id);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Offer> discover(DiscoveryQuery query) {
|
||||
final controller = StreamController<Offer>();
|
||||
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<void> retract(String offerId) async =>
|
||||
_offers.removeWhere((o) => o.id == offerId);
|
||||
|
||||
@override
|
||||
Future<void> 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('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();
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue