接入前端登录权限底座

This commit is contained in:
andy
2026-07-09 20:42:16 +08:00
parent c190d5e3a8
commit e49b2ccc1d
27 changed files with 1745 additions and 68 deletions

View File

@@ -0,0 +1,125 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fetchCurrentAuth, loginAuth, logoutAuth } from '@/services/authService'
const jsonHeaders = {
headers: {
get: (name: string) => (name.toLowerCase() === 'content-type' ? 'application/json' : null),
},
}
function mockJsonResponse(payload: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
json: async () => payload,
text: async () => JSON.stringify(payload),
...jsonHeaders,
} as Response
}
function createAuthPayload() {
return {
access_token: 'session-token',
token_type: 'Bearer',
expires_at: '2026-07-09T12:00:00Z',
user: {
id: '1900000000000000001',
username: 'admin',
display_name: '系统管理员',
super_admin: true,
},
default_hotel_id: 'HOTEL-TEST',
hotels: [
{
hotel_id: 'HOTEL-TEST',
hotel_name: '测试酒店',
time_zone: 'Asia/Bangkok',
default_hotel: true,
},
],
permissions: ['RESERVATION_ORDER_READ'],
menus: [
{
menu_code: 'RESERVATION_ORDERS',
menu_name: '订单列表',
route_path: '/reservation/orders',
component_key: 'ReservationOrders',
icon_key: 'pi pi-list',
permission_code: 'RESERVATION_ORDER_READ',
sort_order: 10,
},
],
}
}
describe('authService', () => {
beforeEach(() => {
vi.restoreAllMocks()
sessionStorage.clear()
})
it('logs in with username, password, and optional preferred hotel id', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockJsonResponse(createAuthPayload()))
const result = await loginAuth({
username: 'admin',
password: 'Admin@123456',
preferred_hotel_id: 'HOTEL-TEST',
})
expect(fetchMock).toHaveBeenCalledWith(
'/api/auth/login',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
}),
body: JSON.stringify({
username: 'admin',
password: 'Admin@123456',
preferred_hotel_id: 'HOTEL-TEST',
}),
}),
)
expect(result.access_token).toBe('session-token')
expect(result.default_hotel_id).toBe('HOTEL-TEST')
})
it('restores the current user through auth me', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockJsonResponse(createAuthPayload()))
sessionStorage.setItem('th_hotel_access_token', 'session-token')
const result = await fetchCurrentAuth()
expect(fetchMock).toHaveBeenCalledWith(
'/api/auth/me',
expect.objectContaining({
method: 'GET',
headers: expect.objectContaining({
Authorization: 'Bearer session-token',
}),
}),
)
expect(result.user.username).toBe('admin')
})
it('logs out the current token without persisting secrets outside sessionStorage', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockJsonResponse({ success: true }))
sessionStorage.setItem('th_hotel_access_token', 'session-token')
const result = await logoutAuth()
expect(fetchMock).toHaveBeenCalledWith(
'/api/auth/logout',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer session-token',
}),
}),
)
expect(result.success).toBe(true)
expect(localStorage.getItem('th_hotel_access_token')).toBeNull()
})
})

View File

