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/database.dart'; import 'package:tane/db/enums.dart'; import 'package:tane/services/plantare_service.dart'; import '../support/test_support.dart'; /// A records-only transport: captures what a service sends so the test can hand /// the resulting move to the other party's [PlantareService.ingest]. No relay. class _CapturingTransport implements PlantareTransport { final proposed = []; final accepted = []; final declined = <({String toPubkey, String pledgeId, String reason})>[]; @override Future propose(PlantarePledge pledge) async => proposed.add(pledge); @override Future accept(PlantarePledge pledge) async => accepted.add(pledge); @override Future decline({ required String toPubkey, required String pledgeId, String reason = '', }) async => declined.add((toPubkey: toPubkey, pledgeId: pledgeId, reason: reason)); @override Stream incoming() => const Stream.empty(); @override Future close() async {} } void main() { Future idFor(int fill) => NostrKeyDerivation.deriveFromSeed( Uint8List(32)..fillRange(0, 32, fill), ); late AppDatabase dbA; late AppDatabase dbB; late VarietyRepository repoA; late VarietyRepository repoB; late NostrIdentity creditor; // gives seed, proposes late NostrIdentity debtor; // receives seed, owes a return late PlantareService svcA; // creditor's service late PlantareService svcB; // debtor's service late _CapturingTransport txA; late _CapturingTransport txB; setUp(() async { dbA = newTestDatabase(); dbB = newTestDatabase(); repoA = newTestRepository(dbA); repoB = newTestRepository(dbB); creditor = await idFor(1); debtor = await idFor(2); txA = _CapturingTransport(); txB = _CapturingTransport(); svcA = PlantareService( repo: repoA, selfPubkey: creditor.publicKeyHex, selfSecretKey: creditor.privateKeyHex, )..bindTransport(txA); svcB = PlantareService( repo: repoB, selfPubkey: debtor.publicKeyHex, selfSecretKey: debtor.privateKeyHex, )..bindTransport(txB); }); tearDown(() async { await svcA.stop(); await svcB.stop(); await dbA.close(); await dbB.close(); }); PlantareEnvelope envelope( PlantareMessageKind kind, String from, PlantarePledge pledge, ) => PlantareEnvelope(kind: kind, fromPubkey: from, pledge: pledge); test('full handshake: propose → accept, both end fully signed & accepted', () async { // Creditor proposes (their view: owedToMe). final pledgeId = await svcA.propose( direction: PlantareDirection.owedToMe, counterpartyKey: debtor.publicKeyHex, counterpartyName: 'Ana', label: 'Tomate rosa', owedDescription: 'un puñado', ); expect(txA.proposed, hasLength(1)); // Creditor's local row: proposed, creditor-signed only. final aRow = await repoA.plantareByPledgeId(pledgeId); expect(aRow!.remoteState, PlantareRemoteState.proposed); expect(aRow.creditorSignature, isNotNull); expect(aRow.debtorSignature, isNull); // Debtor receives the proposal. await svcB.ingest( envelope(PlantareMessageKind.proposed, creditor.publicKeyHex, txA.proposed.single), ); final bRow = await repoB.plantareByPledgeId(pledgeId); expect(bRow, isNotNull); expect(bRow!.direction, PlantareDirection.iReturn); // mirror view expect(bRow.remoteState, PlantareRemoteState.proposed); expect(svcB.pendingProposals, contains(pledgeId)); // Debtor accepts → counter-signs and sends back. await svcB.accept(pledgeId); expect(txB.accepted, hasLength(1)); final closed = txB.accepted.single; expect(await PlantareCrypto.verifyBoth(closed), isTrue); final bAfter = await repoB.plantareByPledgeId(pledgeId); expect(bAfter!.remoteState, PlantareRemoteState.accepted); expect(bAfter.debtorSignature, isNotNull); expect(svcB.pendingProposals, isEmpty); // Creditor receives the accept → their row closes too. await svcA.ingest( envelope(PlantareMessageKind.accepted, debtor.publicKeyHex, closed), ); final aAfter = await repoA.plantareByPledgeId(pledgeId); expect(aAfter!.remoteState, PlantareRemoteState.accepted); expect(aAfter.debtorSignature, isNotNull); expect(aAfter.creditorSignature, isNotNull); }); test('decline notifies the proposer and marks the row declined', () async { final pledgeId = await svcA.propose( direction: PlantareDirection.owedToMe, counterpartyKey: debtor.publicKeyHex, label: 'Maíz', ); await svcB.ingest( envelope(PlantareMessageKind.proposed, creditor.publicKeyHex, txA.proposed.single), ); await svcB.decline(pledgeId, reason: 'ya no me caben'); expect(txB.declined.single.toPubkey, creditor.publicKeyHex); expect(txB.declined.single.reason, 'ya no me caben'); final bRow = await repoB.plantareByPledgeId(pledgeId); expect(bRow!.remoteState, PlantareRemoteState.declined); // Creditor sees the decline. await svcA.ingest(PlantareEnvelope( kind: PlantareMessageKind.declined, fromPubkey: debtor.publicKeyHex, pledgeId: pledgeId, )); expect( (await repoA.plantareByPledgeId(pledgeId))!.remoteState, PlantareRemoteState.declined); }); test('a proposal with a bad signature is dropped, not stored', () async { // Build a proposal but corrupt the creditor's stub. final good = txA; await svcA.propose( direction: PlantareDirection.owedToMe, counterpartyKey: debtor.publicKeyHex, label: 'Judía', ); final tampered = good.proposed.single.copyWith( creditorSignature: 'f' * 128, // invalid ); await svcB.ingest( envelope(PlantareMessageKind.proposed, creditor.publicKeyHex, tampered), ); expect(await repoB.watchPlantares().first, isEmpty); expect(svcB.pendingProposals, isEmpty); }); test('an accept must carry both valid stubs or it is ignored', () async { final pledgeId = await svcA.propose( direction: PlantareDirection.owedToMe, counterpartyKey: debtor.publicKeyHex, label: 'Calabaza', ); // Half-signed "accept" (only the creditor's original stub) → ignored. await svcA.ingest( envelope(PlantareMessageKind.accepted, debtor.publicKeyHex, txA.proposed.single), ); expect( (await repoA.plantareByPledgeId(pledgeId))!.remoteState, PlantareRemoteState.proposed); }); test('a proposal addressed to someone else is not stored', () async { final stranger = await idFor(9); await svcA.propose( direction: PlantareDirection.owedToMe, counterpartyKey: stranger.publicKeyHex, // not the debtor label: 'Pepino', ); await svcB.ingest( envelope(PlantareMessageKind.proposed, creditor.publicKeyHex, txA.proposed.single), ); expect(await repoB.watchPlantares().first, isEmpty); }); }