import { beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, shallowMount } from '@vue/test-utils' import PaymentView from '../PaymentView.vue' import { PAYMENT_RECOVERY_STORAGE_KEY } from '@/components/payment/paymentFlow' const routeState = vi.hoisted(() => ({ path: '/purchase', query: {} as Record, })) const routerReplace = vi.hoisted(() => vi.fn()) const routerPush = vi.hoisted(() => vi.fn()) const routerResolve = vi.hoisted(() => vi.fn(() => ({ href: '/payment/stripe?mock=1' }))) const createOrder = vi.hoisted(() => vi.fn()) const refreshUser = vi.hoisted(() => vi.fn()) const fetchActiveSubscriptions = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)) const showError = vi.hoisted(() => vi.fn()) const showInfo = vi.hoisted(() => vi.fn()) const getCheckoutInfo = vi.hoisted(() => vi.fn()) const bridgeInvoke = vi.hoisted(() => vi.fn()) vi.mock('vue-router', async () => { const actual = await vi.importActual('vue-router') return { ...actual, useRoute: () => routeState, useRouter: () => ({ replace: routerReplace, push: routerPush, resolve: routerResolve, }), } }) vi.mock('vue-i18n', async () => { const actual = await vi.importActual('vue-i18n') return { ...actual, useI18n: () => ({ t: (key: string) => key, }), } }) vi.mock('@/stores/auth', () => ({ useAuthStore: () => ({ user: { username: 'demo-user', balance: 0, }, refreshUser, }), })) vi.mock('@/stores/payment', () => ({ usePaymentStore: () => ({ createOrder, }), })) vi.mock('@/stores/subscriptions', () => ({ useSubscriptionStore: () => ({ activeSubscriptions: [], fetchActiveSubscriptions, }), })) vi.mock('@/stores', () => ({ useAppStore: () => ({ showError, showInfo, }), })) vi.mock('@/api/payment', () => ({ paymentAPI: { getCheckoutInfo, }, })) vi.mock('@/utils/device', () => ({ isMobileDevice: () => true, })) function checkoutInfoFixture() { return { data: { methods: { wxpay: { daily_limit: 0, daily_used: 0, daily_remaining: 0, single_min: 0, single_max: 0, fee_rate: 0, available: true, }, }, global_min: 0, global_max: 0, plans: [], balance_disabled: false, balance_recharge_multiplier: 1, recharge_fee_rate: 0, help_text: '', help_image_url: '', stripe_publishable_key: '', }, } } function jsapiOrderFixture(resumeToken: string) { return { order_id: 123, amount: 88, pay_amount: 88, fee_rate: 0, expires_at: '2099-01-01T00:10:00.000Z', payment_type: 'wxpay', out_trade_no: 'sub2_jsapi_123', result_type: 'jsapi_ready' as const, resume_token: resumeToken, jsapi: { appId: 'wx123', timeStamp: '1712345678', nonceStr: 'nonce', package: 'prepay_id=wx123', signType: 'RSA', paySign: 'signed', }, } } describe('PaymentView WeChat JSAPI flow', () => { beforeEach(() => { routeState.path = '/purchase' routeState.query = { wechat_resume: '1', wechat_resume_token: 'resume-token-123', } routerReplace.mockReset().mockResolvedValue(undefined) routerPush.mockReset().mockResolvedValue(undefined) routerResolve.mockClear() createOrder.mockReset() refreshUser.mockReset() fetchActiveSubscriptions.mockReset().mockResolvedValue(undefined) showError.mockReset() showInfo.mockReset() getCheckoutInfo.mockReset().mockResolvedValue(checkoutInfoFixture()) bridgeInvoke.mockReset() window.localStorage.clear() ;(window as Window & { WeixinJSBridge?: { invoke: typeof bridgeInvoke } }).WeixinJSBridge = { invoke: bridgeInvoke, } }) it('resets payment state and redirects to /payment/result after JSAPI reports success', async () => { createOrder.mockResolvedValue(jsapiOrderFixture('resume-token-123')) bridgeInvoke.mockImplementation((_action, _payload, callback) => { callback({ err_msg: 'get_brand_wcpay_request:ok' }) }) shallowMount(PaymentView, { global: { stubs: { Teleport: true, Transition: false, }, }, }) await flushPromises() await flushPromises() expect(routerReplace).toHaveBeenCalledWith({ path: '/purchase', query: {} }) expect(routerPush).toHaveBeenCalledWith({ path: '/payment/result', query: { order_id: '123', out_trade_no: 'sub2_jsapi_123', resume_token: 'resume-token-123', }, }) expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull() }) it('resets payment state when JSAPI reports cancellation', async () => { createOrder.mockResolvedValue(jsapiOrderFixture('resume-token-cancel')) bridgeInvoke.mockImplementation((_action, _payload, callback) => { callback({ err_msg: 'get_brand_wcpay_request:cancel' }) }) shallowMount(PaymentView, { global: { stubs: { Teleport: true, Transition: false, }, }, }) await flushPromises() await flushPromises() expect(showInfo).toHaveBeenCalledWith('payment.qr.cancelled') expect(routerPush).not.toHaveBeenCalled() expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull() }) it('clears a stale recovery snapshot before handling wechat resume callback params', async () => { createOrder.mockRejectedValueOnce(new Error('resume failed')) window.localStorage.setItem(PAYMENT_RECOVERY_STORAGE_KEY, JSON.stringify({ orderId: 999, amount: 66, qrCode: 'stale-qr', expiresAt: '2099-01-01T00:10:00.000Z', paymentType: 'alipay', payUrl: 'https://pay.example.com/stale', outTradeNo: 'stale-out-trade-no', clientSecret: '', payAmount: 66, orderType: 'balance', paymentMode: 'popup', resumeToken: '', createdAt: Date.UTC(2099, 0, 1, 0, 0, 0), })) shallowMount(PaymentView, { global: { stubs: { Teleport: true, Transition: false, }, }, }) await flushPromises() await flushPromises() expect(createOrder).toHaveBeenCalledWith(expect.objectContaining({ wechat_resume_token: 'resume-token-123', })) expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull() }) })