@@ -0,0 +1,127 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthStore } from '@/stores/authStore'
vi.mock('@/services/authService', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/services/authService')>()
return {
...actual,
fetchCurrentAuth: vi.fn(),
loginAuth: vi.fn(),
logoutAuth: vi.fn(),
}
})
const authService = await import('@/services/authService')
function createAuthPayload(overrides: Record<string, unknown> = {}) {
return {
access_token: 'session-token',
token_type: 'Bearer',
expires_at: '2026-07-09T12:00:00Z',
user: {
id: '1900000000000000001',
username: 'admin',
display_name: '系统管理员',
super_admin: true,
},
default_hotel_id: 'HOTEL-TEST',
hotels: [
{
hotel_id: 'HOTEL-TEST',
hotel_name: '测试酒店',
time_zone: 'Asia/Bangkok',
default_hotel: true,
},
{
hotel_id: 'HOTEL-BKK',
hotel_name: '曼谷酒店',
time_zone: 'Asia/Bangkok',
default_hotel: false,
},
],
permissions: ['RESERVATION_ORDER_READ', 'HOTEL_SWITCH'],
menus: [
{
menu_code: 'RESERVATION_ORDERS',
menu_name: '订单列表',
route_path: '/reservation/orders',
component_key: 'ReservationOrders',
icon_key: 'pi pi-list',
permission_code: 'RESERVATION_ORDER_READ',
sort_order: 10,
},
],
...overrides,
}
}
describe('authStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(authService.fetchCurrentAuth).mockReset()
vi.mocked(authService.loginAuth).mockReset()
vi.mocked(authService.logoutAuth).mockReset()
sessionStorage.clear()
localStorage.clear()
})
it('stores login token only in sessionStorage and applies default hotel context', async () => {
vi.mocked(authService.loginAuth).mockResolvedValue(createAuthPayload())
const store = useAuthStore()
await store.login({
username: 'admin',
password: 'Admin@123456',
})
expect(sessionStorage.getItem('th_hotel_access_token')).toBe('session-token')
expect(localStorage.getItem('th_hotel_access_token')).toBeNull()
expect(store.user?.username).toBe('admin')
expect(store.selectedHotelId).toBe('HOTEL-TEST')
expect(store.hasPermission('RESERVATION_ORDER_READ')).toBe(true)
})
it('restores auth state from an existing session token through auth me', async () => {
vi.mocked(authService.fetchCurrentAuth).mockResolvedValue(createAuthPayload({ access_token: undefined }))
sessionStorage.setItem('th_hotel_access_token', 'session-token')
const store = useAuthStore()
const restored = await store.restoreFromSession()
expect(restored).toBe(true)
expect(authService.fetchCurrentAuth).toHaveBeenCalledOnce()
expect(store.user?.display_name).toBe('系统管理员')
expect(store.selectedHotelId).toBe('HOTEL-TEST')
})
it('clears token and user context when restore fails', async () => {
vi.mocked(authService.fetchCurrentAuth).mockRejectedValue(new Error('AUTH_SESSION_INVALID'))
sessionStorage.setItem('th_hotel_access_token', 'expired-token')
const store = useAuthStore()
const restored = await store.restoreFromSession()
expect(restored).toBe(false)
expect(sessionStorage.getItem('th_hotel_access_token')).toBeNull()
expect(store.user).toBeNull()
expect(store.permissions).toEqual([])
})
it('clears local auth context even when backend logout fails', async () => {
vi.mocked(authService.loginAuth).mockResolvedValue(createAuthPayload())
vi.mocked(authService.logoutAuth).mockRejectedValue(new Error('network down'))
const store = useAuthStore()
await store.login({
username: 'admin',
password: 'Admin@123456',
})
await expect(store.logout()).resolves.toBeUndefined()
expect(sessionStorage.getItem('th_hotel_access_token')).toBeNull()
expect(store.user).toBeNull()
expect(store.permissions).toEqual([])
})
})

View File

