Connect customer quotations and payment execution to Sendico (#50)
This commit is contained in:
1 parent
5d67c25c8e
commit
7a6510d1a8
45 files changed
+2696
-45
No files matched your search
@@ -0,0 +1,245 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||
|
||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||
|
||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||
|
||||
Map<String, Object?> quoteData() => {
|
||||
'id': 'local-quote',
|
||||
'request': {
|
||||
'kind': 'payout',
|
||||
'source': 'ledger',
|
||||
'destination': '',
|
||||
'recipient': 'Saved bank ending 1234',
|
||||
'amount': 2001,
|
||||
},
|
||||
'fee': 100,
|
||||
'debit': 2101,
|
||||
'credit': 2001,
|
||||
'expiresAt': DateTime.now()
|
||||
.add(const Duration(minutes: 5))
|
||||
.toUtc()
|
||||
.toIso8601String(),
|
||||
'rateNumerator': 1,
|
||||
'rateDenominator': 1,
|
||||
};
|
||||
Map<String, Object?> operationData(String status) => {
|
||||
'id': 'local-quote',
|
||||
'quote': quoteData(),
|
||||
'status': status,
|
||||
'createdAt': DateTime.now().toUtc().toIso8601String(),
|
||||
};
|
||||
http.Response success(Object? data) =>
|
||||
http.Response(jsonEncode({'status': 'success', 'data': data}), 200);
|
||||
void main() {
|
||||
test('backend wallet maps accounts, authoritative quote, execution and reconciliation', () async {
|
||||
final requests = <http.Request>[];
|
||||
final service = BackendWalletService(
|
||||
baseUri: Uri.parse('https://bff.example/api/v1'),
|
||||
accessToken: () => 'session',
|
||||
client: MockClient((r) async {
|
||||
requests.add(r);
|
||||
expect(r.headers['Authorization'], 'Bearer session');
|
||||
expect(r.followRedirects, false);
|
||||
switch (r.url.path) {
|
||||
case '/api/v1/accounts/wallet':
|
||||
return success({
|
||||
'accounts': [
|
||||
{
|
||||
'id': 'ledger',
|
||||
'currency': 'EUR',
|
||||
'precision': 2,
|
||||
'available': 7500,
|
||||
'payoutMethod': 'method',
|
||||
'payoutLabel': 'Saved bank ending 1234',
|
||||
'canFund': false,
|
||||
},
|
||||
],
|
||||
'operations': <Object>[],
|
||||
});
|
||||
case '/api/v1/accounts/wallet/quote':
|
||||
expect(jsonDecode(r.body), {
|
||||
'kind': 'payout',
|
||||
'source': 'ledger',
|
||||
'destination': '',
|
||||
'amount': 2001,
|
||||
'recipient': 'method',
|
||||
});
|
||||
return success(quoteData());
|
||||
case '/api/v1/accounts/wallet/submit':
|
||||
expect(jsonDecode(r.body), {
|
||||
'quoteId': 'local-quote',
|
||||
'idempotencyKey': 'authorized-key',
|
||||
});
|
||||
return success(operationData('pending'));
|
||||
case '/api/v1/accounts/wallet/submission':
|
||||
return success(operationData('completed'));
|
||||
default:
|
||||
return success({'locale': 'ru'});
|
||||
}
|
||||
}),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
expect(service.isSimulated, false);
|
||||
final data = await service.fetch();
|
||||
expect(data.accounts.single.available, 7500);
|
||||
expect(data.accounts.single.payoutLabel, 'Saved bank ending 1234');
|
||||
final q = await service.quote(
|
||||
const TransferRequest(
|
||||
kind: TransferKind.payout,
|
||||
source: 'ledger',
|
||||
recipient: 'method',
|
||||
amount: 2001,
|
||||
),
|
||||
);
|
||||
expect(q.debit, 2101);
|
||||
expect(q.request.recipient, 'Saved bank ending 1234');
|
||||
expect(
|
||||
(await service.submit(q.id, 'authorized-key')).status,
|
||||
OperationStatus.pending,
|
||||
);
|
||||
expect(
|
||||
(await service.findSubmission('authorized-key'))!.status,
|
||||
OperationStatus.completed,
|
||||
);
|
||||
expect((await service.profile()).locale, 'ru');
|
||||
expect(requests.length, 5);
|
||||
});
|
||||
test('session replacement rejects late responses and never returns stale balances', () async {
|
||||
String? token = 'one';
|
||||
final response = Completer<http.Response>();
|
||||
final service = BackendWalletService(
|
||||
baseUri: Uri.parse('https://bff.example'),
|
||||
accessToken: () => token,
|
||||
client: MockClient((_) => response.future),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
final result = service.fetch();
|
||||
token = 'two';
|
||||
response.complete(success({'accounts': [], 'operations': []}));
|
||||
await expectLater(result, throwsA(isA<WalletException>()));
|
||||
token = null;
|
||||
await expectLater(service.fetch(), throwsA(isA<WalletException>()));
|
||||
});
|
||||
test(
|
||||
'malformed or unsuccessful responses cannot report payment success',
|
||||
() async {
|
||||
for (final response in [
|
||||
http.Response('broken', 200),
|
||||
http.Response('{"status":"error","data":{"error":"uncertain"}}', 503),
|
||||
success(null),
|
||||
]) {
|
||||
final service = BackendWalletService(
|
||||
baseUri: Uri.parse('https://bff.example'),
|
||||
accessToken: () => 'session',
|
||||
client: MockClient((_) async => response),
|
||||
);
|
||||
await expectLater(
|
||||
service.submit('quote', 'key'),
|
||||
throwsA(isA<WalletException>()),
|
||||
);
|
||||
service.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
test(
|
||||
'payment factor keeps authorization and resend keys stable across retry',
|
||||
() async {
|
||||
final bodies = <Map<String, dynamic>>[];
|
||||
var fail = true;
|
||||
final wallet = BackendWalletService(
|
||||
baseUri: Uri.parse('https://bff.example'),
|
||||
accessToken: () => 'session',
|
||||
client: MockClient((r) async {
|
||||
final body = jsonDecode(r.body) as Map<String, dynamic>;
|
||||
bodies.add(body);
|
||||
if (r.url.path.endsWith('/verify')) {
|
||||
if (fail) {
|
||||
fail = false;
|
||||
return http.Response(
|
||||
'{"status":"error","data":{"error":"factorInvalid"}}',
|
||||
400,
|
||||
);
|
||||
}
|
||||
return success({'idempotencyKey': body['idempotencyKey']});
|
||||
}
|
||||
return success({
|
||||
'challengeId': 'challenge',
|
||||
'target': 'a***@example.com',
|
||||
'expiresAt': DateTime.now()
|
||||
.add(const Duration(minutes: 1))
|
||||
.toUtc()
|
||||
.toIso8601String(),
|
||||
'resendAt': DateTime.now().toUtc().toIso8601String(),
|
||||
});
|
||||
}),
|
||||
);
|
||||
addTearDown(wallet.close);
|
||||
final factor = WalletFactorService(wallet, DummyTwoFactorService());
|
||||
final challenge = (await factor.begin(FactorPurpose.payout, 'quote'))!;
|
||||
await expectLater(
|
||||
factor.verify(challenge.id, 'bad'),
|
||||
throwsA(
|
||||
isA<FactorException>().having(
|
||||
(e) => e.code,
|
||||
'code',
|
||||
FactorError.invalid,
|
||||
),
|
||||
),
|
||||
);
|
||||
final grant = await factor.verify(challenge.id, '123456');
|
||||
expect(grant.token, bodies[1]['idempotencyKey']);
|
||||
expect(bodies[1]['idempotencyKey'], bodies[2]['idempotencyKey']);
|
||||
await factor.resend(challenge.id);
|
||||
expect(bodies.last['idempotencyKey'], isNotEmpty);
|
||||
},
|
||||
);
|
||||
test('provider waits for real account IDs and submits with the authorized intent key', () async {
|
||||
final service = BackendWalletService(
|
||||
baseUri: Uri.parse('https://bff.example'),
|
||||
accessToken: () => 'session',
|
||||
client: MockClient((r) async {
|
||||
if (r.url.path.endsWith('/quote')) return success(quoteData());
|
||||
if (r.url.path.endsWith('/submit')) {
|
||||
expect(jsonDecode(r.body)['idempotencyKey'], 'approved-intent-key');
|
||||
return success(operationData('completed'));
|
||||
}
|
||||
return success({
|
||||
'accounts': [
|
||||
{
|
||||
'id': 'ledger',
|
||||
'currency': 'EUR',
|
||||
'precision': 2,
|
||||
'available': 7500,
|
||||
'payoutMethod': 'method',
|
||||
'payoutLabel': 'Saved bank ending 1234',
|
||||
'canFund': false,
|
||||
},
|
||||
],
|
||||
'operations': <Object>[],
|
||||
});
|
||||
}),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
final wallet = WalletProvider(service, DummyAnalyticsService());
|
||||
addTearDown(wallet.dispose);
|
||||
final draft = TransferProvider(wallet, TransferKind.payout);
|
||||
addTearDown(draft.dispose);
|
||||
await wallet.refresh();
|
||||
expect(draft.source, 'ledger');
|
||||
expect(draft.recipient, 'method');
|
||||
expect(draft.hasDraft, false);
|
||||
draft.change(amount: '20.01');
|
||||
await draft.review();
|
||||
await draft.submit(approval: const FactorGrant('approved-intent-key'));
|
||||
expect(draft.result, isNotNull);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user