Files
admin-web/src/layouts/AppLayout.test.ts
T

342 lines
15 KiB
TypeScript
Raw Normal View History

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
})