391 lines
10 KiB
TypeScript
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',
|
|
)
|
|
})
|
|
})
|