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 checkoutInfoWithPlansFixture() { return { data: { ...checkoutInfoFixture().data, plans: [ { id: 7, group_id: 3, name: 'Starter', description: '', price: 128, original_price: 0, validity_days: 30, validity_unit: 'day', rate_multiplier: 1, daily_limit_usd: null, weekly_limit_usd: null, monthly_limit_usd: null, features: [], group_platform: 'openai', sort_order: 1, for_sale: true, group_name: 'OpenAI', }, ], }, } } 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', }, } } function oauthOrderFixture() { return { order_id: 456, amount: 128, pay_amount: 128, fee_rate: 0, expires_at: '2099-01-01T00:10:00.000Z', payment_type: 'wxpay', result_type: 'oauth_required' as const, oauth: { authorize_url: '/api/v1/auth/oauth/wechat/payment/start?payment_type=wxpay&redirect=%2Fpurchase%3Ffrom%3Dwechat', appid: 'wx123', scope: 'snsapi_base', redirect_url: '/auth/wechat/payment/callback', }, } } 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() }) it('keeps subscription resume context for token-only WeChat callbacks', async () => { routeState.query = { wechat_resume: '1', wechat_resume_token: 'resume-subscription-7', payment_type: 'wxpay_direct', order_type: 'subscription', plan_id: '7', } getCheckoutInfo.mockResolvedValue(checkoutInfoWithPlansFixture()) createOrder.mockResolvedValue(oauthOrderFixture()) const originalLocation = window.location const locationState = { href: 'http://localhost/purchase', origin: 'http://localhost', } Object.defineProperty(window, 'location', { configurable: true, value: locationState, }) shallowMount(PaymentView, { global: { stubs: { Teleport: true, Transition: false, }, }, }) await flushPromises() await flushPromises() expect(routerReplace).toHaveBeenCalledWith({ path: '/purchase', query: {} }) expect(createOrder).toHaveBeenCalledWith(expect.objectContaining({ payment_type: 'wxpay', order_type: 'subscription', plan_id: 7, wechat_resume_token: 'resume-subscription-7', })) expect(locationState.href).toContain('/api/v1/auth/oauth/wechat/payment/start?') expect(new URL(locationState.href, 'http://localhost').searchParams.get('redirect')).toBe( '/purchase?from=wechat&payment_type=wxpay&order_type=subscription&plan_id=7', ) Object.defineProperty(window, 'location', { configurable: true, value: originalLocation, }) }) it('shows explicit H5 authorization guidance instead of failing silently', async () => { routeState.query = { wechat_resume: '1', wechat_resume_token: 'resume-token-h5', payment_type: 'wxpay_direct', } createOrder.mockRejectedValueOnce({ reason: 'WECHAT_H5_NOT_AUTHORIZED' }) shallowMount(PaymentView, { global: { stubs: { Teleport: true, Transition: false, }, }, }) await flushPromises() await flushPromises() expect(showError).toHaveBeenCalledWith( 'payment.errors.wechatH5NotAuthorized payment.errors.wechatOpenInWeChatHint', ) }) })