Files
sxchnge/frontend/packages/sxchnge_wallet/test/backend_test.dart
T

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);
});
}