Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
c229f33e
Commit
c229f33e
authored
Apr 22, 2026
by
IanShaw027
Browse files
fix(review): harden payment, oauth, and migration paths
parent
7fbd5177
Changes
33
Hide whitespace changes
Inline
Side-by-side
backend/migrations/auth_identity_payment_migrations_regression_test.go
0 → 100644
View file @
c229f33e
package
migrations
import
(
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func
TestMigration112UsesIdempotentAddColumn
(
t
*
testing
.
T
)
{
content
,
err
:=
FS
.
ReadFile
(
"112_add_payment_order_provider_key_snapshot.sql"
)
require
.
NoError
(
t
,
err
)
sql
:=
string
(
content
)
require
.
Contains
(
t
,
sql
,
"ADD COLUMN IF NOT EXISTS provider_key VARCHAR(30)"
)
require
.
NotContains
(
t
,
sql
,
"ADD COLUMN provider_key VARCHAR(30);"
)
}
func
TestMigration118DoesNotForceOverwriteAuthSourceGrantDefaults
(
t
*
testing
.
T
)
{
content
,
err
:=
FS
.
ReadFile
(
"118_wechat_dual_mode_and_auth_source_defaults.sql"
)
require
.
NoError
(
t
,
err
)
sql
:=
string
(
content
)
require
.
NotContains
(
t
,
sql
,
"UPDATE settings"
)
require
.
NotContains
(
t
,
sql
,
"SET value = 'false'"
)
require
.
True
(
t
,
strings
.
Contains
(
sql
,
"ON CONFLICT (key) DO NOTHING"
))
}
func
TestMigration119EnforcesOutTradeNoPartialUniqueIndex
(
t
*
testing
.
T
)
{
content
,
err
:=
FS
.
ReadFile
(
"119_enforce_payment_orders_out_trade_no_unique.sql"
)
require
.
NoError
(
t
,
err
)
sql
:=
string
(
content
)
require
.
Contains
(
t
,
sql
,
"DROP INDEX IF EXISTS paymentorder_out_trade_no"
)
require
.
Contains
(
t
,
sql
,
"CREATE UNIQUE INDEX IF NOT EXISTS paymentorder_out_trade_no"
)
require
.
Contains
(
t
,
sql
,
"WHERE out_trade_no <> ''"
)
}
frontend/src/api/__tests__/auth-oauth-adoption.spec.ts
View file @
c229f33e
...
...
@@ -173,20 +173,12 @@ describe('oauth adoption auth api', () => {
expect
(
hasPendingOAuthSuggestedProfile
({})).
toBe
(
false
)
})
it
(
'
p
re
pares an oauth bind access token
cookie before redirect binding
'
,
async
()
=>
{
it
(
'
re
quests an HttpOnly oauth bind
cookie before redirect binding
'
,
async
()
=>
{
localStorage
.
setItem
(
'
auth_token
'
,
'
access-token-value
'
)
const
setCookie
=
vi
.
fn
()
Object
.
defineProperty
(
document
,
'
cookie
'
,
{
configurable
:
true
,
get
:
()
=>
''
,
set
:
setCookie
})
const
{
prepareOAuthBindAccessTokenCookie
}
=
await
import
(
'
@/api/auth
'
)
prepareOAuthBindAccessTokenCookie
()
await
prepareOAuthBindAccessTokenCookie
()
expect
(
setCookie
).
toHaveBeenCalledTimes
(
1
)
expect
(
setCookie
.
mock
.
calls
[
0
]?.[
0
]).
toContain
(
'
oauth_bind_access_token=access-token-value
'
)
expect
(
post
).
toHaveBeenCalledWith
(
'
/auth/oauth/bind-token
'
)
})
})
frontend/src/api/auth.ts
View file @
c229f33e
...
...
@@ -278,33 +278,11 @@ export function persistOAuthTokenContext(tokens: Partial<OAuthTokenResponse>): v
}
}
export
function
prepareOAuthBindAccessTokenCookie
():
void
{
if
(
typeof
document
===
'
undefined
'
||
typeof
window
===
'
undefined
'
)
{
export
async
function
prepareOAuthBindAccessTokenCookie
():
Promise
<
void
>
{
if
(
!
getAuthToken
()
)
{
return
}
const
token
=
getAuthToken
()
if
(
!
token
)
{
return
}
const
secure
=
window
.
location
.
protocol
===
'
https:
'
?
'
; Secure
'
:
''
const
path
=
resolveOAuthBindCookiePath
()
document
.
cookie
=
`oauth_bind_access_token=
${
encodeURIComponent
(
token
)}
; Path=
${
path
}
/auth/oauth; Max-Age=600; SameSite=Lax
${
secure
}
`
}
function
resolveOAuthBindCookiePath
():
string
{
const
apiBase
=
((
import
.
meta
.
env
.
VITE_API_BASE_URL
as
string
|
undefined
)
||
'
/api/v1
'
).
replace
(
/
\/
$/
,
''
)
try
{
return
new
URL
(
apiBase
,
window
.
location
.
origin
).
pathname
.
replace
(
/
\/
$/
,
''
)
||
'
/api/v1
'
}
catch
{
if
(
apiBase
.
startsWith
(
'
/
'
))
{
return
apiBase
}
return
'
/api/v1
'
}
await
apiClient
.
post
(
'
/auth/oauth/bind-token
'
)
}
/**
...
...
frontend/src/api/user.ts
View file @
c229f33e
...
...
@@ -153,10 +153,10 @@ export function buildOAuthBindingStartURL(
return
`
${
normalized
}
/auth/oauth/
${
provider
}
/start?
${
params
.
toString
()}
`
}
export
function
startOAuthBinding
(
export
async
function
startOAuthBinding
(
provider
:
BindableOAuthProvider
,
options
:
BuildOAuthBindingStartURLOptions
=
{}
):
void
{
):
Promise
<
void
>
{
if
(
typeof
window
===
'
undefined
'
)
{
return
}
...
...
@@ -164,7 +164,7 @@ export function startOAuthBinding(
if
(
!
startURL
)
{
return
}
prepareOAuthBindAccessTokenCookie
()
await
prepareOAuthBindAccessTokenCookie
()
window
.
location
.
href
=
startURL
}
...
...
frontend/src/router/__tests__/guards.spec.ts
View file @
c229f33e
...
...
@@ -83,7 +83,8 @@ function simulateGuard(
'
/auth/callback
'
,
'
/auth/linuxdo/callback
'
,
'
/auth/oidc/callback
'
,
'
/auth/wechat/callback
'
'
/auth/wechat/callback
'
,
'
/auth/wechat/payment/callback
'
,
]
const
pendingAuthPaths
=
[
'
/register
'
,
'
/email-verify
'
]
const
isAllowed
=
...
...
@@ -131,7 +132,8 @@ function simulateGuard(
'
/auth/callback
'
,
'
/auth/linuxdo/callback
'
,
'
/auth/oidc/callback
'
,
'
/auth/wechat/callback
'
'
/auth/wechat/callback
'
,
'
/auth/wechat/payment/callback
'
,
]
const
pendingAuthPaths
=
[
'
/register
'
,
'
/email-verify
'
]
const
isAllowed
=
...
...
@@ -448,6 +450,18 @@ describe('路由守卫逻辑', () => {
expect
(
redirect
).
toBeNull
()
})
it
(
'
unauthenticated: WeChat payment callback route is allowed
'
,
()
=>
{
const
authState
:
MockAuthState
=
{
isAuthenticated
:
false
,
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
hasPendingAuthSession
:
false
,
}
const
redirect
=
simulateGuard
(
'
/auth/wechat/payment/callback
'
,
{
requiresAuth
:
false
},
authState
)
expect
(
redirect
).
toBeNull
()
})
it
(
'
unauthenticated: /register is allowed when a pending auth session exists
'
,
()
=>
{
const
authState
:
MockAuthState
=
{
isAuthenticated
:
false
,
...
...
frontend/src/router/__tests__/wechat-route.spec.ts
View file @
c229f33e
...
...
@@ -52,4 +52,13 @@ describe('router WeChat OAuth route', () => {
expect
(
route
?.
meta
.
requiresAuth
).
toBe
(
false
)
expect
(
route
?.
meta
.
title
).
toBe
(
'
WeChat OAuth Callback
'
)
})
it
(
'
registers the WeChat payment callback route as a public route
'
,
async
()
=>
{
const
{
default
:
router
}
=
await
import
(
'
@/router
'
)
const
route
=
router
.
getRoutes
().
find
((
record
)
=>
record
.
name
===
'
WeChatPaymentOAuthCallback
'
)
expect
(
route
?.
path
).
toBe
(
'
/auth/wechat/payment/callback
'
)
expect
(
route
?.
meta
.
requiresAuth
).
toBe
(
false
)
expect
(
route
?.
meta
.
title
).
toBe
(
'
WeChat Payment Callback
'
)
})
})
frontend/src/router/index.ts
View file @
c229f33e
...
...
@@ -547,7 +547,8 @@ const BACKEND_MODE_CALLBACK_PATHS = [
'
/auth/callback
'
,
'
/auth/linuxdo/callback
'
,
'
/auth/oidc/callback
'
,
'
/auth/wechat/callback
'
'
/auth/wechat/callback
'
,
'
/auth/wechat/payment/callback
'
,
]
const
BACKEND_MODE_PENDING_AUTH_PATHS
=
[
'
/register
'
,
'
/email-verify
'
]
...
...
frontend/src/views/auth/WechatCallbackView.vue
View file @
c229f33e
...
...
@@ -613,7 +613,7 @@ async function handleBindCurrentAccount() {
return
}
prepareOAuthBindAccessTokenCookie
()
await
prepareOAuthBindAccessTokenCookie
()
window
.
location
.
href
=
startURL
}
...
...
frontend/src/views/user/PaymentResultView.vue
View file @
c229f33e
...
...
@@ -101,7 +101,11 @@ import { ref, computed, onBeforeUnmount, onMounted } from 'vue'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useRoute
,
useRouter
}
from
'
vue-router
'
import
OrderStatusBadge
from
'
@/components/payment/OrderStatusBadge.vue
'
import
{
PAYMENT_RECOVERY_STORAGE_KEY
,
readPaymentRecoverySnapshot
}
from
'
@/components/payment/paymentFlow
'
import
{
PAYMENT_RECOVERY_STORAGE_KEY
,
clearPaymentRecoverySnapshot
,
readPaymentRecoverySnapshot
,
}
from
'
@/components/payment/paymentFlow
'
import
{
usePaymentStore
}
from
'
@/stores/payment
'
import
{
paymentAPI
}
from
'
@/api/payment
'
import
type
{
PaymentOrder
}
from
'
@/types/payment
'
...
...
@@ -193,6 +197,18 @@ function clearStatusRefreshTimer(): void {
}
}
function
clearRecoverySnapshot
():
void
{
if
(
typeof
window
===
'
undefined
'
)
return
clearPaymentRecoverySnapshot
(
window
.
localStorage
,
PAYMENT_RECOVERY_STORAGE_KEY
)
}
function
clearRecoverySnapshotForTerminalStatus
(
status
:
string
|
null
|
undefined
):
void
{
if
(
!
status
)
return
if
(
!
isPendingStatus
(
status
))
{
clearRecoverySnapshot
()
}
}
function
scheduleStatusRefresh
(
refreshOrder
:
(()
=>
Promise
<
PaymentOrder
|
null
>
)
|
null
):
void
{
clearStatusRefreshTimer
()
if
(
!
refreshOrder
||
!
isPending
.
value
||
refreshAttempts
.
value
>=
STATUS_REFRESH_MAX_ATTEMPTS
)
{
...
...
@@ -204,6 +220,7 @@ function scheduleStatusRefresh(refreshOrder: (() => Promise<PaymentOrder | null>
const
refreshedOrder
=
await
refreshOrder
()
if
(
refreshedOrder
)
{
order
.
value
=
refreshedOrder
clearRecoverySnapshotForTerminalStatus
(
refreshedOrder
.
status
)
}
if
(
isPendingStatus
(
order
.
value
?.
status
))
{
...
...
@@ -285,6 +302,10 @@ onMounted(async () => {
if
(
isPendingStatus
(
order
.
value
?.
status
))
{
scheduleStatusRefresh
(
refreshOrder
)
}
else
if
(
order
.
value
)
{
clearRecoverySnapshotForTerminalStatus
(
order
.
value
.
status
)
}
else
if
(
returnInfo
.
value
)
{
clearRecoverySnapshot
()
}
loading
.
value
=
false
})
...
...
frontend/src/views/user/PaymentView.vue
View file @
c229f33e
...
...
@@ -391,6 +391,20 @@ function resetPayment() {
removeRecoverySnapshot
()
}
async
function
redirectToPaymentResult
(
state
:
PaymentRecoverySnapshot
):
Promise
<
void
>
{
const
query
:
Record
<
string
,
string
|
undefined
>
=
{
}
if
(
state
.
orderId
>
0
)
{
query
.
order_id
=
String
(
state
.
orderId
)
}
if
(
state
.
resumeToken
)
{
query
.
resume_token
=
state
.
resumeToken
}
await
router
.
push
({
path
:
'
/payment/result
'
,
query
,
}
)
}
function
onPaymentDone
()
{
const
wasSubscription
=
paymentState
.
value
.
orderType
===
'
subscription
'
resetPayment
()
...
...
@@ -684,8 +698,14 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
const
errMsg
=
String
(
jsapiResult
.
err_msg
||
''
).
toLowerCase
()
if
(
errMsg
.
includes
(
'
cancel
'
))
{
appStore
.
showInfo
(
t
(
'
payment.qr.cancelled
'
))
resetPayment
()
}
else
if
(
errMsg
&&
!
errMsg
.
includes
(
'
ok
'
))
{
applyScenarioError
({
reason
:
'
WECHAT_JSAPI_FAILED
'
,
message
:
errMsg
}
,
visibleMethod
)
resetPayment
()
}
else
{
const
resultState
=
{
...
decision
.
paymentState
}
resetPayment
()
await
redirectToPaymentResult
(
resultState
)
}
return
}
...
...
frontend/src/views/user/__tests__/PaymentResultView.spec.ts
View file @
c229f33e
...
...
@@ -60,6 +60,21 @@ const orderFactory = (status: string) => ({
refund_amount
:
0
,
})
const
recoverySnapshotFactory
=
(
resumeToken
:
string
)
=>
({
orderId
:
42
,
amount
:
88
,
qrCode
:
''
,
expiresAt
:
'
2099-01-01T00:10:00.000Z
'
,
paymentType
:
'
alipay
'
,
payUrl
:
'
https://pay.example.com/session/42
'
,
clientSecret
:
''
,
payAmount
:
88
,
orderType
:
'
balance
'
,
paymentMode
:
'
popup
'
,
resumeToken
,
createdAt
:
Date
.
UTC
(
2099
,
0
,
1
,
0
,
0
,
0
),
})
describe
(
'
PaymentResultView
'
,
()
=>
{
beforeEach
(()
=>
{
routeState
.
query
=
{}
...
...
@@ -162,6 +177,7 @@ describe('PaymentResultView', () => {
expect
(
wrapper
.
text
()).
toContain
(
'
payment.result.success
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
103.00
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
100.00
'
)
expect
(
window
.
localStorage
.
getItem
(
PAYMENT_RECOVERY_STORAGE_KEY
)).
toBeNull
()
})
it
(
'
refreshes a pending resume-token result until the order becomes paid
'
,
async
()
=>
{
...
...
@@ -169,6 +185,10 @@ describe('PaymentResultView', () => {
routeState
.
query
=
{
resume_token
:
'
resume-77
'
,
}
window
.
localStorage
.
setItem
(
PAYMENT_RECOVERY_STORAGE_KEY
,
JSON
.
stringify
(
recoverySnapshotFactory
(
'
resume-77
'
)),
)
resolveOrderPublicByResumeToken
.
mockResolvedValueOnce
({
data
:
orderFactory
(
'
PENDING
'
),
...
...
@@ -189,6 +209,7 @@ describe('PaymentResultView', () => {
expect
(
resolveOrderPublicByResumeToken
).
toHaveBeenCalledTimes
(
1
)
expect
(
wrapper
.
text
()).
toContain
(
'
payment.result.processing
'
)
expect
(
window
.
localStorage
.
getItem
(
PAYMENT_RECOVERY_STORAGE_KEY
)).
not
.
toBeNull
()
await
vi
.
advanceTimersByTimeAsync
(
2000
)
await
flushPromises
()
...
...
@@ -196,6 +217,7 @@ describe('PaymentResultView', () => {
expect
(
resolveOrderPublicByResumeToken
).
toHaveBeenCalledTimes
(
2
)
expect
(
wrapper
.
text
()).
toContain
(
'
payment.result.success
'
)
expect
(
wrapper
.
text
()).
not
.
toContain
(
'
payment.result.failed
'
)
expect
(
window
.
localStorage
.
getItem
(
PAYMENT_RECOVERY_STORAGE_KEY
)).
toBeNull
()
})
it
(
'
does not fall back to public out_trade_no verification when resume_token recovery fails
'
,
async
()
=>
{
...
...
frontend/src/views/user/__tests__/PaymentView.spec.ts
0 → 100644
View file @
c229f33e
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
()
})
})
frontend/src/views/user/__tests__/paymentUx.spec.ts
View file @
c229f33e
...
...
@@ -28,6 +28,16 @@ describe('describePaymentScenarioError', () => {
})
})
it
(
'
maps WeChat H5 authorization errors when provider aliases use wxpay_direct
'
,
()
=>
{
expect
(
describePaymentScenarioError
(
{
reason
:
'
WECHAT_H5_NOT_AUTHORIZED
'
},
{
paymentMethod
:
'
wxpay_direct
'
,
isMobile
:
true
,
isWechatBrowser
:
false
},
)).
toEqual
({
messageKey
:
'
payment.errors.wechatH5NotAuthorized
'
,
hintKey
:
'
payment.errors.wechatOpenInWeChatHint
'
,
})
})
it
(
'
maps missing WeixinJSBridge to a JSAPI-specific prompt
'
,
()
=>
{
expect
(
describePaymentScenarioError
(
new
Error
(
'
WeixinJSBridge is unavailable
'
),
...
...
Prev
1
2
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment