mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 08:25:35 +00:00
fix: harden billing concurrency and payment recovery
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type NavigationGuard = (
|
||||
to: Record<string, any>,
|
||||
from: Record<string, any>,
|
||||
next: ReturnType<typeof vi.fn>
|
||||
) => Promise<void>
|
||||
|
||||
const routerHarness = vi.hoisted(() => ({
|
||||
guard: null as NavigationGuard | null,
|
||||
}))
|
||||
|
||||
const authStore = vi.hoisted(() => ({
|
||||
checkAuth: vi.fn(),
|
||||
isAuthenticated: true,
|
||||
isAdmin: false,
|
||||
isSimpleMode: false,
|
||||
hasPendingAuthSession: false,
|
||||
}))
|
||||
|
||||
const appStore = vi.hoisted(() => ({
|
||||
siteName: 'Sub2API',
|
||||
backendModeEnabled: false,
|
||||
publicSettingsLoaded: false,
|
||||
cachedPublicSettings: null as null | {
|
||||
payment_enabled?: boolean
|
||||
risk_control_enabled?: boolean
|
||||
custom_menu_items?: []
|
||||
},
|
||||
fetchPublicSettings: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
createWebHistory: vi.fn(() => ({})),
|
||||
createRouter: vi.fn(() => ({
|
||||
beforeEach: vi.fn((guard: NavigationGuard) => {
|
||||
routerHarness.guard = guard
|
||||
}),
|
||||
afterEach: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
useAuthStore: () => authStore,
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => appStore,
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/adminSettings', () => ({
|
||||
useAdminSettingsStore: () => ({ customMenuItems: [] }),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/adminCompliance', () => ({
|
||||
useAdminComplianceStore: () => ({
|
||||
initialized: true,
|
||||
fetchStatus: vi.fn(),
|
||||
requireAcknowledgement: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useNavigationLoading', () => ({
|
||||
useNavigationLoadingState: () => ({
|
||||
startNavigation: vi.fn(),
|
||||
endNavigation: vi.fn(),
|
||||
isLoading: { value: false },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useRoutePrefetch', () => ({
|
||||
useRoutePrefetch: () => ({
|
||||
triggerPrefetch: vi.fn(),
|
||||
cancelPendingPrefetch: vi.fn(),
|
||||
resetPrefetchState: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function runGuard(meta: Record<string, unknown>, path: string) {
|
||||
if (!routerHarness.guard) {
|
||||
throw new Error('router guard was not registered')
|
||||
}
|
||||
|
||||
const next = vi.fn()
|
||||
const navigation = routerHarness.guard(
|
||||
{
|
||||
path,
|
||||
fullPath: path,
|
||||
name: 'FeatureRoute',
|
||||
params: {},
|
||||
meta: { requiresAuth: true, ...meta },
|
||||
},
|
||||
{},
|
||||
next
|
||||
)
|
||||
return { navigation, next }
|
||||
}
|
||||
|
||||
describe('feature route guard', () => {
|
||||
beforeAll(async () => {
|
||||
await import('@/router')
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
authStore.isAuthenticated = true
|
||||
authStore.isAdmin = false
|
||||
authStore.isSimpleMode = false
|
||||
appStore.publicSettingsLoaded = false
|
||||
appStore.cachedPublicSettings = null
|
||||
appStore.fetchPublicSettings.mockReset()
|
||||
})
|
||||
|
||||
it('waits for the first public-settings request before deciding payment access', async () => {
|
||||
const deferred = createDeferred<{ payment_enabled: boolean }>()
|
||||
appStore.fetchPublicSettings.mockImplementation(async () => {
|
||||
const settings = await deferred.promise
|
||||
appStore.cachedPublicSettings = settings
|
||||
appStore.publicSettingsLoaded = true
|
||||
return settings
|
||||
})
|
||||
|
||||
const { navigation, next } = runGuard({ requiresPayment: true }, '/purchase')
|
||||
|
||||
await vi.waitFor(() => expect(appStore.fetchPublicSettings).toHaveBeenCalledTimes(1))
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
|
||||
deferred.resolve({ payment_enabled: true })
|
||||
await navigation
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
expect(next).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['payment', { requiresPayment: true }, '/purchase'],
|
||||
['risk control', { requiresRiskControl: true }, '/admin/risk-control'],
|
||||
])('does not treat a failed %s settings load as explicitly disabled', async (_name, meta, path) => {
|
||||
authStore.isAdmin = meta.requiresRiskControl === true
|
||||
appStore.fetchPublicSettings.mockResolvedValue(null)
|
||||
|
||||
const { navigation, next } = runGuard(meta, path)
|
||||
await navigation
|
||||
|
||||
expect(appStore.publicSettingsLoaded).toBe(false)
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
expect(next).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['payment', { requiresPayment: true }, { payment_enabled: false }, '/dashboard'],
|
||||
[
|
||||
'risk control',
|
||||
{ requiresRiskControl: true },
|
||||
{ risk_control_enabled: false },
|
||||
'/admin/settings',
|
||||
],
|
||||
])('redirects when loaded settings explicitly disable %s', async (_name, meta, settings, target) => {
|
||||
authStore.isAdmin = meta.requiresRiskControl === true
|
||||
appStore.cachedPublicSettings = settings
|
||||
appStore.publicSettingsLoaded = true
|
||||
|
||||
const { navigation, next } = runGuard(meta, '/feature')
|
||||
await navigation
|
||||
|
||||
expect(appStore.fetchPublicSettings).not.toHaveBeenCalled()
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
expect(next).toHaveBeenCalledWith(target)
|
||||
})
|
||||
})
|
||||
@@ -837,21 +837,24 @@ router.beforeEach(async (to, _from, next) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Check payment requirement (internal payment system only)
|
||||
if (to.meta.requiresPayment) {
|
||||
const paymentEnabled = appStore.cachedPublicSettings?.payment_enabled
|
||||
if (!paymentEnabled) {
|
||||
next(authStore.isAdmin ? '/admin/dashboard' : '/dashboard')
|
||||
return
|
||||
}
|
||||
// Only an explicit value from successfully loaded settings can disable a route.
|
||||
// A transient settings failure is unknown state, not a confirmed feature toggle.
|
||||
if (
|
||||
to.meta.requiresPayment &&
|
||||
appStore.publicSettingsLoaded &&
|
||||
appStore.cachedPublicSettings?.payment_enabled === false
|
||||
) {
|
||||
next(authStore.isAdmin ? '/admin/dashboard' : '/dashboard')
|
||||
return
|
||||
}
|
||||
|
||||
if (to.meta.requiresRiskControl) {
|
||||
const riskControlEnabled = appStore.cachedPublicSettings?.risk_control_enabled === true
|
||||
if (!riskControlEnabled) {
|
||||
next(authStore.isAdmin ? '/admin/settings' : '/dashboard')
|
||||
return
|
||||
}
|
||||
if (
|
||||
to.meta.requiresRiskControl &&
|
||||
appStore.publicSettingsLoaded &&
|
||||
appStore.cachedPublicSettings?.risk_control_enabled === false
|
||||
) {
|
||||
next(authStore.isAdmin ? '/admin/settings' : '/dashboard')
|
||||
return
|
||||
}
|
||||
|
||||
// 简易模式下限制访问某些页面
|
||||
|
||||
Reference in New Issue
Block a user