credit-payment-flow.test.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  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. // 设置默认路由参数
  84. mockUseRouter.mockReturnValue({
  85. params: {
  86. orderId: '123',
  87. amount: '100',
  88. orderNo: 'ORD123456',
  89. },
  90. })
  91. })
  92. test('完整额度支付流程:从选择到支付成功', async () => {
  93. // Mock 额度查询返回正常数据
  94. const initialBalance = createTestCreditBalance({ availableAmount: 800 })
  95. ;(creditBalanceClient.me.$get as jest.Mock).mockResolvedValue({
  96. status: 200,
  97. json: () => Promise.resolve(initialBalance),
  98. })
  99. // Mock 额度支付成功
  100. const updatedBalance = createTestCreditBalance({
  101. usedAmount: 300, // 原200 + 支付100 = 300
  102. availableAmount: 700 // 原800 - 支付100 = 700
  103. })
  104. ;(creditBalanceClient.payment.$post as jest.Mock).mockResolvedValue({
  105. status: 200,
  106. json: () => Promise.resolve(updatedBalance),
  107. })
  108. render(
  109. <TestWrapper>
  110. <PaymentPage />
  111. </TestWrapper>
  112. )
  113. // 1. 验证页面加载和额度显示
  114. await waitFor(() => {
  115. expect(screen.getByTestId('payment-page-title')).toBeInTheDocument()
  116. expect(screen.getByTestId('available-amount-text')).toHaveTextContent(/可用额度: ¥800\.00/)
  117. })
  118. // 2. 选择额度支付方式
  119. const creditOption = screen.getByText('额度支付').closest('[class*="border-gray-200"]')
  120. fireEvent.click(creditOption!)
  121. await waitFor(() => {
  122. expect(creditOption).toHaveClass('border-blue-500')
  123. expect(screen.getByTestId('pay-button')).toHaveTextContent('额度支付 ¥100.00')
  124. })
  125. // 3. 验证额度详情显示
  126. const creditDetails = screen.getByTestId('credit-payment-details')
  127. expect(creditDetails).toHaveTextContent(/使用信用额度支付,无需立即付款/)
  128. expect(creditDetails).toHaveTextContent(/可用额度: ¥800\.00/)
  129. expect(creditDetails).toHaveTextContent(/总额度: ¥1000\.00/)
  130. expect(creditDetails).toHaveTextContent(/已用额度: ¥200\.00/)
  131. // 4. 点击支付按钮
  132. const payButton = screen.getByTestId('pay-button')
  133. fireEvent.click(payButton)
  134. // 5. 验证支付处理中状态
  135. await waitFor(() => {
  136. expect(screen.getByText('支付中...')).toBeInTheDocument()
  137. })
  138. // 6. 验证调用了额度支付API
  139. await waitFor(() => {
  140. expect(creditBalanceClient.payment.$post).toHaveBeenCalledWith({
  141. json: {
  142. amount: 100,
  143. referenceId: 'ORD123456',
  144. remark: '订单支付 - ORD123456',
  145. },
  146. })
  147. })
  148. // 7. 验证支付成功状态
  149. await waitFor(() => {
  150. // 使用类名选择器找到支付成功状态文本
  151. expect(screen.getByText('支付成功', { selector: 'span.text-xl' })).toBeInTheDocument()
  152. })
  153. // 8. 验证跳转到成功页面
  154. await waitFor(() => {
  155. expect(mockRedirectTo).toHaveBeenCalledWith({
  156. url: '/pages/payment-success/index?orderId=123&amount=100&paymentMethod=credit',
  157. })
  158. })
  159. })
  160. test('额度支付失败流程:显示错误并可以重试', async () => {
  161. // Mock 额度查询返回正常数据
  162. const initialBalance = createTestCreditBalance({ availableAmount: 800 })
  163. ;(creditBalanceClient.me.$get as jest.Mock).mockResolvedValue({
  164. status: 200,
  165. json: () => Promise.resolve(initialBalance),
  166. })
  167. // Mock 额度支付第一次失败,第二次成功
  168. let paymentCallCount = 0
  169. ;(creditBalanceClient.payment.$post as jest.Mock).mockImplementation(() => {
  170. paymentCallCount++
  171. if (paymentCallCount === 1) {
  172. return Promise.resolve({
  173. status: 400,
  174. json: () => Promise.resolve({ message: '额度支付失败,请重试' }),
  175. })
  176. } else {
  177. return Promise.resolve({
  178. status: 200,
  179. json: () => Promise.resolve(createTestCreditBalance({ usedAmount: 300, availableAmount: 700 })),
  180. })
  181. }
  182. })
  183. render(
  184. <TestWrapper>
  185. <PaymentPage />
  186. </TestWrapper>
  187. )
  188. // 等待页面加载
  189. await waitFor(() => {
  190. expect(screen.getByTestId('available-amount-text')).toHaveTextContent(/可用额度: ¥800\.00/)
  191. })
  192. // 选择额度支付
  193. const creditOption = screen.getByText('额度支付').closest('[class*="border-gray-200"]')
  194. fireEvent.click(creditOption!)
  195. // 点击支付按钮(第一次失败)
  196. const payButton = screen.getByTestId('pay-button')
  197. fireEvent.click(payButton)
  198. // 验证显示错误信息
  199. await waitFor(() => {
  200. expect(screen.getByText('额度支付失败,请重试')).toBeInTheDocument()
  201. expect(screen.getByText('重试支付')).toBeInTheDocument()
  202. })
  203. // 点击重试按钮
  204. const retryButton = screen.getByText('重试支付')
  205. fireEvent.click(retryButton)
  206. // 验证第二次支付成功
  207. await waitFor(() => {
  208. expect(screen.getByText('支付成功')).toBeInTheDocument()
  209. expect(mockRedirectTo).toHaveBeenCalledWith({
  210. url: '/pages/payment-success/index?orderId=123&amount=100&paymentMethod=credit',
  211. })
  212. })
  213. })
  214. test('额度不足时的支付流程', async () => {
  215. // Mock 额度查询返回额度不足的数据
  216. const initialBalance = createTestCreditBalance({
  217. totalLimit: 50,
  218. usedAmount: 45,
  219. availableAmount: 5 // 可用额度5元,支付金额100元
  220. })
  221. ;(creditBalanceClient.me.$get as jest.Mock).mockResolvedValue({
  222. status: 200,
  223. json: () => Promise.resolve(initialBalance),
  224. })
  225. render(
  226. <TestWrapper>
  227. <PaymentPage />
  228. </TestWrapper>
  229. )
  230. // 等待页面加载
  231. await waitFor(() => {
  232. expect(screen.getByTestId('available-amount-text')).toHaveTextContent(/可用额度: ¥5\.00 \(不足\)/)
  233. })
  234. // 验证额度支付选项被禁用
  235. const creditOption = screen.getByText('额度支付').closest('[class*="border-gray-200"]')
  236. expect(creditOption).toHaveClass('opacity-50')
  237. // 尝试点击额度支付选项(应该不会选中)
  238. fireEvent.click(creditOption!)
  239. expect(creditOption).not.toHaveClass('border-blue-500')
  240. // 验证支付按钮被禁用
  241. const payButton = screen.getByTestId('pay-button')
  242. expect(payButton).toBeDisabled()
  243. })
  244. test('额度为0时的支付流程', async () => {
  245. // Mock 额度查询返回额度为0的数据
  246. const initialBalance = createTestCreditBalance({
  247. totalLimit: 0,
  248. usedAmount: 0,
  249. availableAmount: 0,
  250. isEnabled: false,
  251. })
  252. ;(creditBalanceClient.me.$get as jest.Mock).mockResolvedValue({
  253. status: 200,
  254. json: () => Promise.resolve(initialBalance),
  255. })
  256. render(
  257. <TestWrapper>
  258. <PaymentPage />
  259. </TestWrapper>
  260. )
  261. // 等待页面加载
  262. await waitFor(() => {
  263. expect(screen.getByText('额度未启用')).toBeInTheDocument()
  264. })
  265. // 验证额度支付选项被禁用
  266. const creditOption = screen.getByText('额度支付').closest('[class*="border-gray-200"]')
  267. expect(creditOption).toHaveClass('opacity-50')
  268. // 验证支付按钮被禁用
  269. const payButton = screen.getByTestId('pay-button')
  270. expect(payButton).toBeDisabled()
  271. })
  272. test('额度支付与微信支付切换流程', async () => {
  273. // Mock 额度查询返回正常数据
  274. const initialBalance = createTestCreditBalance({ availableAmount: 800 })
  275. ;(creditBalanceClient.me.$get as jest.Mock).mockResolvedValue({
  276. status: 200,
  277. json: () => Promise.resolve(initialBalance),
  278. })
  279. // Mock 微信支付参数
  280. const mockPaymentData = createTestPaymentData()
  281. ;(paymentClient.payment.$post as jest.Mock).mockResolvedValue({
  282. status: 200,
  283. json: () => Promise.resolve(mockPaymentData),
  284. })
  285. render(
  286. <TestWrapper>
  287. <PaymentPage />
  288. </TestWrapper>
  289. )
  290. // 等待页面加载
  291. await waitFor(() => {
  292. expect(screen.getByTestId('available-amount-text')).toHaveTextContent(/可用额度: ¥800\.00/)
  293. })
  294. // 初始为微信支付选中
  295. const wechatOption = screen.getByText('微信支付').closest('[class*="border-gray-200"]')
  296. expect(wechatOption).toHaveClass('border-blue-500')
  297. expect(screen.getByText('微信支付 ¥100.00')).toBeInTheDocument()
  298. // 切换到额度支付
  299. const creditOption = screen.getByText('额度支付').closest('[class*="border-gray-200"]')
  300. fireEvent.click(creditOption!)
  301. await waitFor(() => {
  302. expect(creditOption).toHaveClass('border-blue-500')
  303. expect(screen.getByTestId('pay-button')).toHaveTextContent('额度支付 ¥100.00')
  304. })
  305. // 验证额度详情显示
  306. expect(screen.getByText('• 使用信用额度支付,无需立即付款')).toBeInTheDocument()
  307. // 切换回微信支付
  308. fireEvent.click(wechatOption!)
  309. await waitFor(() => {
  310. expect(wechatOption).toHaveClass('border-blue-500')
  311. expect(screen.getByTestId('pay-button')).toHaveTextContent('微信支付 ¥100.00')
  312. })
  313. // 验证额度详情隐藏
  314. expect(screen.queryByText('• 使用信用额度支付,无需立即付款')).not.toBeInTheDocument()
  315. })
  316. test('网络异常时的降级处理', async () => {
  317. // Mock 额度查询网络异常
  318. ;(creditBalanceClient.me.$get as jest.Mock).mockRejectedValue(new Error('网络连接失败'))
  319. render(
  320. <TestWrapper>
  321. <PaymentPage />
  322. </TestWrapper>
  323. )
  324. // 等待页面加载(即使额度查询失败,页面也应该显示)
  325. await waitFor(() => {
  326. // 使用data-testid查询支付页面标题
  327. expect(screen.getByTestId('payment-page-title')).toBeInTheDocument()
  328. })
  329. // 验证额度支付选项被禁用(因为查询失败)
  330. const creditOption = screen.getByText('额度支付').closest('[class*="border-gray-200"]')
  331. expect(creditOption).toHaveClass('opacity-50')
  332. // 验证支付按钮被禁用
  333. const payButton = screen.getByTestId('pay-button')
  334. expect(payButton).toBeDisabled()
  335. })
  336. test('支付过程中的取消操作', async () => {
  337. // Mock 额度查询返回正常数据
  338. const initialBalance = createTestCreditBalance({ availableAmount: 800 })
  339. ;(creditBalanceClient.me.$get as jest.Mock).mockResolvedValue({
  340. status: 200,
  341. json: () => Promise.resolve(initialBalance),
  342. })
  343. // Mock 额度支付延迟(模拟用户取消)
  344. let resolvePayment: any
  345. const paymentPromise = new Promise((resolve) => {
  346. resolvePayment = resolve
  347. })
  348. ;(creditBalanceClient.payment.$post as jest.Mock).mockReturnValue(paymentPromise)
  349. render(
  350. <TestWrapper>
  351. <PaymentPage />
  352. </TestWrapper>
  353. )
  354. // 等待页面加载
  355. await waitFor(() => {
  356. expect(screen.getByTestId('available-amount-text')).toHaveTextContent(/可用额度: ¥800\.00/)
  357. })
  358. // 选择额度支付
  359. const creditOption = screen.getByText('额度支付').closest('[class*="border-gray-200"]')
  360. fireEvent.click(creditOption!)
  361. // 点击支付按钮
  362. const payButton = screen.getByTestId('pay-button')
  363. fireEvent.click(payButton)
  364. // 验证支付处理中状态
  365. await waitFor(() => {
  366. expect(screen.getByText('支付中...')).toBeInTheDocument()
  367. })
  368. // 此时页面应该显示支付处理中,用户无法进行其他操作
  369. await waitFor(() => {
  370. // 重新获取按钮元素(文本已变为"支付处理中...")
  371. const processingButton = screen.getByText('支付处理中...')
  372. // 检查按钮是否被禁用
  373. expect(processingButton).toBeDisabled()
  374. })
  375. // 模拟支付完成(超时或其他原因)
  376. resolvePayment({
  377. status: 200,
  378. json: () => Promise.resolve(createTestCreditBalance({ usedAmount: 300, availableAmount: 700 })),
  379. })
  380. // 验证支付成功
  381. await waitFor(() => {
  382. // 使用更具体的查询,避免多个"支付成功"元素
  383. expect(screen.getByText('支付成功', { selector: 'span.text-xl' })).toBeInTheDocument()
  384. })
  385. })
  386. })