import 'package:flutter_test/flutter_test.dart'; import 'package:sxchnge_analytics/sxchnge_analytics.dart'; import 'package:sxchnge_kyc/sxchnge_kyc.dart'; import 'package:sxchnge_wallet/sxchnge_wallet.dart'; import 'package:sxchnge_wallet/src/destinations/transport.dart'; const crypto = CryptoDestination( assetId: 'demo:ethereum:native', networkId: 'eip155:1', address: '0x1234567890123456789012345678901234567890', assetLabel: 'Untrusted label', networkLabel: 'Untrusted network', ); const input = OneTimeDestination(crypto); const card = CardDestination( capture: 'demo-card-ready', brand: 'Untrusted brand', last4: '9999', expiryMonth: 1, expiryYear: 1900, ); const bank = Bank( bik: '044525225', name: 'Demo Bank', city: 'Moscow', correspondent: '30101810400000000225', revision: 'demo-1', ); const russian = RussianBankDestination( firstName: 'Anna Maria', lastName: 'de la Cruz', bank: bank, account: '40817810899910004312', ); Matcher issue(DestinationIssue value) => throwsA(isA().having((e) => e.issue, 'issue', value)); void main() { late DummyDestinationService service; setUp(() { service = DummyDestinationService(); }); test('one-time validation, quotation and acceptance never save', () async { final lease = await service.prepare( input, const PayoutContext('eur', amount: 100), ); expect(lease.snapshot.display, contains('ETH')); service.accept(lease); expect(await service.list(), isEmpty); }); test('idempotency replays creation and rejects changed payload', () async { final first = await service.create( crypto, 'Private name', 'creation-key-000001', ); expect(first.outcome, SaveOutcome.created); expect( (await service.create( crypto, 'Private name', 'creation-key-000001', )).destinationId, first.destinationId, ); await expectLater( service.create(crypto, 'Changed', 'creation-key-000001'), issue(DestinationIssue.conflict), ); expect((await service.list()).length, 1); }); test( 'duplicate outcome is stable after rename, removal and concurrent create', () async { final results = await Future.wait( List.generate( 8, (i) => service.create(crypto, 'Name $i', 'creation-key-00000$i'), ), ); expect(results.where((r) => r.outcome == SaveOutcome.created).length, 1); expect(results.map((r) => r.destinationId).toSet().length, 1); final d = await service.get(results.first.destinationId); final renamed = await service.rename( d.id, 'Original stays', d.metadataRevision, ); await service.remove( d.id, renamed.metadataRevision, renamed.executionRevision, ); final replay = await service.create( crypto, 'Name 1', 'creation-key-000001', ); expect(replay.outcome, SaveOutcome.duplicate); expect(replay.destinationId, d.id); expect( (await service.creation('creation-key-000001'))!.outcome, SaveOutcome.duplicate, ); }, ); test( 'save timeout reconciles a created record separately from duplicate', () async { service.loseNextSaveResponse = true; await expectLater( service.create(crypto, '', 'creation-key-000001'), issue(DestinationIssue.uncertain), ); expect( (await service.creation('creation-key-000001'))!.outcome, SaveOutcome.created, ); service.loseNextSaveResponse = true; await expectLater( service.create(crypto, '', 'creation-key-000002'), issue(DestinationIssue.uncertain), ); expect( (await service.creation('creation-key-000002'))!.outcome, SaveOutcome.duplicate, ); }, ); test('customer isolation includes duplicates, attempt lookup, reads and mutations', () async { final memory = DestinationMemory(); final a = DummyDestinationService(customer: 'a', memory: memory); final b = DummyDestinationService(customer: 'b', memory: memory); final saved = await a.create(crypto, 'A', 'creation-key-000001'); expect(await b.creation('creation-key-000001'), isNull); await expectLater( b.get(saved.destinationId), issue(DestinationIssue.removed), ); await expectLater( b.rename(saved.destinationId, 'B', 1), issue(DestinationIssue.removed), ); await expectLater( b.remove(saved.destinationId, 1, 1), issue(DestinationIssue.removed), ); expect( (await b.create(crypto, 'B', 'creation-key-000001')).outcome, SaveOutcome.created, ); }); test('metadata concurrency never changes execution revision or invalidates lease', () async { final result = await service.create(crypto, '', 'creation-key-000001'); final d = await service.get(result.destinationId); final lease = await service.prepare(d.input, const PayoutContext('eur')); final changed = await service.rename(d.id, 'New label', d.metadataRevision); expect(changed.executionRevision, d.executionRevision); expect(changed.metadataRevision, d.metadataRevision + 1); service.accept(lease); await expectLater( service.rename(d.id, 'Stale', d.metadataRevision), issue(DestinationIssue.conflict), ); }); test( 'removal invalidates draft lease and never mutates previous snapshots', () async { final result = await service.create(crypto, '', 'creation-key-000001'); final d = await service.get(result.destinationId); final lease = await service.prepare(d.input, const PayoutContext('eur')); await service.remove(d.id, d.metadataRevision, d.executionRevision); expect(() => service.accept(lease), issue(DestinationIssue.removed)); expect(lease.snapshot.review, contains(crypto.address)); expect(await service.list(), isEmpty); expect(service.memory.audit.map((a) => a.action), contains('remove')); }, ); test( 'requirements and contextual availability are rechecked at acceptance', () async { final result = await service.create(crypto, '', 'creation-key-000001'); final d = await service.get(result.destinationId); final lease = await service.prepare(d.input, const PayoutContext('eur')); service.availability[d.id] = const PayoutEligibility( EligibilityState.temporarilyUnavailable, reason: DestinationIssue.unavailable, ); expect(() => service.accept(lease), issue(DestinationIssue.unavailable)); service.availability.clear(); service.requirementsRevision++; expect(() => service.accept(lease), issue(DestinationIssue.conflict)); }, ); test( 'network and canonical token identity distinguish duplicate addresses', () async { final ids = {}; for (final a in DummyDestinationService.assets.where( (a) => a.networkId.startsWith('eip155:'), )) { final result = await service.create( CryptoDestination( assetId: a.id, networkId: a.networkId, address: crypto.address, memo: '', assetLabel: a.label, networkLabel: a.networkLabel, ), '', destinationKey(), ); ids.add(result.destinationId); } expect(ids.length, 4); }, ); test( 'address presentation preserves case; normalization is service-owned', () async { const upper = CryptoDestination( assetId: 'demo:ethereum:native', networkId: 'eip155:1', address: '0xABCDEFabcdefABCDEFabcdefABCDEFabcdefABCD', assetLabel: 'ETH', networkLabel: 'Ethereum', ); final first = await service.create(upper, '', 'creation-key-000001'); final lower = CryptoDestination( assetId: upper.assetId, networkId: upper.networkId, address: upper.address.toLowerCase(), assetLabel: 'ETH', networkLabel: 'Ethereum', ); expect( (await service.create(lower, '', 'creation-key-000002')).destinationId, first.destinationId, ); expect( (await service.get(first.destinationId)).details.review, contains(upper.address), ); }, ); test('memo requirements are validated before saving', () async { const d = CryptoDestination( assetId: 'demo:xrp:native', networkId: 'xrpl:mainnet', address: 'r123456789ABCDEFGHJKLMNPQRSTU', assetLabel: 'XRP', networkLabel: 'XRP Ledger', ); await expectLater( service.create(d, '', 'creation-key-000001'), issue(DestinationIssue.invalid), ); expect(await service.list(), isEmpty); }); test('card captures never trust user display metadata; recovery preserves record', () async { final r = await service.create(card, '', 'creation-key-000001'); final saved = await service.get(r.destinationId); expect(saved.details.display, contains('4242')); expect(saved.details.display, isNot(contains('9999'))); const recovery = CardDestination( capture: 'demo-card-recover', brand: '', last4: '', expiryMonth: 0, expiryYear: 0, ); final other = DummyDestinationService(); final pending = await other.create(recovery, '', 'creation-key-000001'); final d = await other.get(pending.destinationId); expect( (await other.assess(d.input, const PayoutContext('eur'))).state, EligibilityState.requiresAction, ); expect((await other.list()).length, 1); }); test('unreliable card fingerprint does not merge by last four', () async { const d = CardDestination( capture: 'demo-card-unknown', brand: '', last4: '', expiryMonth: 0, expiryYear: 0, ); final a = await service.create(d, '', 'creation-key-000001'); final b = await service.create(d, '', 'creation-key-000002'); expect(a.destinationId, isNot(b.destinationId)); expect(b.outcome, SaveOutcome.created); }); test( 'bank control key and directory changes are separate from ownership', () async { expect(validBankAccount('40817810899910004312', bank.bik), isTrue); final r = await service.create(russian, '', 'creation-key-000001'); final saved = await service.get(r.destinationId); expect(saved.ownership.verified, isFalse); service.directory = [ const Bank( bik: '044525225', name: 'New cosmetic name', city: 'Moscow', correspondent: '30101810400000000225', revision: 'demo-2', ), ]; expect( (await service.assess( saved.input, const PayoutContext('eur'), )).eligible, isTrue, ); service.directory = [ const Bank( bik: '044525225', name: 'New bank', city: 'Moscow', correspondent: 'changed', revision: 'demo-3', ), ]; expect( (await service.assess(saved.input, const PayoutContext('eur'))).reason, DestinationIssue.bankChanged, ); expect( (await service.get(saved.id)).details.review, contains(bank.correspondent), ); }, ); test( 'paste conflicts are never guessed and full holder names remain unsplit', () { final parsed = BankPaste.parse( 'БИК: 044525225\nБИК: 044525999\nНомер счета: 0000 1234\nФИО: Anna Maria de la Cruz', ); expect(parsed.conflicts, contains('bik')); expect(parsed.values, isNot(contains('bik'))); expect(parsed.values['account'], '00001234'); expect(parsed.values['holder'], 'Anna Maria de la Cruz'); expect(parsed.values, isNot(contains('firstName'))); }, ); test('all details round trip through DTO mapper', () { for (final d in [crypto, card, russian]) { expect(DestinationMapper.clone(d).review, d.review); } }); test( 'optional save failure can continue one-time without claiming saved', () async { final p = DestinationFormProvider( service, DummyAnalyticsService(), context: const PayoutContext('eur'), ); addTearDown(p.dispose); p.change(crypto); p.preferences(save: true); service.nextSaveError = DestinationIssue.conflict; await p.proceed(); expect(p.savingFailed, isTrue); expect(p.saved, isNull); await p.proceed(withoutSaving: true); expect(p.result, isA()); expect(await service.list(), isEmpty); }, ); test('saving and contextual eligibility have independent outcomes', () async { final p = DestinationFormProvider( service, DummyAnalyticsService(), context: const PayoutContext('unsupported'), ); addTearDown(p.dispose); p.change(crypto); p.preferences(save: true); await p.proceed(); expect(p.saved!.outcome, SaveOutcome.created); expect(p.eligibility!.state, EligibilityState.unsupported); expect(p.result, isNull); expect(p.error, isNull); }); test('explicit duplicate reuse preserves existing nickname', () async { final first = await service.create( crypto, 'Original', 'creation-key-000001', ); final p = DestinationFormProvider( service, DummyAnalyticsService(), context: const PayoutContext('eur'), ); addTearDown(p.dispose); p.change(crypto); p.preferences(save: true, nickname: 'New'); await p.proceed(); expect(p.saved!.outcome, SaveOutcome.duplicate); expect(p.result, isNull); await p.useExisting(); expect((p.result as SavedReference).id, first.destinationId); expect((await service.get(first.destinationId)).nickname, 'Original'); }); test( 'wallet acceptance rejects removed destination without debiting', () async { final wallet = DummyWalletService( DummyIdentityService(delay: Duration.zero), delay: Duration.zero, destinations: service, ); final created = await service.create(crypto, '', 'creation-key-000001'); final d = await service.get(created.destinationId); final q = await wallet.quote( TransferRequest( kind: TransferKind.payout, source: 'eur', amount: 1000, payoutDestination: d.input, ), ); await service.remove(d.id, d.metadataRevision, d.executionRevision); await expectLater( wallet.submit(q.id, 'request'), throwsA( isA().having( (e) => e.code, 'code', WalletError.destinationChanged, ), ), ); expect((await wallet.fetch()).accounts.first.available, 7500); expect((await wallet.fetch()).operations, isEmpty); }, ); test('large collections search safe metadata and sort recent use without choosing', () async { for (var i = 0; i < 240; i++) { await service.create( CryptoDestination( assetId: crypto.assetId, networkId: crypto.networkId, address: '0x' + i.toRadixString(16).padLeft(40, '0'), assetLabel: 'ETH', networkLabel: 'Ethereum', ), 'Wallet ' + i.toString(), destinationKey(), ); } final rows = await service.list(); service.accept( await service.prepare(rows.last.input, const PayoutContext('eur')), ); final p = DestinationsProvider(service); addTearDown(p.dispose); await p.refresh(); expect(p.search('', DestinationType.crypto).first.id, rows.last.id); expect( p.search('Wallet 239', DestinationType.crypto).single.id, rows.last.id, ); expect(p.search('not found', DestinationType.crypto), isEmpty); }); test('cards without supported reuse or recovery cannot be saved', () async { service.reusableCards = false; service.cardRecovery = false; expect( (await service.assess( const OneTimeDestination(card), const PayoutContext('eur'), )).canSave, isFalse, ); await expectLater( service.create(card, '', 'creation-key-000001'), issue(DestinationIssue.unsupported), ); expect(await service.list(), isEmpty); }); test('existing card survives loss of its only reusable binding', () async { final result = await service.create(card, '', 'creation-key-000001'); final d = await service.get(result.destinationId); expect((d.details as CardDestination).capture, isEmpty); final old = await service.prepare(d.input, const PayoutContext('eur')); service.memory.records[service.customer]![d.id] = SavedDestination( id: d.id, details: d.details, metadataRevision: d.metadataRevision, executionRevision: d.executionRevision + 1, bindings: [ const ProviderBinding( reference: 'binding', state: BindingState.revoked, reusable: true, recoverable: true, ), ], ); final changed = await service.get(d.id); expect( (await service.assess(changed.input, const PayoutContext('eur'))).state, EligibilityState.requiresAction, ); expect(() => service.accept(old), issue(DestinationIssue.conflict)); expect((await service.list()).length, 1); service.cardRecovery = false; expect( (await service.assess(changed.input, const PayoutContext('eur'))).state, EligibilityState.unsupported, ); }); test( 'accepted payout snapshot survives nickname changes and removal', () async { final wallet = DummyWalletService( DummyIdentityService(delay: Duration.zero), delay: Duration.zero, destinations: service, ); final result = await service.create(crypto, '', 'creation-key-000001'); final d = await service.get(result.destinationId); final q = await wallet.quote( TransferRequest( kind: TransferKind.payout, source: 'eur', amount: 1000, payoutDestination: d.input, ), ); final renamed = await service.rename( d.id, 'After quote', d.metadataRevision, ); final accepted = await wallet.submit(q.id, 'payout-key'); await service.remove( d.id, renamed.metadataRevision, renamed.executionRevision, ); expect((await wallet.fetch()).operations.single.id, accepted.id); expect(accepted.quote.payoutSnapshot!.review, contains(crypto.address)); }, ); test('card recovery preserves identity, invalidates execution and replays idempotently', () async { const capture = CardDestination( capture: 'demo-card-recover', brand: '', last4: '', expiryMonth: 0, expiryYear: 0, ); final created = await service.create( capture, 'Keep label', 'creation-key-000001', ); final d = await service.get(created.destinationId); final restored = await service.restoreCard( d.id, d.executionRevision, card, 'card-recovery-key-0001', ); expect(restored.id, d.id); expect(restored.nickname, d.nickname); expect(restored.metadataRevision, d.metadataRevision); expect(restored.executionRevision, d.executionRevision + 1); expect( (await service.assess( restored.input, const PayoutContext('eur'), )).eligible, isTrue, ); expect( (await service.restoreCard( d.id, d.executionRevision, card, 'card-recovery-key-0001', )).executionRevision, restored.executionRevision, ); expect((await service.list()).length, 1); const unidentified = CardDestination( capture: 'demo-card-unknown', brand: '', last4: '', expiryMonth: 0, expiryYear: 0, ); await expectLater( service.restoreCard( d.id, restored.executionRevision, unidentified, 'card-recovery-key-0002', ), issue(DestinationIssue.replacementRequired), ); }); }