接入前端登录权限底座
This commit is contained in:
125
client/src/tests/authService.spec.ts
Normal file
125
client/src/tests/authService.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
127
client/src/tests/authStore.spec.ts
Normal file
127
client/src/tests/authStore.spec.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
65
client/src/tests/httpClientAuth.spec.ts
Normal file
65
client/src/tests/httpClientAuth.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
134
client/src/tests/loginView.spec.ts
Normal file
134
client/src/tests/loginView.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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('测试酒店')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user