618 lines
20 KiB
Dart
618 lines
20 KiB
Dart
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<DestinationException>().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 = <String>{};
|
|
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 <DestinationDetails>[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<OneTimeDestination>());
|
|
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<WalletException>().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),
|
|
);
|
|
});
|
|
}
|