import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' const routeState = vi.hoisted(() => ({ query: {} as Record, })) const routerPush = vi.hoisted(() => vi.fn()) const pollOrderStatus = vi.hoisted(() => vi.fn()) const verifyOrderPublic = vi.hoisted(() => vi.fn()) const verifyOrder = vi.hoisted(() => vi.fn()) const resolveOrderPublicByResumeToken = vi.hoisted(() => vi.fn()) vi.mock('vue-router', async () => { const actual = await vi.importActual('vue-router') return { ...actual, useRoute: () => routeState, useRouter: () => ({ push: routerPush }), } }) vi.mock('vue-i18n', async () => { const actual = await vi.importActual('vue-i18n') return { ...actual, useI18n: () => ({ t: (key: string) => key, }), } }) vi.mock('@/stores/payment', () => ({ usePaymentStore: () => ({ pollOrderStatus, }), })) vi.mock('@/api/payment', () => ({ paymentAPI: { verifyOrderPublic, verifyOrder, resolveOrderPublicByResumeToken, }, })) import PaymentResultView from '../PaymentResultView.vue' import { PAYMENT_RECOVERY_STORAGE_KEY } from '@/components/payment/paymentFlow' const orderFactory = (status: string) => ({ id: 42, user_id: 9, amount: 88, pay_amount: 88, fee_rate: 0, payment_type: 'alipay', out_trade_no: 'sub2_20260420abcd1234', status, order_type: 'balance', created_at: '2026-04-20T12:00:00Z', expires_at: '2026-04-20T12:30:00Z', refund_amount: 0, }) describe('PaymentResultView', () => { beforeEach(() => { routeState.query = {} routerPush.mockReset() pollOrderStatus.mockReset() verifyOrderPublic.mockReset() verifyOrder.mockReset() resolveOrderPublicByResumeToken.mockReset() window.localStorage.clear() }) afterEach(() => { vi.useRealTimers() }) it('renders a pending state instead of a failure state when the restored order is still pending', async () => { routeState.query = { resume_token: 'resume-42', order_id: '999', status: 'success', } window.localStorage.setItem(PAYMENT_RECOVERY_STORAGE_KEY, JSON.stringify({ orderId: 42, amount: 88, qrCode: '', expiresAt: '2099-01-01T00:10:00.000Z', paymentType: 'alipay', payUrl: 'https://pay.example.com/session/42', clientSecret: '', payAmount: 88, orderType: 'balance', paymentMode: 'redirect', resumeToken: 'resume-42', createdAt: Date.UTC(2099, 0, 1, 0, 0, 0), })) pollOrderStatus.mockResolvedValue(orderFactory('PENDING')) const wrapper = mount(PaymentResultView, { global: { stubs: { OrderStatusBadge: true, }, }, }) await flushPromises() expect(pollOrderStatus).toHaveBeenCalledWith(42) expect(verifyOrderPublic).not.toHaveBeenCalled() expect(wrapper.text()).toContain('payment.result.processing') expect(wrapper.text()).not.toContain('payment.result.success') expect(wrapper.text()).not.toContain('payment.result.failed') }) it('refreshes a pending resume-token result until the order becomes paid', async () => { vi.useFakeTimers() routeState.query = { resume_token: 'resume-77', } resolveOrderPublicByResumeToken .mockResolvedValueOnce({ data: orderFactory('PENDING'), }) .mockResolvedValueOnce({ data: orderFactory('PAID'), }) const wrapper = mount(PaymentResultView, { global: { stubs: { OrderStatusBadge: true, }, }, }) await flushPromises() expect(resolveOrderPublicByResumeToken).toHaveBeenCalledTimes(1) expect(wrapper.text()).toContain('payment.result.processing') await vi.advanceTimersByTimeAsync(2000) await flushPromises() expect(resolveOrderPublicByResumeToken).toHaveBeenCalledTimes(2) expect(wrapper.text()).toContain('payment.result.success') expect(wrapper.text()).not.toContain('payment.result.failed') }) it('does not fall back to public out_trade_no verification when resume_token recovery fails', async () => { routeState.query = { resume_token: 'resume-fail', out_trade_no: 'legacy-should-not-run', trade_status: 'TRADE_SUCCESS', } resolveOrderPublicByResumeToken.mockRejectedValueOnce(new Error('resume failed')) mount(PaymentResultView, { global: { stubs: { OrderStatusBadge: true, }, }, }) await flushPromises() expect(resolveOrderPublicByResumeToken).toHaveBeenCalledWith('resume-fail') expect(verifyOrderPublic).not.toHaveBeenCalled() expect(verifyOrder).not.toHaveBeenCalled() }) it('keeps legacy out_trade_no verification as a fallback when no order context is available', async () => { routeState.query = { out_trade_no: 'legacy-123', trade_status: 'TRADE_SUCCESS', } verifyOrderPublic.mockResolvedValue({ data: orderFactory('PAID'), }) const wrapper = mount(PaymentResultView, { global: { stubs: { OrderStatusBadge: true, }, }, }) await flushPromises() expect(verifyOrderPublic).toHaveBeenCalledWith('legacy-123') expect(wrapper.text()).toContain('payment.result.success') }) it('does not use public out_trade_no verification for bare order numbers without legacy return markers', async () => { routeState.query = { out_trade_no: 'legacy-bare', } mount(PaymentResultView, { global: { stubs: { OrderStatusBadge: true, }, }, }) await flushPromises() expect(verifyOrderPublic).not.toHaveBeenCalled() expect(verifyOrder).not.toHaveBeenCalled() }) it('resolves order by resume token when local recovery snapshot is missing', async () => { routeState.query = { resume_token: 'resume-77', } resolveOrderPublicByResumeToken.mockResolvedValue({ data: orderFactory('PAID'), }) const wrapper = mount(PaymentResultView, { global: { stubs: { OrderStatusBadge: true, }, }, }) await flushPromises() expect(resolveOrderPublicByResumeToken).toHaveBeenCalledWith('resume-77') expect(wrapper.text()).toContain('payment.result.success') expect(verifyOrderPublic).not.toHaveBeenCalled() }) it('normalizes aliased payment methods before rendering the label', async () => { routeState.query = { resume_token: 'resume-88', } resolveOrderPublicByResumeToken.mockResolvedValueOnce({ data: { ...orderFactory('PAID'), payment_type: 'alipay_direct', }, }) const wrapper = mount(PaymentResultView, { global: { stubs: { OrderStatusBadge: true, }, }, }) await flushPromises() expect(wrapper.text()).toContain('payment.methods.alipay') expect(wrapper.text()).not.toContain('payment.methods.alipay_direct') }) })