credit-payment-flow.test.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. /**
  2. * 额度支付流程集成测试
  3. * 测试完整额度支付流程
  4. */
  5. import { render, screen, waitFor, fireEvent } from '@testing-library/react'
  6. import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
  7. import PaymentPage from '@/pages/payment/index'
  8. import { creditBalanceClient, paymentClient } from '@/api'
  9. import { mockUseRouter, mockNavigateTo, mockShowToast, mockRedirectTo } from '~/__mocks__/taroMock'
  10. // @tarojs/taro 已经在 jest.config.js 中通过 moduleNameMapper 重定向到 mock 文件
  11. // 不需要额外 mock
  12. // Mock API客户端
  13. jest.mock('@/api', () => ({
  14. creditBalanceClient: {
  15. me: {
  16. $get: jest.fn(),
  17. },
  18. payment: {
  19. $post: jest.fn(),
  20. },
  21. },
  22. paymentClient: {
  23. payment: {
  24. $post: jest.fn(),
  25. },
  26. },
  27. }))
  28. // Mock 支付工具函数
  29. jest.mock('@/utils/payment', () => ({
  30. requestWechatPayment: jest.fn(),
  31. PaymentStatus: {
  32. PENDING: 'pending',
  33. PROCESSING: 'processing',
  34. SUCCESS: 'success',
  35. FAILED: 'failed',
  36. },
  37. PaymentStateManager: {
  38. getInstance: jest.fn(() => ({
  39. setPaymentState: jest.fn(),
  40. clearPaymentState: jest.fn(),
  41. })),
  42. },
  43. PaymentRateLimiter: {
  44. getInstance: jest.fn(() => ({
  45. isRateLimited: jest.fn(() => ({ limited: false })),
  46. recordAttempt: jest.fn(),
  47. clearAttempts: jest.fn(),
  48. })),
  49. },
  50. retryPayment: jest.fn(),
  51. }))
  52. // 创建测试QueryClient
  53. const createTestQueryClient = () => new QueryClient({
  54. defaultOptions: {
  55. queries: { retry: false },
  56. mutations: { retry: false },
  57. },
  58. })
  59. // 测试包装器
  60. const TestWrapper = ({ children }: { children: React.ReactNode }) => (
  61. <QueryClientProvider client={createTestQueryClient()}>
  62. {children}
  63. </QueryClientProvider>
  64. )
  65. // 测试数据工厂
  66. const createTestCreditBalance = (overrides = {}) => ({
  67. totalLimit: 1000,
  68. usedAmount: 200,
  69. availableAmount: 800,
  70. isEnabled: true,
  71. ...overrides,
  72. })
  73. const createTestPaymentData = () => ({
  74. timeStamp: '1234567890',
  75. nonceStr: 'test-nonce',
  76. package: 'prepay_id=test_prepay_id',
  77. signType: 'MD5',
  78. paySign: 'test-sign',
  79. })
  80. describe('额度支付流程集成测试', () => {
  81. beforeEach(() => {
  82. jest.clearAllMocks()
  83. jest.useFakeTimers()
  84. // 设置默认路由参数
  85. mockUseRouter.mockReturnValue({
  86. params: {
  87. orderId: '123',
  88. amount: '100',
  89. orderNo: 'ORD123456',
  90. },
  91. })
  92. })
  93. afterEach(() => {
  94. jest.useRealTimers()
  95. })
  96. test('完整额度支付流程:从选择到支付成功', async () => {
  97. // Mock 额度查询返回正常数据
  98. const initialBalance = createTestCreditBalance({ availableAmount: 800 })
  99. ;(creditBalanceClient.me.$get as jest.Mock).mockResolvedValue({
  100. status: 200,
  101. json: () => Promise.resolve(initialBalance),
  102. })
  103. // Mock 额度支付成功
  104. const updatedBalance = createTestCreditBalance({
  105. usedAmount: 300, // 原200 + 支付100 = 300
  106. availableAmount: 700 // 原800 - 支付100 = 700
  107. })
  108. ;(creditBalanceClient.payment.$post as jest.Mock).mockResolvedValue({
  109. status: 200,
  110. json: () => Promise.resolve(updatedBalance),
  111. })
  112. render(
  113. <TestWrapper>
  114. <PaymentPage />
  115. </TestWrapper>
  116. )
  117. // 1. 验证页面加载和额度显示
  118. await waitFor(() => {
  119. expect(screen.getByTestId('payment-page-title')).toBeInTheDocument()
  120. expect(screen.getByTestId('available-amount-text')).toHaveTextContent(/使用信用额度支付/)
  121. })
  122. // 2. 选择额度支付方式
  123. const creditOption = screen.getByTestId('credit-payment-option')
  124. fireEvent.click(creditOption)
  125. await waitFor(() => {
  126. expect(creditOption).toHaveClass('border-blue-500')
  127. expect(screen.getByTestId('pay-button')).toHaveTextContent('额度支付 ¥100.00')
  128. })
  129. // 3. 验证额度详情显示
  130. const creditDetails = screen.getByTestId('credit-payment-details')
  131. expect(creditDetails).toHaveTextContent(/使用信用额度支付,无需立即付款/)
  132. // 不应该包含额度详情
  133. expect(creditDetails).not.toHaveTextContent(/可用额度:/)
  134. expect(creditDetails).not.toHaveTextContent(/总额度:/)
  135. expect(creditDetails).not.toHaveTextContent(/已用额度:/)
  136. // 4. 点击支付按钮
  137. const payButton = screen.getByTestId('pay-button')
  138. fireEvent.click(payButton)
  139. // 5. 验证支付处理中状态
  140. await waitFor(() => {
  141. expect(screen.getByText('支付中...')).toBeInTheDocument()
  142. })
  143. // 6. 验证调用了额度支付API
  144. await waitFor(() => {
  145. expect(creditBalanceClient.payment.$post).toHaveBeenCalledWith({
  146. json: {
  147. referenceId: '123', // 传递订单ID而不是订单号
  148. remark: '订单支付 - ORD123456',
  149. },
  150. })
  151. })
  152. // 7. 验证支付成功状态
  153. await waitFor(() => {
  154. // 使用类名选择器找到支付成功状态文本
  155. expect(screen.getByText('支付成功', { selector: 'span.text-xl' })).toBeInTheDocument()
  156. })
  157. // 8. 推进时间以触发跳转
  158. jest.advanceTimersByTime(1600)
  159. // 9. 验证跳转到成功页面
  160. await waitFor(() => {
  161. expect(mockRedirectTo).toHaveBeenCalledWith({
  162. url: '/pages/payment-success/index?orderId=123&amount=100&paymentMethod=credit',
  163. })
  164. })
  165. })
  166. test('额度支付失败流程:显示错误并可以重试', async () => {
  167. // Mock 额度查询返回正常数据
  168. const initialBalance = createTestCreditBalance({ availableAmount: 800 })
  169. ;(creditBalanceClient.me.$get as jest.Mock).mockResolvedValue({
  170. status: 200,
  171. json: () => Promise.resolve(initialBalance),
  172. })
  173. // Mock 额度支付第一次失败,第二次成功
  174. let paymentCallCount = 0
  175. ;(creditBalanceClient.payment.$post as jest.Mock).mockImplementation(() => {
  176. paymentCallCount++
  177. if (paymentCallCount === 1) {
  178. return Promise.resolve({
  179. status: 400,
  180. json: () => Promise.resolve({ message: '额度支付失败,请重试' }),
  181. })
  182. } else {
  183. return Promise.resolve({
  184. status: 200,
  185. json: () => Promise.resolve(createTestCreditBalance({ usedAmount: 300, availableAmount: 700 })),
  186. })
  187. }
  188. })
  189. render(
  190. <TestWrapper>
  191. <PaymentPage />
  192. </TestWrapper>
  193. )
  194. // 等待页面加载
  195. await waitFor(() => {
  196. expect(screen.getByTestId('available-amount-text')).toHaveTextContent(/使用信用额度支付/)
  197. })
  198. // 选择额度支付
  199. const creditOption = screen.getByTestId('credit-payment-option')
  200. fireEvent.click(creditOption)
  201. // 点击支付按钮(第一次失败)
  202. const payButton = screen.getByTestId('pay-button')
  203. fireEvent.click(payButton)
  204. // 验证显示错误信息
  205. await waitFor(() => {
  206. expect(screen.getByText('额度支付失败,请重试')).toBeInTheDocument()
  207. expect(screen.getByText('重试支付')).toBeInTheDocument()
  208. })
  209. // 点击重试按钮
  210. const retryButton = screen.getByText('重试支付')
  211. fireEvent.click(retryButton)
  212. // 验证第二次支付成功
  213. await waitFor(() => {
  214. // 使用更精确的选择器,避免多个"支付成功"元素
  215. expect(screen.getByText('支付成功', { selector: 'span.text-xl' })).toBeInTheDocument()
  216. })
  217. // 推进时间以触发跳转
  218. jest.advanceTimersByTime(1600)
  219. await waitFor(() => {
  220. expect(mockRedirectTo).toHaveBeenCalledWith({
  221. url: '/pages/payment-success/index?orderId=123&amount=100&paymentMethod=credit',
  222. })
  223. })
  224. })
  225. test('额度不足时的支付流程', async () => {
  226. // Mock 额度查询返回额度不足的数据
  227. const initialBalance = createTestCreditBalance({
  228. totalLimit: 50,
  229. usedAmount: 45,
  230. availableAmount: 5 // 可用额度5元,支付金额100元
  231. })
  232. ;(creditBalanceClient.me.$get as jest.Mock).mockResolvedValue({
  233. status: 200,
  234. json: () => Promise.resolve(initialBalance),
  235. })
  236. render(
  237. <TestWrapper>
  238. <PaymentPage />
  239. </TestWrapper>
  240. )
  241. // 等待页面加载
  242. await waitFor(() => {
  243. // 额度不足时,额度支付选项不应该显示
  244. expect(screen.queryByTestId('credit-payment-option')).not.toBeInTheDocument()
  245. })
  246. // 验证支付按钮没有被禁用(因为默认选择微信支付)
  247. const payButton = screen.getByTestId('pay-button')
  248. expect(payButton).not.toBeDisabled()
  249. })
  250. test('额度为0时的支付流程', async () => {
  251. // Mock 额度查询返回额度为0的数据
  252. const initialBalance = createTestCreditBalance({
  253. totalLimit: 0,
  254. usedAmount: 0,
  255. availableAmount: 0,
  256. isEnabled: false,
  257. })
  258. ;(creditBalanceClient.me.$get as jest.Mock).mockResolvedValue({
  259. status: 200,
  260. json: () => Promise.resolve(initialBalance),
  261. })
  262. render(
  263. <TestWrapper>
  264. <PaymentPage />
  265. </TestWrapper>
  266. )
  267. // 等待页面加载
  268. await waitFor(() => {
  269. expect(screen.getByTestId('payment-page-title')).toBeInTheDocument()
  270. })
  271. // 验证额度支付选项不显示(因为额度为0且未启用)
  272. expect(screen.queryByTestId('credit-payment-option')).not.toBeInTheDocument()
  273. // 验证支付按钮没有被禁用(因为默认选择微信支付)
  274. const payButton = screen.getByTestId('pay-button')
  275. expect(payButton).not.toBeDisabled()
  276. })
  277. test('额度支付与微信支付切换流程', async () => {
  278. // Mock 额度查询返回正常数据
  279. const initialBalance = createTestCreditBalance({ availableAmount: 800 })
  280. ;(creditBalanceClient.me.$get as jest.Mock).mockResolvedValue({
  281. status: 200,
  282. json: () => Promise.resolve(initialBalance),
  283. })
  284. // Mock 微信支付参数
  285. const mockPaymentData = createTestPaymentData()
  286. ;(paymentClient.payment.$post as jest.Mock).mockResolvedValue({
  287. status: 200,
  288. json: () => Promise.resolve(mockPaymentData),
  289. })
  290. render(
  291. <TestWrapper>
  292. <PaymentPage />
  293. </TestWrapper>
  294. )
  295. // 等待页面加载
  296. await waitFor(() => {
  297. expect(screen.getByTestId('available-amount-text')).toHaveTextContent(/使用信用额度支付/)
  298. })
  299. // 初始为微信支付选中
  300. const wechatOption = screen.getByTestId('wechat-payment-option')
  301. expect(wechatOption).toHaveClass('border-blue-500')
  302. expect(screen.getByText('微信支付 ¥100.00')).toBeInTheDocument()
  303. // 切换到额度支付
  304. const creditOption = screen.getByTestId('credit-payment-option')
  305. fireEvent.click(creditOption)
  306. await waitFor(() => {
  307. expect(creditOption).toHaveClass('border-blue-500')
  308. expect(screen.getByTestId('pay-button')).toHaveTextContent('额度支付 ¥100.00')
  309. })
  310. // 验证额度详情显示
  311. expect(screen.getByText('• 使用信用额度支付,无需立即付款')).toBeInTheDocument()
  312. // 切换回微信支付
  313. fireEvent.click(wechatOption)
  314. await waitFor(() => {
  315. expect(wechatOption).toHaveClass('border-blue-500')
  316. expect(screen.getByTestId('pay-button')).toHaveTextContent('微信支付 ¥100.00')
  317. })
  318. // 验证额度详情隐藏
  319. expect(screen.queryByText('• 使用信用额度支付,无需立即付款')).not.toBeInTheDocument()
  320. })
  321. test('网络异常时的降级处理', async () => {
  322. // Mock 额度查询网络异常
  323. ;(creditBalanceClient.me.$get as jest.Mock).mockRejectedValue(new Error('网络连接失败'))
  324. render(
  325. <TestWrapper>
  326. <PaymentPage />
  327. </TestWrapper>
  328. )
  329. // 等待页面加载(即使额度查询失败,页面也应该显示)
  330. await waitFor(() => {
  331. // 使用data-testid查询支付页面标题
  332. expect(screen.getByTestId('payment-page-title')).toBeInTheDocument()
  333. })
  334. // 验证额度支付选项不显示(因为查询失败)
  335. // 当额度查询失败时,额度支付选项不应该显示
  336. expect(screen.queryByTestId('credit-payment-option')).not.toBeInTheDocument()
  337. // 验证支付按钮没有被禁用(因为默认选择微信支付,额度查询失败不影响微信支付)
  338. const payButton = screen.getByTestId('pay-button')
  339. expect(payButton).not.toBeDisabled()
  340. })
  341. test('支付过程中的取消操作', async () => {
  342. // Mock 额度查询返回正常数据
  343. const initialBalance = createTestCreditBalance({ availableAmount: 800 })
  344. ;(creditBalanceClient.me.$get as jest.Mock).mockResolvedValue({
  345. status: 200,
  346. json: () => Promise.resolve(initialBalance),
  347. })
  348. // Mock 额度支付延迟(模拟用户取消)
  349. let resolvePayment: any
  350. const paymentPromise = new Promise((resolve) => {
  351. resolvePayment = resolve
  352. })
  353. ;(creditBalanceClient.payment.$post as jest.Mock).mockReturnValue(paymentPromise)
  354. render(
  355. <TestWrapper>
  356. <PaymentPage />
  357. </TestWrapper>
  358. )
  359. // 等待页面加载
  360. await waitFor(() => {
  361. expect(screen.getByTestId('available-amount-text')).toHaveTextContent(/使用信用额度支付/)
  362. })
  363. // 选择额度支付
  364. const creditOption = screen.getByTestId('credit-payment-option')
  365. fireEvent.click(creditOption)
  366. // 点击支付按钮
  367. const payButton = screen.getByTestId('pay-button')
  368. fireEvent.click(payButton)
  369. // 验证支付处理中状态
  370. await waitFor(() => {
  371. expect(screen.getByText('支付中...')).toBeInTheDocument()
  372. })
  373. // 此时页面应该显示支付处理中,用户无法进行其他操作
  374. await waitFor(() => {
  375. // 重新获取按钮元素(文本已变为"支付处理中...")
  376. const processingButton = screen.getByText('支付处理中...')
  377. // 检查按钮是否被禁用
  378. expect(processingButton).toBeDisabled()
  379. })
  380. // 模拟支付完成(超时或其他原因)
  381. resolvePayment({
  382. status: 200,
  383. json: () => Promise.resolve(createTestCreditBalance({ usedAmount: 300, availableAmount: 700 })),
  384. })
  385. // 验证支付成功
  386. await waitFor(() => {
  387. // 使用更具体的查询,避免多个"支付成功"元素
  388. expect(screen.getByText('支付成功', { selector: 'span.text-xl' })).toBeInTheDocument()
  389. })
  390. // 推进时间以触发跳转
  391. jest.advanceTimersByTime(1600)
  392. })
  393. })