@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getJson, setUnauthorizedHandler } from '@/services/httpClient'
const jsonHeaders = {
headers: {
get: (name: string) => (name.toLowerCase() === 'content-type' ? 'application/json' : null),
},
}
function mockJsonResponse(payload: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
json: async () => payload,
text: async () => JSON.stringify(payload),
...jsonHeaders,
} as Response
}
describe('httpClient auth integration', () => {
beforeEach(() => {
vi.restoreAllMocks()
sessionStorage.clear()
localStorage.clear()
setUnauthorizedHandler(null)
})
it('adds Authorization when a session token exists', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockJsonResponse({ ok: true }))
sessionStorage.setItem('th_hotel_access_token', 'session-token')
await getJson('/api/reservation/orders')
expect(fetchMock).toHaveBeenCalledWith(
'/api/reservation/orders',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer session-token',
}),
}),
)
})
it('runs the unauthorized handler for invalid auth sessions', async () => {
const unauthorizedHandler = vi.fn()
setUnauthorizedHandler(unauthorizedHandler)
sessionStorage.setItem('th_hotel_access_token', 'expired-token')
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
mockJsonResponse(
{
error_code: 'AUTH_SESSION_INVALID',
message: '登录已失效。',
},
401,
),
)
await expect(getJson('/api/auth/me')).rejects.toMatchObject({
status: 401,
})
expect(unauthorizedHandler).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,134 @@
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { createMemoryHistory, createRouter } from 'vue-router'
import zhCN from '@/i18n/locales/zh-CN'
import { ApiError } from '@/services/httpClient'
import { useAuthStore } from '@/stores/authStore'
import LoginView from '@/views/auth/LoginView.vue'
vi.mock('@/services/authService', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/services/authService')>()
return {
...actual,
loginAuth: vi.fn(),
}
})
const authService = await import('@/services/authService')
function createAuthPayload() {
return {
access_token: 'session-token',
token_type: 'Bearer',
expires_at: '2026-07-09T12:00:00Z',
user: {
id: '1900000000000000001',
username: 'admin',
display_name: '系统管理员',
super_admin: true,
},
default_hotel_id: 'HOTEL-TEST',
hotels: [
{
hotel_id: 'HOTEL-TEST',
hotel_name: '测试酒店',
time_zone: 'Asia/Bangkok',
default_hotel: true,
},
],
permissions: ['RESERVATION_ORDER_READ'],
menus: [
{
menu_code: 'RESERVATION_ORDERS',
menu_name: '订单列表',
route_path: '/reservation/orders',
component_key: 'ReservationOrders',
icon_key: 'pi pi-list',
permission_code: 'RESERVATION_ORDER_READ',
sort_order: 10,
},
],
}
}
async function mountLoginView(redirect = '/reservation/orders') {
const pinia = createPinia()
setActivePinia(pinia)
const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
messages: {
'zh-CN': zhCN,
},
})
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/login', component: LoginView },
{ path: '/reservation/orders', component: { template: '<div />' } },
{ path: '/reservation/tasks', component: { template: '<div />' } },
],
})
await router.push({ path: '/login', query: { redirect } })
await router.isReady()
const wrapper = mount(LoginView, {
global: {
plugins: [pinia, i18n, router],
},
})
return {
wrapper,
router,
authStore: useAuthStore(),
}
}
describe('LoginView', () => {
beforeEach(() => {
vi.mocked(authService.loginAuth).mockReset()
sessionStorage.clear()
localStorage.clear()
})
it('logs in and redirects to the original protected route', async () => {
vi.mocked(authService.loginAuth).mockResolvedValue(createAuthPayload())
const { wrapper, router, authStore } = await mountLoginView('/reservation/tasks')
await wrapper.find('input[name="username"]').setValue('admin')
await wrapper.find('input[name="password"]').setValue('Admin@123456')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(authService.loginAuth).toHaveBeenCalledWith({
username: 'admin',
password: 'Admin@123456',
preferred_hotel_id: undefined,
})
expect(authStore.user?.username).toBe('admin')
expect(router.currentRoute.value.fullPath).toBe('/reservation/tasks')
})
it('shows a safe login failure message without storing the token', async () => {
vi.mocked(authService.loginAuth).mockRejectedValue(
new ApiError('用户名或密码错误。', 401, {
error_code: 'AUTH_INVALID_CREDENTIALS',
message: '用户名或密码错误。',
}),
)
const { wrapper } = await mountLoginView()
await wrapper.find('input[name="username"]').setValue('admin')
await wrapper.find('input[name="password"]').setValue('wrong-password')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('用户名或密码错误')
expect(sessionStorage.getItem('th_hotel_access_token')).toBeNull()
expect(localStorage.getItem('th_hotel_access_token')).toBeNull()
})
})

View File

