Files
sub2api/frontend/src/views/user/__tests__/PaymentView.spec.ts
2026-04-22 14:57:16 +08:00

391 lines
10 KiB
TypeScript

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<string, unknown>,
}))
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<typeof import('vue-router')>('vue-router')
return {
...actual,
useRoute: () => routeState,
useRouter: () => ({
replace: routerReplace,
push: routerPush,
resolve: routerResolve,
}),
}
})
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('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 stale recovery state when JSAPI never becomes available', async () => {
vi.useFakeTimers()
createOrder.mockResolvedValue(jsapiOrderFixture('resume-token-missing-bridge'))
;(window as Window & { WeixinJSBridge?: { invoke: typeof bridgeInvoke } }).WeixinJSBridge = undefined
const wrapper = shallowMount(PaymentView, {
global: {
stubs: {
Teleport: true,
Transition: false,
},
},
})
await flushPromises()
await vi.advanceTimersByTimeAsync(4000)
await flushPromises()
await flushPromises()
expect(showError).toHaveBeenCalledWith(
'payment.errors.wechatJsapiUnavailable payment.errors.wechatOpenInWeChatHint',
)
expect(routerPush).not.toHaveBeenCalled()
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull()
expect(wrapper.html()).not.toContain('payment-status-panel-stub')
})
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',
)
})
})