fix(review): harden payment, oauth, and migration paths
This commit is contained in:
205
frontend/src/views/user/__tests__/PaymentView.spec.ts
Normal file
205
frontend/src/views/user/__tests__/PaymentView.spec.ts
Normal file
@ -0,0 +1,205 @@
|
||||
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 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',
|
||||
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',
|
||||
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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user