@@ -1,13 +1,15 @@
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { createPinia, setActivePinia } from 'pinia'
import { createI18n } from 'vue-i18n'
import { createMemoryHistory, createRouter } from 'vue-router'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ReservationAppShell from '@/layouts/ReservationAppShell.vue'
import enUS from '@/i18n/locales/en-US'
import thTH from '@/i18n/locales/th-TH'
import zhCN from '@/i18n/locales/zh-CN'
import { useAuthStore } from '@/stores/authStore'
import type { AuthMenuResult } from '@/types/auth'
vi.mock('@/services/healthService', () => ({
fetchBackendHealth: vi.fn().mockResolvedValue({
@@ -16,7 +18,68 @@ vi.mock('@/services/healthService', () => ({
}),
}))
async function mountShell() {
function createAuthPayload(menus: AuthMenuResult[]) {
return {
access_token: 'session-token',
token_type: 'Bearer',
expires_at: '2026-07-09T12:00:00Z',
user: {
id: '1900000000000000001',
username: 'admin',
display_name: '系统管理员',
super_admin: true,
},
default_hotel_id: 'HOTEL-TEST',
hotels: [
{
hotel_id: 'HOTEL-TEST',
hotel_name: '测试酒店',
time_zone: 'Asia/Bangkok',
default_hotel: true,
},
],
permissions: ['RESERVATION_ORDER_READ', 'RESERVATION_TASK_READ'],
menus,
}
}
function createReservationOrdersMenu(): AuthMenuResult {
return {
menu_code: 'RESERVATION_ORDERS',
menu_name: '订单列表',
route_path: '/reservation/orders',
component_key: 'ReservationOrders',
icon_key: 'pi pi-list',
permission_code: 'RESERVATION_ORDER_READ',
sort_order: 10,
}
}
function createReservationTasksMenu(): AuthMenuResult {
return {
menu_code: 'RESERVATION_TASKS',
menu_name: '任务队列',
route_path: '/reservation/tasks',
component_key: 'ReservationTasks',
icon_key: 'pi pi-check-square',
permission_code: 'RESERVATION_TASK_READ',
sort_order: 20,
}
}
function createDebugMenu(): AuthMenuResult {
return {
menu_code: 'DEBUG_EML_SUPERAGENT',
menu_name: 'Debug EML',
route_path: '/debug/eml-superagent',
component_key: 'DebugEmlSuperAgent',
icon_key: 'pi pi-upload',
permission_code: 'SYSTEM_DEBUG_EML_RUN',
sort_order: 30,
}
}
async function mountShell(menus = [createReservationOrdersMenu(), createReservationTasksMenu()]) {
const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
@@ -47,12 +110,15 @@ async function mountShell() {
},
],
})
const pinia = createPinia()
setActivePinia(pinia)
useAuthStore().applyLoginResult(createAuthPayload(menus))
await router.push('/reservation/orders')
await router.isReady()
return mount(ReservationAppShell, {
global: {
plugins: [i18n, router, createPinia()],
plugins: [i18n, router, pinia],
},
slots: {
default: '<section />',
@@ -61,6 +127,10 @@ async function mountShell() {
}
describe('ReservationAppShell', () => {
beforeEach(() => {
sessionStorage.clear()
})
it('switches between Chinese, English, and Thai labels', async () => {
const wrapper = await mountShell()
@@ -73,12 +143,25 @@ describe('ReservationAppShell', () => {
expect(wrapper.text()).toContain('Order list')
})
it('shows the Debug EML navigation entry before menu management takes over permissions', async () => {
it('renders navigation from backend menus and hides Debug EML when the menu is absent', async () => {
const wrapper = await mountShell()
const debugLink = wrapper.find('a[href="/debug/eml-superagent"]')
expect(wrapper.find('a[href="/reservation/orders"]').exists()).toBe(true)
expect(wrapper.find('a[href="/reservation/tasks"]').exists()).toBe(true)
expect(wrapper.find('a[href="/debug/eml-superagent"]').exists()).toBe(false)
})
expect(debugLink.exists()).toBe(true)
expect(debugLink.text()).toContain('Debug EML')
it('shows Debug EML only when backend menus include it', async () => {
const wrapper = await mountShell([createReservationOrdersMenu(), createDebugMenu()])
expect(wrapper.find('a[href="/debug/eml-superagent"]').exists()).toBe(true)
expect(wrapper.text()).toContain('Debug EML')
})
it('shows current user and hotel context in the top bar', async () => {
const wrapper = await mountShell()
expect(wrapper.text()).toContain('系统管理员')
expect(wrapper.text()).toContain('测试酒店')
})
})

View File

@@ -1,9 +1,40 @@
import { describe, expect, it } from 'vitest'
import type { RouteLocationNormalized } from 'vue-router'
import { router } from '@/router'
import { createAuthGuard, router } from '@/router'
import type { useAuthStore } from '@/stores/authStore'
type AuthStoreForGuard = ReturnType<typeof useAuthStore>
function createRoute(path: string, meta: Record<string, unknown> = {}): RouteLocationNormalized {
return {
fullPath: path,
path,
name: undefined,
params: {},
query: {},
hash: '',
matched: [],
meta,
redirectedFrom: undefined,
} as RouteLocationNormalized
}
function createAuthStoreForGuard(overrides: Partial<AuthStoreForGuard> = {}): AuthStoreForGuard {
return {
restoreAttempted: true,
isAuthenticated: true,
firstMenuPath: '/reservation/orders',
hasStoredToken: () => false,
restoreFromSession: async () => true,
hasPermission: () => true,
...overrides,
} as unknown as AuthStoreForGuard
}
describe('reservation router', () => {
it('exposes the P0 frontend routes', () => {
expect(router.resolve('/login').name).toBe('login')
expect(router.resolve('/reservation/orders').name).toBe('reservation-order-list')
expect(router.resolve('/reservation/tasks').name).toBe('reservation-task-list')
expect(router.resolve('/reservation/orders/20001').name).toBe('reservation-order-detail')
@@ -16,4 +47,42 @@ describe('reservation router', () => {
it('exposes the Debug EML route', () => {
expect(router.resolve('/debug/eml-superagent').name).toBe('debug-eml-superagent')
})
it('declares permissions for protected P0 routes', () => {
expect(router.resolve('/reservation/orders').meta.permission).toBe('RESERVATION_ORDER_READ')
expect(router.resolve('/reservation/orders/20001').meta.permission).toBe('RESERVATION_ORDER_READ')
expect(router.resolve('/reservation/tasks').meta.permission).toBe('RESERVATION_TASK_READ')
expect(router.resolve('/reservation/tasks/10001').meta.permission).toBe('RESERVATION_TASK_READ')
expect(router.resolve('/reservation/source-messages/30001/conversation').meta.permission).toBe(
'SOURCE_MESSAGE_ORIGINAL_READ',
)
expect(router.resolve('/debug/eml-superagent').meta.permission).toBe('SYSTEM_DEBUG_EML_RUN')
})
it('redirects anonymous users to login with the protected target as redirect', async () => {
const guard = createAuthGuard(() =>
createAuthStoreForGuard({
isAuthenticated: false,
}),
)
await expect(guard(createRoute('/reservation/orders', { permission: 'RESERVATION_ORDER_READ' }))).resolves.toEqual({
path: '/login',
query: {
redirect: '/reservation/orders',
},
})
})
it('redirects authenticated users without route permission to the unauthorized page', async () => {
const guard = createAuthGuard(() =>
createAuthStoreForGuard({
hasPermission: () => false,
}),
)
await expect(guard(createRoute('/debug/eml-superagent', { permission: 'SYSTEM_DEBUG_EML_RUN' }))).resolves.toEqual({
name: 'unauthorized',
})
})
})

View File

@@ -8,6 +8,7 @@ import {
fetchReservationTaskList,
fetchSourceMessageConversation,
} from '@/services/reservationService'
import { setReservationHotelIdProvider } from '@/config/reservationConfig'
const jsonHeaders = {
headers: {
@@ -28,6 +29,7 @@ function mockJsonResponse(payload: unknown): Response {
describe('reservationService real API mode', () => {
beforeEach(() => {
vi.restoreAllMocks()
setReservationHotelIdProvider(null)
})
it('fetches the task list from the backend by default', async () => {
@@ -102,6 +104,30 @@ describe('reservationService real API mode', () => {
)
})
it('uses the selected auth hotel before falling back to the environment hotel id', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
mockJsonResponse({
items: [],
page: {
page_num: 1,
page_size: 20,
total: 0,
},
}),
)
setReservationHotelIdProvider(() => 'HOTEL-BKK')
await fetchReservationTaskList({
page_num: 1,
page_size: 20,
})
expect(fetchMock).toHaveBeenCalledWith(
'/api/reservation/tasks?hotel_id=HOTEL-BKK&page_num=1&page_size=20',
expect.objectContaining({ method: 'GET' }),
)
})
it('fetches the order list from the backend by default', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
mockJsonResponse({

View File

@@ -1,10 +1,12 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { createMemoryHistory, createRouter } from 'vue-router'
import zhCN from '@/i18n/locales/zh-CN'
import ReservationTaskDetailPanel from '@/components/reservation/ReservationTaskDetailPanel.vue'
import { useAuthStore } from '@/stores/authStore'
import type {
ReservationManualReviewConversionResult,
ReservationOrderListItem,
@@ -163,7 +165,40 @@ function createRequiredField(overrides: Partial<ReservationTaskFieldResult> = {}
}
}
async function mountPanel() {
function createAuthPayload(permissions: string[]) {
return {
access_token: 'session-token',
token_type: 'Bearer',
expires_at: '2026-07-09T12:00:00Z',
user: {
id: '1900000000000000001',
username: 'admin',
display_name: '系统管理员',
super_admin: true,
},
default_hotel_id: 'HOTEL-TEST',
hotels: [
{
hotel_id: 'HOTEL-TEST',
hotel_name: '测试酒店',
time_zone: 'Asia/Bangkok',
default_hotel: true,
},
],
permissions,
menus: [],
}
}
const defaultTaskPermissions = [
'RESERVATION_TASK_READ',
'RESERVATION_TASK_EDIT',
'RESERVATION_TASK_CONFIRM',
'RESERVATION_OPERA_SIM_EXECUTE',
'RESERVATION_AUDIT_READ',
]
async function mountPanel(permissions = defaultTaskPermissions) {
const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
@@ -179,6 +214,9 @@ async function mountPanel() {
{ path: '/reservation/source-messages/:sourceMessageId/conversation', component: { template: '<div />' } },
],
})
const pinia = createPinia()
setActivePinia(pinia)
useAuthStore().applyLoginResult(createAuthPayload(permissions))
await router.push('/reservation/tasks/10001')
await router.isReady()
@@ -187,7 +225,7 @@ async function mountPanel() {
taskId: '10001',
},
global: {
plugins: [i18n, router],
plugins: [i18n, router, pinia],
stubs: {
RouterLink: {
template: '<a><slot /></a>',
@@ -222,6 +260,7 @@ describe('ReservationTaskDetailPanel', () => {
})
vi.mocked(service.fetchReservationOrders).mockResolvedValue(createOrderCandidateResult([]))
vi.mocked(service.convertReservationManualReviewTask).mockResolvedValue(createManualReviewConversionResult())
sessionStorage.clear()
})
it('shows draft save errors instead of leaving an unhandled rejection', async () => {
@@ -500,4 +539,45 @@ describe('ReservationTaskDetailPanel', () => {
target_order_id: '20002',
})
})
it('hides task processing buttons and audit timeline without permissions', async () => {
vi.mocked(service.fetchReservationTaskAudits).mockResolvedValue({
task_id: '10001',
items: [createAudit('audit-1', 'TASK_LOADED')],
})
vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue(
createTaskDetail({
availability: {
blocked: false,
read_only: false,
editable: true,
confirmable: true,
executable: true,
blocked_by_task_id: null,
blocked_reason: null,
},
opera_operations: [
{
operation_id: 'op-1',
task_id: '10001',
operation_sequence: 1,
operation_code: 'CREATE_RESERVATION',
operation_name: 'Create reservation',
operation_status: 'PENDING',
attempt_count: 0,
last_error_message: null,
attempts: [],
},
],
}),
)
const wrapper = await mountPanel(['RESERVATION_TASK_READ'])
expect(wrapper.text()).not.toContain('保存草稿')
expect(wrapper.text()).not.toContain('确认任务')
expect(wrapper.text()).not.toContain('审计时间线')
expect(wrapper.find('button[title="执行"]').exists()).toBe(false)
expect(wrapper.find('button[title="重试"]').exists()).toBe(false)
})
})