246 lines
8.1 KiB
Dart
246 lines
8.1 KiB
Dart
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);
|
|
});
|
|
}
|