feat(client): pay 支付领域模型 + PaymentApi + 支付流控制器(轮询 activated)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:pangolin_vpn/services/api_client.dart';
|
||||
import 'package:pangolin_vpn/services/auth_api.dart';
|
||||
import 'package:pangolin_vpn/services/payment_api.dart';
|
||||
|
||||
ApiClient _client(MockClient mock) => ApiClient(
|
||||
baseUrl: 'http://x',
|
||||
getToken: () => 'tok1',
|
||||
refresh: () async => false,
|
||||
client: mock,
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('catalog 解析三档', () async {
|
||||
final api = PaymentApi(_client(MockClient((req) async {
|
||||
expect(req.url.path, '/v1/pay/catalog');
|
||||
return http.Response(
|
||||
'{"items":[{"sku":"pro_month","plan":"pro","days":31,"price_minor":2999,"currency":"CNY"},'
|
||||
'{"sku":"pro_quarter","plan":"pro","days":92,"price_minor":6888,"currency":"CNY"},'
|
||||
'{"sku":"pro_year","plan":"pro","days":366,"price_minor":19999,"currency":"CNY"}]}',
|
||||
200);
|
||||
})));
|
||||
final items = await api.catalog();
|
||||
expect(items.length, 3);
|
||||
expect(items.first.sku, 'pro_month');
|
||||
expect(items.first.priceLabel(), '¥29.99');
|
||||
});
|
||||
|
||||
test('createOrder 只传 sku+method+metadata,解析 session', () async {
|
||||
final api = PaymentApi(_client(MockClient((req) async {
|
||||
expect(req.url.path, '/v1/pay/orders');
|
||||
expect(req.body.contains('"amount'), isFalse, reason: '客户端绝不传金额');
|
||||
return http.Response(
|
||||
'{"order_no":"pay1","session":{"render_type":"crypto_address",'
|
||||
'"payload":{"address":"Txx","amount":"4.201234","amount_minor":4201234,'
|
||||
'"currency":"USDT","network":"TRC20"},"expires_at":"2026-07-10T12:00:00Z"}}',
|
||||
200);
|
||||
})));
|
||||
final order = await api.createOrder(sku: 'pro_month', method: 'crypto');
|
||||
expect(order.orderNo, 'pay1');
|
||||
expect(order.session.renderType, 'crypto_address');
|
||||
expect(order.session.payload['address'], 'Txx');
|
||||
expect(order.session.expiresAt, isNotNull);
|
||||
});
|
||||
|
||||
test('orderStatus 解析 activated/expires_at', () async {
|
||||
final api = PaymentApi(_client(MockClient((req) async => http.Response(
|
||||
'{"order_no":"pay1","pay_status":"succeeded","activated":true,'
|
||||
'"expires_at":"2026-08-10T12:00:00Z"}',
|
||||
200))));
|
||||
final st = await api.orderStatus('pay1');
|
||||
expect(st.activated, isTrue);
|
||||
expect(st.payStatus, 'succeeded');
|
||||
expect(st.expiresAt, isNotNull);
|
||||
});
|
||||
|
||||
test('retry 409 CURRENCY_MISMATCH 抛带 code 的异常', () async {
|
||||
final api = PaymentApi(_client(MockClient((req) async => http.Response(
|
||||
'{"code":"CURRENCY_MISMATCH","message_zh":"该支付方式结算币种与订单不符,请重新下单",'
|
||||
'"message_en":"mismatch"}',
|
||||
409,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'}))));
|
||||
try {
|
||||
await api.retry('pay1', method: 'alipay');
|
||||
fail('应抛异常');
|
||||
} on AuthApiException catch (e) {
|
||||
expect(e.statusCode, 409);
|
||||
expect(e.code, 'CURRENCY_MISMATCH');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pangolin_vpn/models/payment.dart';
|
||||
import 'package:pangolin_vpn/services/auth_api.dart';
|
||||
import 'package:pangolin_vpn/services/payment_api.dart';
|
||||
import 'package:pangolin_vpn/state/payment_provider.dart';
|
||||
|
||||
class _FakePaymentApi implements PaymentApi {
|
||||
_FakePaymentApi();
|
||||
int createCalls = 0, cancelCalls = 0, statusCalls = 0;
|
||||
bool activated = false;
|
||||
Exception? retryError;
|
||||
String lastMethod = '';
|
||||
|
||||
@override
|
||||
Future<List<PayCatalogItem>> catalog() async => const [];
|
||||
|
||||
@override
|
||||
Future<PayOrder> createOrder({required String sku, required String method, Map<String, String>? metadata}) async {
|
||||
createCalls++;
|
||||
lastMethod = method;
|
||||
return PayOrder(
|
||||
orderNo: 'pay$createCalls',
|
||||
session: PaySession(renderType: method == 'crypto' ? 'crypto_address' : 'redirect', payload: const {'url': 'https://x'}, expiresAt: null));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PayOrderStatus> orderStatus(String orderNo) async {
|
||||
statusCalls++;
|
||||
return PayOrderStatus(orderNo: orderNo, payStatus: activated ? 'succeeded' : 'pending', activated: activated, expiresAt: null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PayOrder> retry(String orderNo, {required String method, Map<String, String>? metadata}) async {
|
||||
if (retryError != null) throw retryError!;
|
||||
return PayOrder(orderNo: orderNo, session: PaySession(renderType: 'redirect', payload: const {'url': 'https://y'}, expiresAt: null));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cancel(String orderNo) async {
|
||||
cancelCalls++;
|
||||
}
|
||||
}
|
||||
|
||||
ProviderContainer _container(_FakePaymentApi api) {
|
||||
final c = ProviderContainer(overrides: [paymentApiProvider.overrideWithValue(api)]);
|
||||
addTearDown(c.dispose);
|
||||
return c;
|
||||
}
|
||||
|
||||
void main() {
|
||||
const item = PayCatalogItem(sku: 'pro_month', plan: 'pro', days: 31, priceMinor: 2999, currency: 'CNY');
|
||||
|
||||
test('start → awaitingPayment,pollOnce 到 activated 变 succeeded', () async {
|
||||
final api = _FakePaymentApi();
|
||||
final c = _container(api);
|
||||
final ctl = c.read(paymentFlowProvider.notifier);
|
||||
await ctl.start(item, 'crypto');
|
||||
expect(c.read(paymentFlowProvider).phase, PaymentPhase.awaitingPayment);
|
||||
await ctl.pollOnce();
|
||||
expect(c.read(paymentFlowProvider).phase, PaymentPhase.awaitingPayment);
|
||||
api.activated = true;
|
||||
await ctl.pollOnce();
|
||||
expect(c.read(paymentFlowProvider).phase, PaymentPhase.succeeded);
|
||||
});
|
||||
|
||||
test('switchMethod 遇 CURRENCY_MISMATCH → cancel 旧单 + 新 method 重新下单', () async {
|
||||
final api = _FakePaymentApi();
|
||||
final c = _container(api);
|
||||
final ctl = c.read(paymentFlowProvider.notifier);
|
||||
await ctl.start(item, 'crypto');
|
||||
api.retryError = const AuthApiException(
|
||||
statusCode: 409, messageZh: 'x', messageEn: 'x', code: 'CURRENCY_MISMATCH');
|
||||
await ctl.switchMethod('alipay');
|
||||
expect(api.cancelCalls, 1);
|
||||
expect(api.createCalls, 2, reason: '换币种必须新单(pay 契约)');
|
||||
expect(api.lastMethod, 'alipay');
|
||||
expect(c.read(paymentFlowProvider).order?.orderNo, 'pay2');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user