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 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 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 = []; 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': [], }); 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(); 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())); token = null; await expectLater(service.fetch(), throwsA(isA())); }); 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()), ); service.close(); } }, ); test( 'payment factor keeps authorization and resend keys stable across retry', () async { final bodies = >[]; 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; 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().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': [], }); }), ); 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); }); }