tane/apps/app_seeds/test/services/plantare_service_test.dart
vjrj b607ddde41 feat(plantare): PlantareService — orchestrate the signed handshake
The app-layer driver that turns local intent into a signed proposal,
counter-signs or declines incoming ones, and reconciles every move into
the local ledger. Cryptography and wire format stay in commons_core;
this owns persistence and orchestration.

- propose(): builds + self-signs a PlantarePledge, records the local row
  (remoteState=proposed, my stub) and sends it. Direction maps to
  debtor/creditor: iReturn = I received and owe, owedToMe = I gave.
- accept(): counter-signs the exact in-hand proposal (verified) and sends
  the doubly-signed copy back, closing the row.
- decline(): notifies the proposer and marks the row declined.
- ingest(): verifies stubs before storing — a bad-signature proposal or a
  half-signed "accept" is dropped; proposals not addressed to me are
  ignored. Pending proposals held in memory keyed by pledge id (relay
  redelivers on reconnect), so accept signs the verified payload.

Wired into DI + Bootstrap alongside the inbox/sync listeners and torn
down/rebuilt on identity switch. SocialSession now exposes the transport.

Tests: full propose→accept close, decline, and the three drop paths with
a records-only transport + real repos. services/data green.
2026-07-14 11:21:46 +02:00

211 lines
7.1 KiB
Dart

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 = <PlantarePledge>[];
final accepted = <PlantarePledge>[];
final declined = <({String toPubkey, String pledgeId, String reason})>[];
@override
Future<void> propose(PlantarePledge pledge) async => proposed.add(pledge);
@override
Future<void> accept(PlantarePledge pledge) async => accepted.add(pledge);
@override
Future<void> decline({
required String toPubkey,
required String pledgeId,
String reason = '',
}) async =>
declined.add((toPubkey: toPubkey, pledgeId: pledgeId, reason: reason));
@override
Stream<PlantareEnvelope> incoming() => const Stream.empty();
@override
Future<void> close() async {}
}
void main() {
Future<NostrIdentity> 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);
});
}