Files
pangolin/client/test/unit/payment_flow_test.dart
wangjia 301e13f477 fix(client/payment): switchMethod 补 catch-all 兜网络/超时错(与 start/resume 对齐)防卡 awaitingPayment
switchMethod 的首个 retry() 调用只 catch AuthApiException,网络/超时/未知异常会穿出到
无 try/catch 的 _pickAndSwitch,UI 卡在 awaitingPayment 无任何提示。补一个 catch-all,
与 start()/resume() 同一处理形状落 failed 相位(自动经 build() 切到 _failed() 呈现);
payment_page.dart 的 _pickAndSwitch 再加一层防御性 try/catch + toast,防任何逃逸异常
变成无提示的 unhandled rejection。补单测覆盖非 Auth 异常路径。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
2026-07-13 15:53:30 +08:00

153 lines
6.0 KiB
Dart

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, retryCalls = 0;
bool activated = false;
Exception? retryError;
String lastMethod = '';
String lastOrderNo = '';
@override
Future<List<PayCatalogItem>> catalog() async => const [];
@override
Future<List<PayOrderSummary>> listOrders() 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 {
retryCalls++;
lastMethod = method;
lastOrderNo = orderNo;
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('stopPolling() 只停 Timer,不远程 cancel、不重置 state', () 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);
ctl.stopPolling();
expect(api.cancelCalls, 0);
expect(c.read(paymentFlowProvider).phase, PaymentPhase.awaitingPayment);
expect(c.read(paymentFlowProvider).order?.orderNo, 'pay1');
// Timer 已停:再等一拍轮询周期,statusCalls 不应继续增长(pollOnce 只由
// Timer 或测试显式调用驱动,这里断言的是"没有 Timer 在背后继续触发")。
final callsAfterStop = api.statusCalls;
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(api.statusCalls, callsAfterStop);
});
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');
});
test('switchMethod 遇非 Auth 异常(网络/超时)→ 落 failed 兜底,不卡 awaitingPayment', () async {
final api = _FakePaymentApi();
final c = _container(api);
final ctl = c.read(paymentFlowProvider.notifier);
await ctl.start(item, 'crypto');
api.retryError = Exception('network timeout');
await ctl.switchMethod('alipay');
expect(c.read(paymentFlowProvider).phase, PaymentPhase.failed);
expect(c.read(paymentFlowProvider).errorZh, isNotNull);
expect(c.read(paymentFlowProvider).errorZh, isNotEmpty);
expect(c.read(paymentFlowProvider).errorEn, isNotNull);
expect(c.read(paymentFlowProvider).errorEn, isNotEmpty);
});
test('resume() 成功 → retry 恢复原单会话(不新下单),phase=awaitingPayment', () async {
final api = _FakePaymentApi();
final c = _container(api);
final ctl = c.read(paymentFlowProvider.notifier);
await ctl.resume('o-month', 'alipay');
expect(api.retryCalls, 1);
expect(api.createCalls, 0, reason: '继续支付不应新下单');
expect(api.lastOrderNo, 'o-month');
expect(api.lastMethod, 'alipay');
expect(c.read(paymentFlowProvider).phase, PaymentPhase.awaitingPayment);
expect(c.read(paymentFlowProvider).order?.orderNo, 'o-month');
});
test('resume() 遇 409 ORDER_NOT_PENDING(订单其实已变化)→ failed 自纠,不 crash', () async {
final api = _FakePaymentApi();
final c = _container(api);
final ctl = c.read(paymentFlowProvider.notifier);
api.retryError = const AuthApiException(
statusCode: 409, messageZh: 'x', messageEn: 'x', code: 'ORDER_NOT_PENDING');
await ctl.resume('o-month', 'alipay');
expect(c.read(paymentFlowProvider).phase, PaymentPhase.failed);
expect(c.read(paymentFlowProvider).errorZh, isNotNull);
expect(c.read(paymentFlowProvider).errorZh, isNotEmpty);
expect(c.read(paymentFlowProvider).errorEn, isNotNull);
expect(c.read(paymentFlowProvider).errorEn, isNotEmpty);
});
}