2026-07-30 17:59:43 +08:00
|
|
|
|
import { createPinia, setActivePinia } from 'pinia'
|
|
|
|
|
|
import { flushPromises, mount } from '@vue/test-utils'
|
|
|
|
|
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
|
|
|
|
|
import { nextTick } from 'vue'
|
|
|
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
2026-07-31 13:43:16 +08:00
|
|
|
|
import { ApiResponseError } from '@/api/http'
|
2026-07-30 17:59:43 +08:00
|
|
|
|
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
|
|
|
|
|
import { useAdminDataStore } from '@/stores/adminData'
|
|
|
|
|
|
import { useAuthStore } from '@/stores/auth'
|
|
|
|
|
|
import { useUnsavedWorkStore } from '@/stores/unsavedWork'
|
|
|
|
|
|
import AppLayout from './AppLayout.vue'
|
|
|
|
|
|
|
|
|
|
|
|
vi.mock('element-plus', async (importOriginal) => {
|
|
|
|
|
|
const actual = await importOriginal<typeof import('element-plus')>()
|
|
|
|
|
|
return {
|
|
|
|
|
|
...actual,
|
2026-07-31 13:43:16 +08:00
|
|
|
|
ElMessage: { success: vi.fn(), warning: vi.fn(), error: vi.fn() },
|
2026-07-30 17:59:43 +08:00
|
|
|
|
ElMessageBox: { confirm: vi.fn() },
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const page = { template: '<p>page</p>' }
|
|
|
|
|
|
|
|
|
|
|
|
function createMemoryStorage(): Storage {
|
|
|
|
|
|
const values = new Map<string, string>()
|
|
|
|
|
|
return {
|
|
|
|
|
|
get length() { return values.size },
|
|
|
|
|
|
clear: () => values.clear(),
|
|
|
|
|
|
getItem: (key) => values.get(key) ?? null,
|
|
|
|
|
|
key: (index) => [...values.keys()][index] ?? null,
|
|
|
|
|
|
removeItem: (key) => values.delete(key),
|
|
|
|
|
|
setItem: (key, value) => values.set(key, value),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function mountLayout(path: string) {
|
|
|
|
|
|
const pinia = createPinia()
|
|
|
|
|
|
setActivePinia(pinia)
|
|
|
|
|
|
const auth = useAuthStore()
|
|
|
|
|
|
auth.token = 'test-access-token'
|
|
|
|
|
|
auth.user = { id: '1', name: '平台管理员', account: 'platform_admin', roles: ['PLATFORM_ADMIN'] }
|
|
|
|
|
|
localStorage.setItem('cc-admin-token', auth.token)
|
|
|
|
|
|
const router = createRouter({
|
|
|
|
|
|
history: createMemoryHistory(),
|
|
|
|
|
|
routes: [
|
|
|
|
|
|
{ path: '/', component: page, meta: { title: '管理后台' } },
|
|
|
|
|
|
{ path: '/customers', component: page, meta: { title: '客户管理', permission: 'platform:customer:list' } },
|
|
|
|
|
|
{ path: '/warehouses', component: page, meta: { title: '仓库管理', permission: 'platform:warehouse:list' } },
|
|
|
|
|
|
{ path: '/orders/first-leg', component: page, meta: { title: '头程订单', permission: 'platform:order:list' } },
|
|
|
|
|
|
{ path: '/system/users', component: page, meta: { title: '内部用户', permission: 'platform:user:list' } },
|
|
|
|
|
|
{ path: '/403', component: page, meta: { title: '权限不足' } },
|
|
|
|
|
|
{ path: '/login', component: page, meta: { title: '登录' } },
|
|
|
|
|
|
],
|
|
|
|
|
|
})
|
|
|
|
|
|
await router.push(path)
|
|
|
|
|
|
await router.isReady()
|
|
|
|
|
|
|
|
|
|
|
|
const wrapper = mount(AppLayout, {
|
|
|
|
|
|
attachTo: document.body,
|
|
|
|
|
|
global: {
|
|
|
|
|
|
plugins: [pinia, router],
|
|
|
|
|
|
stubs: {
|
|
|
|
|
|
AdminSidebarMenu: { props: ['items'], template: '<nav data-sidebar :data-count="items.length" />' },
|
|
|
|
|
|
RolePreviewSwitcher: {
|
|
|
|
|
|
props: ['roles', 'currentRole'],
|
|
|
|
|
|
emits: ['select'],
|
|
|
|
|
|
template: '<div><span data-preview>{{ currentRole?.name }}</span><button data-switch-r003 @click="$emit(\'select\', \'R003\')">switch R003</button><button data-switch-r005 @click="$emit(\'select\', \'R005\')">switch R005</button></div>',
|
|
|
|
|
|
},
|
|
|
|
|
|
'el-button': { emits: ['click'], template: '<button type="button" @click="$emit(\'click\')"><slot /></button>' },
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
await nextTick()
|
|
|
|
|
|
return {
|
|
|
|
|
|
wrapper,
|
|
|
|
|
|
router,
|
|
|
|
|
|
auth,
|
|
|
|
|
|
access: useAccessPreviewStore(),
|
|
|
|
|
|
data: useAdminDataStore(),
|
|
|
|
|
|
unsaved: useUnsavedWorkStore(),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
describe('AppLayout role preview coordination', () => {
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
|
vi.stubGlobal('localStorage', createMemoryStorage())
|
|
|
|
|
|
vi.mocked(ElMessageBox.confirm).mockReset()
|
|
|
|
|
|
vi.mocked(ElMessage.success).mockReset()
|
2026-07-31 13:43:16 +08:00
|
|
|
|
vi.mocked(ElMessage.warning).mockReset()
|
2026-07-30 17:59:43 +08:00
|
|
|
|
vi.mocked(ElMessage.error).mockReset()
|
|
|
|
|
|
document.body.innerHTML = ''
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('keeps the login identity visibly separate from the preview role', async () => {
|
|
|
|
|
|
const { wrapper } = await mountLayout('/')
|
|
|
|
|
|
|
|
|
|
|
|
expect(wrapper.get('[aria-label="登录身份:平台管理员(platform_admin)"]').text())
|
|
|
|
|
|
.toBe('登录身份:平台管理员(platform_admin)')
|
|
|
|
|
|
expect(wrapper.get('[data-preview]').text()).toBe('超级管理员')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('commits a role and replaces an inaccessible system route with its first leaf', async () => {
|
|
|
|
|
|
const { wrapper, router, access } = await mountLayout('/system/users')
|
|
|
|
|
|
const replace = vi.spyOn(router, 'replace')
|
|
|
|
|
|
|
|
|
|
|
|
await wrapper.get('[data-switch-r003]').trigger('click')
|
|
|
|
|
|
await flushPromises()
|
|
|
|
|
|
|
|
|
|
|
|
expect(access.currentRole?.id).toBe('R003')
|
|
|
|
|
|
expect(replace).toHaveBeenCalledWith('/warehouses')
|
|
|
|
|
|
expect(router.currentRoute.value.path).toBe('/warehouses')
|
|
|
|
|
|
expect(wrapper.get('[aria-live="polite"]').text()).toContain('操作员')
|
|
|
|
|
|
expect(wrapper.get('[aria-live="polite"]').text()).toContain('仓库管理')
|
|
|
|
|
|
expect(ElMessage.success).toHaveBeenCalledWith('已切换至“操作员”,当前仅为静态权限预览')
|
|
|
|
|
|
expect(document.activeElement).toBe(wrapper.get('h1').element)
|
|
|
|
|
|
expect(wrapper.get('[data-sidebar]').attributes('data-count'))
|
|
|
|
|
|
.toBe(String(access.filteredNavigation.length))
|
|
|
|
|
|
expect(access.filteredNavigation).toHaveLength(1)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('recovers from 403 to the first accessible leaf after a role change', async () => {
|
|
|
|
|
|
const { wrapper, router, access } = await mountLayout('/403')
|
|
|
|
|
|
const replace = vi.spyOn(router, 'replace')
|
|
|
|
|
|
|
|
|
|
|
|
await wrapper.get('[data-switch-r003]').trigger('click')
|
|
|
|
|
|
await flushPromises()
|
|
|
|
|
|
|
|
|
|
|
|
expect(access.currentRole?.id).toBe('R003')
|
|
|
|
|
|
expect(replace).toHaveBeenCalledWith('/warehouses')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('keeps a queried 403 route when the candidate role has no accessible page', async () => {
|
|
|
|
|
|
const { wrapper, router, access, data } = await mountLayout('/403?from=/system/users')
|
|
|
|
|
|
data.roles.find((role) => role.id === 'R003')!.permissionIds = []
|
|
|
|
|
|
const replace = vi.spyOn(router, 'replace')
|
|
|
|
|
|
|
|
|
|
|
|
await wrapper.get('[data-switch-r003]').trigger('click')
|
|
|
|
|
|
await flushPromises()
|
|
|
|
|
|
|
|
|
|
|
|
expect(access.currentRole?.id).toBe('R003')
|
|
|
|
|
|
expect(router.currentRoute.value.fullPath).toBe('/403?from=/system/users')
|
|
|
|
|
|
expect(replace).not.toHaveBeenCalled()
|
|
|
|
|
|
expect(wrapper.get('[aria-live="polite"]').text()).toBe('权限预览已切换为操作员')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('keeps role, route and dirty entry unchanged when role switching is cancelled', async () => {
|
|
|
|
|
|
vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel')
|
|
|
|
|
|
const { wrapper, router, access, unsaved } = await mountLayout('/system/users')
|
|
|
|
|
|
unsaved.upsert({ key: 'user-form', label: '内部用户编辑表单', dirty: true, requiredPermission: 'system:user:write' })
|
|
|
|
|
|
|
|
|
|
|
|
await wrapper.get('[data-switch-r003]').trigger('click')
|
|
|
|
|
|
await flushPromises()
|
|
|
|
|
|
|
|
|
|
|
|
expect(ElMessageBox.confirm).toHaveBeenCalledWith(
|
|
|
|
|
|
expect.stringMatching(/切换到操作员.*将离开内部用户.*内部用户编辑表单/),
|
|
|
|
|
|
expect.any(String),
|
|
|
|
|
|
expect.any(Object),
|
|
|
|
|
|
)
|
|
|
|
|
|
expect(access.currentRole?.id).toBe('R001')
|
|
|
|
|
|
expect(router.currentRoute.value.path).toBe('/system/users')
|
|
|
|
|
|
expect(unsaved.entries).toHaveLength(1)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('warns that same-page forms will lose save permission without claiming navigation', async () => {
|
|
|
|
|
|
vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel')
|
|
|
|
|
|
const { wrapper, router, access, unsaved } = await mountLayout('/orders/first-leg')
|
|
|
|
|
|
unsaved.upsert({ key: 'restricted-form', label: '受限订单表单', dirty: true, requiredPermission: 'system:user:write' })
|
|
|
|
|
|
|
|
|
|
|
|
await wrapper.get('[data-switch-r003]').trigger('click')
|
|
|
|
|
|
await flushPromises()
|
|
|
|
|
|
|
|
|
|
|
|
const message = String(vi.mocked(ElMessageBox.confirm).mock.calls[0]?.[0])
|
|
|
|
|
|
expect(message).toContain('切换到操作员')
|
|
|
|
|
|
expect(message).toContain('头程订单')
|
|
|
|
|
|
expect(message).toContain('将失去保存权限')
|
|
|
|
|
|
expect(message).toContain('受限订单表单')
|
|
|
|
|
|
expect(message).not.toContain('将离开头程订单')
|
|
|
|
|
|
expect(access.currentRole?.id).toBe('R001')
|
|
|
|
|
|
expect(router.currentRoute.value.path).toBe('/orders/first-leg')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('commits only after dirty-work confirmation succeeds', async () => {
|
|
|
|
|
|
let resolveConfirmation: (() => void) | undefined
|
|
|
|
|
|
vi.mocked(ElMessageBox.confirm).mockReturnValueOnce(new Promise((resolve) => {
|
|
|
|
|
|
resolveConfirmation = () => resolve({} as never)
|
|
|
|
|
|
}))
|
|
|
|
|
|
const { wrapper, access, unsaved } = await mountLayout('/system/users')
|
|
|
|
|
|
unsaved.upsert({ key: 'user-form', label: '内部用户编辑表单', dirty: true, requiredPermission: 'system:user:write' })
|
|
|
|
|
|
|
|
|
|
|
|
await wrapper.get('[data-switch-r003]').trigger('click')
|
|
|
|
|
|
await nextTick()
|
|
|
|
|
|
expect(access.currentRole?.id).toBe('R001')
|
|
|
|
|
|
|
|
|
|
|
|
resolveConfirmation?.()
|
|
|
|
|
|
await flushPromises()
|
|
|
|
|
|
expect(access.currentRole?.id).toBe('R003')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('ignores another role selection while the first confirmation is pending', async () => {
|
|
|
|
|
|
let resolveConfirmation: (() => void) | undefined
|
|
|
|
|
|
vi.mocked(ElMessageBox.confirm).mockReturnValueOnce(new Promise((resolve) => {
|
|
|
|
|
|
resolveConfirmation = () => resolve({} as never)
|
|
|
|
|
|
}))
|
|
|
|
|
|
const { wrapper, access, unsaved } = await mountLayout('/system/users')
|
|
|
|
|
|
unsaved.upsert({ key: 'user-form', label: '内部用户编辑表单', dirty: true, requiredPermission: 'system:user:write' })
|
|
|
|
|
|
|
|
|
|
|
|
await wrapper.get('[data-switch-r003]').trigger('click')
|
|
|
|
|
|
await wrapper.get('[data-switch-r005]').trigger('click')
|
|
|
|
|
|
await nextTick()
|
|
|
|
|
|
|
|
|
|
|
|
expect(ElMessageBox.confirm).toHaveBeenCalledTimes(1)
|
|
|
|
|
|
expect(access.currentRole?.id).toBe('R001')
|
|
|
|
|
|
resolveConfirmation?.()
|
|
|
|
|
|
await flushPromises()
|
|
|
|
|
|
expect(access.currentRole?.id).toBe('R003')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('reconfirms when a new blocker appears while the first confirmation is pending', async () => {
|
|
|
|
|
|
let resolveFirst: (() => void) | undefined
|
|
|
|
|
|
let resolveSecond: (() => void) | undefined
|
|
|
|
|
|
vi.mocked(ElMessageBox.confirm)
|
|
|
|
|
|
.mockReturnValueOnce(new Promise((resolve) => { resolveFirst = () => resolve({} as never) }))
|
|
|
|
|
|
.mockReturnValueOnce(new Promise((resolve) => { resolveSecond = () => resolve({} as never) }))
|
|
|
|
|
|
const { wrapper, access, unsaved } = await mountLayout('/orders/first-leg')
|
|
|
|
|
|
unsaved.upsert({ key: 'first-form', label: '第一张表单', dirty: true, requiredPermission: 'system:user:write' })
|
|
|
|
|
|
|
|
|
|
|
|
await wrapper.get('[data-switch-r003]').trigger('click')
|
|
|
|
|
|
await nextTick()
|
|
|
|
|
|
unsaved.upsert({ key: 'second-form', label: '新增表单', dirty: true, requiredPermission: 'customer:create' })
|
|
|
|
|
|
resolveFirst?.()
|
|
|
|
|
|
await flushPromises()
|
|
|
|
|
|
|
|
|
|
|
|
expect(ElMessageBox.confirm).toHaveBeenCalledTimes(2)
|
|
|
|
|
|
expect(String(vi.mocked(ElMessageBox.confirm).mock.calls[1]?.[0])).toContain('新增表单')
|
|
|
|
|
|
expect(access.currentRole?.id).toBe('R001')
|
|
|
|
|
|
resolveSecond?.()
|
|
|
|
|
|
await flushPromises()
|
|
|
|
|
|
expect(access.currentRole?.id).toBe('R003')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('replaces the route when live permission changes make the current page inaccessible', async () => {
|
|
|
|
|
|
const { router, access, data } = await mountLayout('/system/users')
|
|
|
|
|
|
const replace = vi.spyOn(router, 'replace')
|
|
|
|
|
|
const permission = data.permissions.find((item) => item.code === 'platform:user:list')
|
|
|
|
|
|
if (!permission) throw new Error('fixture permission missing')
|
|
|
|
|
|
|
|
|
|
|
|
permission.status = '停用'
|
|
|
|
|
|
await nextTick()
|
|
|
|
|
|
await flushPromises()
|
|
|
|
|
|
|
|
|
|
|
|
expect(access.currentRole?.id).toBe('R001')
|
|
|
|
|
|
expect(replace).toHaveBeenCalledWith('/customers')
|
|
|
|
|
|
expect(document.activeElement).toBe(document.querySelector('h1'))
|
|
|
|
|
|
expect(document.querySelector('[aria-live="polite"]')?.textContent)
|
|
|
|
|
|
.toBe('当前页面权限已变化,已前往客户管理')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('does not replace or announce navigation when live access remains on queried 403', async () => {
|
|
|
|
|
|
const { router, data } = await mountLayout('/403?from=/system/users')
|
|
|
|
|
|
const replace = vi.spyOn(router, 'replace')
|
|
|
|
|
|
|
|
|
|
|
|
data.permissions.forEach((permission) => { permission.status = '停用' })
|
|
|
|
|
|
await nextTick()
|
|
|
|
|
|
await flushPromises()
|
|
|
|
|
|
|
|
|
|
|
|
expect(router.currentRoute.value.fullPath).toBe('/403?from=/system/users')
|
|
|
|
|
|
expect(replace).not.toHaveBeenCalled()
|
|
|
|
|
|
expect(document.querySelector('[aria-live="polite"]')?.textContent).toBe('')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('rolls back the role and reports an error when correction navigation fails', async () => {
|
|
|
|
|
|
const { wrapper, router, access } = await mountLayout('/system/users')
|
|
|
|
|
|
vi.spyOn(router, 'replace').mockRejectedValueOnce(new Error('navigation failed'))
|
|
|
|
|
|
|
|
|
|
|
|
await wrapper.get('[data-switch-r003]').trigger('click')
|
|
|
|
|
|
await flushPromises()
|
|
|
|
|
|
|
|
|
|
|
|
expect(access.currentRole?.id).toBe('R001')
|
|
|
|
|
|
expect(router.currentRoute.value.path).toBe('/system/users')
|
|
|
|
|
|
expect(ElMessage.error).toHaveBeenCalledWith('权限预览切换失败,已恢复原角色')
|
|
|
|
|
|
expect(ElMessage.success).not.toHaveBeenCalled()
|
|
|
|
|
|
|
|
|
|
|
|
await wrapper.get('[data-switch-r003]').trigger('click')
|
|
|
|
|
|
await flushPromises()
|
|
|
|
|
|
expect(access.currentRole?.id).toBe('R003')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('clears preview and unsaved state before logging out and replacing with login', async () => {
|
|
|
|
|
|
vi.mocked(ElMessageBox.confirm).mockResolvedValueOnce({} as never)
|
|
|
|
|
|
const { wrapper, router, auth, access, unsaved } = await mountLayout('/')
|
|
|
|
|
|
unsaved.upsert({ key: 'draft', label: '草稿', dirty: true, requiredPermission: null })
|
|
|
|
|
|
const calls: string[] = []
|
|
|
|
|
|
const resetAccess = access.reset
|
|
|
|
|
|
const clearUnsaved = unsaved.clear
|
|
|
|
|
|
const logout = auth.logout
|
|
|
|
|
|
vi.spyOn(access, 'reset').mockImplementation(() => { calls.push('access'); resetAccess() })
|
|
|
|
|
|
vi.spyOn(unsaved, 'clear').mockImplementation(() => { calls.push('unsaved'); clearUnsaved() })
|
2026-07-31 13:43:16 +08:00
|
|
|
|
vi.spyOn(auth, 'logout').mockImplementation(() => { calls.push('auth'); return logout() })
|
2026-07-30 17:59:43 +08:00
|
|
|
|
const replace = vi.spyOn(router, 'replace')
|
|
|
|
|
|
|
|
|
|
|
|
await wrapper.get('[aria-label="退出登录"]').trigger('click')
|
|
|
|
|
|
await flushPromises()
|
|
|
|
|
|
|
|
|
|
|
|
expect(calls).toEqual(['access', 'unsaved', 'auth'])
|
|
|
|
|
|
expect(replace).toHaveBeenCalledWith('/login')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('keeps cleared logout state and reports a login navigation failure', async () => {
|
|
|
|
|
|
vi.mocked(ElMessageBox.confirm).mockResolvedValueOnce({} as never)
|
|
|
|
|
|
const { wrapper, router, auth, access, unsaved } = await mountLayout('/')
|
|
|
|
|
|
unsaved.upsert({ key: 'draft', label: '草稿', dirty: true, requiredPermission: null })
|
|
|
|
|
|
vi.spyOn(router, 'replace').mockRejectedValueOnce(new Error('navigation failed'))
|
|
|
|
|
|
|
|
|
|
|
|
await wrapper.get('[aria-label="退出登录"]').trigger('click')
|
|
|
|
|
|
await flushPromises()
|
|
|
|
|
|
|
|
|
|
|
|
expect(auth.isAuthenticated).toBe(false)
|
|
|
|
|
|
expect(access.currentRole).toBeNull()
|
|
|
|
|
|
expect(unsaved.entries).toHaveLength(0)
|
|
|
|
|
|
expect(ElMessage.error).toHaveBeenCalledWith('已退出登录,但登录页跳转失败,请刷新页面')
|
|
|
|
|
|
expect(router.currentRoute.value.path).toBe('/')
|
|
|
|
|
|
expect(router.replace).toHaveBeenCalledTimes(1)
|
|
|
|
|
|
})
|
2026-07-31 13:43:16 +08:00
|
|
|
|
|
|
|
|
|
|
it('keeps the local logout and reports server revocation failure with a tracking id', async () => {
|
|
|
|
|
|
vi.mocked(ElMessageBox.confirm).mockResolvedValueOnce({} as never)
|
|
|
|
|
|
const { wrapper, router, auth } = await mountLayout('/')
|
|
|
|
|
|
vi.spyOn(auth, 'logout').mockImplementation(async () => {
|
|
|
|
|
|
auth.token = ''
|
|
|
|
|
|
auth.refreshToken = ''
|
|
|
|
|
|
throw new ApiResponseError('服务不可用', 'S0503', 503, undefined, 'request-logout')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
await wrapper.get('[aria-label="退出登录"]').trigger('click')
|
|
|
|
|
|
await flushPromises()
|
|
|
|
|
|
|
|
|
|
|
|
expect(router.currentRoute.value.path).toBe('/login')
|
|
|
|
|
|
expect(ElMessage.warning).toHaveBeenCalledWith('本地已退出,但服务器会话撤销失败(追踪标识:request-logout)')
|
|
|
|
|
|
})
|
2026-07-30 17:59:43 +08:00
|
|
|
|
})
|