873 lines
32 KiB
TypeScript
873 lines
32 KiB
TypeScript
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 ReservationOrderDetailView from '@/views/reservation/ReservationOrderDetailView.vue'
|
||
import ReservationOrderListView from '@/views/reservation/ReservationOrderListView.vue'
|
||
import ReservationSourceMessageConversationView from '@/views/reservation/ReservationSourceMessageConversationView.vue'
|
||
import ReservationTaskListView from '@/views/reservation/ReservationTaskListView.vue'
|
||
import { useAuthStore } from '@/stores/authStore'
|
||
import type { ReservationTaskListResult, SourceMessageConversationResult } from '@/types/reservation'
|
||
|
||
vi.mock('@/services/reservationService', async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import('@/services/reservationService')>()
|
||
return {
|
||
...actual,
|
||
fetchReservationOrderDetail: vi.fn(),
|
||
fetchReservationOrders: vi.fn(),
|
||
fetchReservationTaskList: vi.fn(),
|
||
fetchSourceMessageConversation: vi.fn(),
|
||
}
|
||
})
|
||
|
||
const service = await import('@/services/reservationService')
|
||
|
||
function createOrderListResult(
|
||
displayOrderKey = 'GRP-001',
|
||
page: { page_num: number; page_size: number; total: number } = {
|
||
page_num: 1,
|
||
page_size: 20,
|
||
total: 1,
|
||
},
|
||
) {
|
||
return {
|
||
items: [
|
||
{
|
||
order_id: displayOrderKey === 'GRP-002' ? '20002' : '20001',
|
||
hotel_id: 'HOTEL-TEST',
|
||
order_status: 'ACTIVE',
|
||
display_order_key: displayOrderKey,
|
||
temporary_order_no: null,
|
||
confirmation_number: null,
|
||
group_code: displayOrderKey,
|
||
display_name: displayOrderKey,
|
||
open_task_count: 2,
|
||
next_processable_task_id: '10002',
|
||
created_at: '2026-07-08T03:00:00Z',
|
||
updated_at: '2026-07-08T03:10:00Z',
|
||
},
|
||
],
|
||
page,
|
||
}
|
||
}
|
||
|
||
function createTaskListResult(
|
||
displayOrderKey: string,
|
||
sourceSubject: string,
|
||
page: { page_num: number; page_size: number; total: number } = {
|
||
page_num: 1,
|
||
page_size: 20,
|
||
total: 1,
|
||
},
|
||
): ReservationTaskListResult {
|
||
return {
|
||
items: [
|
||
{
|
||
task_id: displayOrderKey === 'GRP-NEW' ? '10002' : '10001',
|
||
order_id: '20001',
|
||
hotel_id: 'HOTEL-TEST',
|
||
display_order_key: displayOrderKey,
|
||
temporary_order_no: null,
|
||
task_type: 'NEW_BOOKING',
|
||
task_subtype: 'NEW_BOOKING',
|
||
task_status: 'PENDING_CONFIRM',
|
||
card_name: 'New Booking',
|
||
queue_sequence: 1,
|
||
queue_participation: true,
|
||
can_process: true,
|
||
readonly_reason_code: null,
|
||
source_message_id: '30001',
|
||
source_subject: sourceSubject,
|
||
source_sender_summary: null,
|
||
source_received_at: null,
|
||
external_conversation_id: null,
|
||
conversation_message_count: null,
|
||
created_at: '2026-07-08T03:00:00Z',
|
||
updated_at: '2026-07-08T03:10:00Z',
|
||
},
|
||
],
|
||
page,
|
||
}
|
||
}
|
||
|
||
function createSourceMessageOnlyTaskListResult(taskSubtype: 'S10' | 'S99' = 'S10') {
|
||
return {
|
||
items: [
|
||
{
|
||
task_id: taskSubtype === 'S10' ? '19990' : '19999',
|
||
order_id: taskSubtype === 'S10' ? '29990' : '29999',
|
||
hotel_id: 'HOTEL-TEST',
|
||
display_order_key: null,
|
||
temporary_order_no: null,
|
||
task_type: 'MESSAGE_NOTIFICATION',
|
||
task_subtype: taskSubtype,
|
||
result_type: 'source_message_review_notification',
|
||
ai_task_type: 'Message Notification',
|
||
route_code: taskSubtype,
|
||
system_process_category: 'SOURCE_MESSAGE_NOTIFICATION',
|
||
task_status: 'COMPLETED',
|
||
card_name: 'Message Notification',
|
||
queue_sequence: null,
|
||
queue_participation: false,
|
||
can_process: false,
|
||
readonly_reason_code: 'QUEUE_EXCLUDED',
|
||
source_message_id: taskSubtype === 'S10' ? '39990' : '39999',
|
||
source_subject: taskSubtype === 'S10' ? 'FYI only' : 'Unclear email',
|
||
source_sender_summary: 'guest@example.test',
|
||
source_received_at: '2026-07-08T03:00:00Z',
|
||
external_conversation_id: 'thread-source-only',
|
||
conversation_message_count: 1,
|
||
created_at: '2026-07-08T03:00:00Z',
|
||
updated_at: '2026-07-08T03:10:00Z',
|
||
},
|
||
],
|
||
page: {
|
||
page_num: 1,
|
||
page_size: 20,
|
||
total: 1,
|
||
},
|
||
}
|
||
}
|
||
|
||
function createParentGroupCancelAllotmentTaskListResult(): ReservationTaskListResult {
|
||
const result = createTaskListResult('PARENT-2608-BLOCK', 'Parent split confirmation')
|
||
result.items[0] = {
|
||
...result.items[0]!,
|
||
task_type: 'CANCEL_BOOKING',
|
||
task_subtype: 'cancel_allotment_control_block',
|
||
result_type: 'normal_task',
|
||
ai_task_type: 'Cancel Allotment',
|
||
route_code: 'R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_NORMAL',
|
||
system_process_category: 'BUSINESS_TASK',
|
||
card_name: 'CANCEL_ALLOTMENT',
|
||
}
|
||
return result
|
||
}
|
||
|
||
function createOrderDetailResult(displayName = 'GRP-001') {
|
||
return {
|
||
order: {
|
||
order_id: displayName === 'GRP-002' ? '20002' : '20001',
|
||
hotel_id: 'HOTEL-TEST',
|
||
order_status: 'ACTIVE',
|
||
temporary_order_no: null,
|
||
confirmation_number: null,
|
||
group_code: displayName,
|
||
block_code: null,
|
||
allotment_code: null,
|
||
display_name: displayName,
|
||
created_at: '2026-07-08T03:00:00Z',
|
||
updated_at: '2026-07-08T03:10:00Z',
|
||
},
|
||
tasks: [
|
||
{
|
||
task_id: '10001',
|
||
task_type: 'NEW_BOOKING',
|
||
task_subtype: 'NEW_BOOKING',
|
||
task_status: 'PENDING_CONFIRM',
|
||
card_name: 'New Booking',
|
||
queue_sequence: 1,
|
||
queue_participation: true,
|
||
can_process: true,
|
||
readonly_reason_code: null,
|
||
source_message_id: '30001',
|
||
source_subject: 'Booking Request',
|
||
source_sender_summary: 'guest@example.test',
|
||
source_received_at: '2026-07-08T03:00:00Z',
|
||
external_conversation_id: null,
|
||
conversation_message_count: 2,
|
||
created_at: '2026-07-08T03:00:00Z',
|
||
},
|
||
{
|
||
task_id: '10002',
|
||
task_type: 'UPDATE_BOOKING',
|
||
task_subtype: 'RATE_CHANGE',
|
||
task_status: 'BLOCKED',
|
||
card_name: 'Rate Change',
|
||
queue_sequence: 2,
|
||
queue_participation: true,
|
||
can_process: false,
|
||
readonly_reason_code: 'PREVIOUS_TASK_NOT_FINISHED',
|
||
source_message_id: '30002',
|
||
source_subject: 'Booking Update',
|
||
source_sender_summary: 'agent@example.test',
|
||
source_received_at: '2026-07-08T04:00:00Z',
|
||
external_conversation_id: null,
|
||
conversation_message_count: 3,
|
||
created_at: '2026-07-08T04:00:00Z',
|
||
},
|
||
],
|
||
warnings: [],
|
||
}
|
||
}
|
||
|
||
function createSourceMessageConversationResult(
|
||
overrides: Partial<SourceMessageConversationResult> = {},
|
||
): SourceMessageConversationResult {
|
||
const result = createSourceMessageConversationResultBase()
|
||
return {
|
||
...result,
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
function createSourceMessageConversationResultBase(): SourceMessageConversationResult {
|
||
return {
|
||
conversation: {
|
||
hotel_id: 'HOTEL-TEST',
|
||
channel: 'EMAIL',
|
||
external_conversation_id: 'thread-30001',
|
||
subject: 'Booking Request',
|
||
message_count: 2,
|
||
first_received_at: '2026-07-08T02:00:00Z',
|
||
last_received_at: '2026-07-08T03:00:00Z',
|
||
},
|
||
messages: [
|
||
{
|
||
id: '30001',
|
||
external_message_id: 'msg-30001',
|
||
external_conversation_id: 'thread-30001',
|
||
sender_summary: 'guest@example.test',
|
||
subject: 'Booking Request',
|
||
received_at: '2026-07-08T02:00:00Z',
|
||
source_sent_at: '2026-07-08T01:58:00Z',
|
||
text_body: '完整邮件正文',
|
||
html_body: '<script>alert("raw")</script><strong onclick="alert(1)">原始邮件正文</strong>',
|
||
html_body_sanitized: '<strong>清洗后邮件正文</strong>',
|
||
html_sanitize_required: true,
|
||
html_render_mode: 'SANITIZED_HTML',
|
||
inline_images: [],
|
||
attachments: [
|
||
{
|
||
mediaType: 'ATTACHMENT',
|
||
fileName: 'rooming-list.xlsx',
|
||
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
sizeBytes: 1024,
|
||
externalUrl: 'https://example.test/rooming-list.xlsx',
|
||
externalMediaId: 'media-1',
|
||
},
|
||
],
|
||
related_orders: [
|
||
{
|
||
order_id: '20001',
|
||
display_order_key: 'GRP-001',
|
||
order_status: 'ACTIVE',
|
||
},
|
||
],
|
||
related_tasks: [
|
||
{
|
||
task_id: '10001',
|
||
order_id: '20001',
|
||
task_type: 'NEW_BOOKING',
|
||
task_subtype: 'NEW_BOOKING',
|
||
task_status: 'PENDING_CONFIRM',
|
||
card_name: 'New Booking',
|
||
},
|
||
],
|
||
},
|
||
],
|
||
}
|
||
}
|
||
|
||
function createAttachment(overrides: Record<string, unknown> = {}) {
|
||
return {
|
||
mediaType: 'ATTACHMENT',
|
||
fileName: 'rooming-list.xlsx',
|
||
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
sizeBytes: 1024,
|
||
externalUrl: 'https://example.test/rooming-list.xlsx',
|
||
externalMediaId: 'media-1',
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
function createDeferred<T>() {
|
||
let resolve!: (value: T) => void
|
||
let reject!: (reason?: unknown) => void
|
||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||
resolve = promiseResolve
|
||
reject = promiseReject
|
||
})
|
||
return { promise, resolve, reject }
|
||
}
|
||
|
||
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,
|
||
},
|
||
{
|
||
hotel_id: 'HOTEL-BKK',
|
||
hotel_name: '曼谷酒店',
|
||
time_zone: 'Asia/Bangkok',
|
||
default_hotel: false,
|
||
},
|
||
],
|
||
permissions: ['HOTEL_SWITCH'],
|
||
menus: [],
|
||
}
|
||
}
|
||
|
||
async function mountWithPlugins(component: object, initialPath = '/', stubs: Record<string, unknown> = {}) {
|
||
const pinia = createPinia()
|
||
setActivePinia(pinia)
|
||
const authStore = useAuthStore()
|
||
authStore.applyLoginResult(createAuthPayload())
|
||
const i18n = createI18n({
|
||
legacy: false,
|
||
locale: 'zh-CN',
|
||
messages: {
|
||
'zh-CN': zhCN,
|
||
},
|
||
})
|
||
const router = createRouter({
|
||
history: createMemoryHistory(),
|
||
routes: [
|
||
{ path: '/', component: { template: '<div />' } },
|
||
{ path: '/reservation/orders/:orderId', component: { template: '<div />' } },
|
||
{ path: '/reservation/tasks/:taskId', component: { template: '<div />' } },
|
||
{ path: '/reservation/source-messages/:sourceMessageId/conversation', component: { template: '<div />' } },
|
||
],
|
||
})
|
||
await router.push(initialPath)
|
||
await router.isReady()
|
||
|
||
return mount(component, {
|
||
global: {
|
||
plugins: [pinia, i18n, router],
|
||
stubs: {
|
||
RouterLink: {
|
||
template: '<a><slot /></a>',
|
||
},
|
||
...stubs,
|
||
},
|
||
},
|
||
})
|
||
}
|
||
|
||
describe('reservation P0 views', () => {
|
||
beforeEach(() => {
|
||
sessionStorage.clear()
|
||
localStorage.clear()
|
||
vi.mocked(service.fetchReservationOrderDetail).mockReset()
|
||
vi.mocked(service.fetchReservationOrders).mockReset()
|
||
vi.mocked(service.fetchReservationTaskList).mockReset()
|
||
vi.mocked(service.fetchSourceMessageConversation).mockReset()
|
||
})
|
||
|
||
it('renders active order task source evidence without the stale pending copy', async () => {
|
||
vi.mocked(service.fetchReservationOrderDetail).mockResolvedValue(createOrderDetailResult())
|
||
|
||
const wrapper = await mountWithPlugins(ReservationOrderDetailView, '/reservation/orders/20001', {
|
||
ReservationTaskDetailPanel: {
|
||
template: '<section />',
|
||
},
|
||
})
|
||
await vi.dynamicImportSettled()
|
||
|
||
expect(wrapper.text()).toContain('Booking Request')
|
||
expect(wrapper.text()).toContain('guest@example.test')
|
||
expect(wrapper.text()).toContain('2')
|
||
expect(wrapper.text()).toContain('前序任务未完成')
|
||
expect(wrapper.text()).toContain('查看邮件会话')
|
||
expect(wrapper.text()).not.toContain('PREVIOUS_TASK_NOT_FINISHED')
|
||
expect(wrapper.text()).not.toContain('来源邮件摘要字段待后端补充')
|
||
})
|
||
|
||
it('renders order list items returned by the backend', async () => {
|
||
vi.mocked(service.fetchReservationOrders).mockResolvedValue(createOrderListResult())
|
||
|
||
const wrapper = await mountWithPlugins(ReservationOrderListView)
|
||
await vi.dynamicImportSettled()
|
||
|
||
expect(wrapper.text()).toContain('GRP-001')
|
||
expect(wrapper.text()).toContain('有效')
|
||
expect(wrapper.text()).toContain('继续处理')
|
||
expect(wrapper.text()).not.toContain('接口待接入')
|
||
})
|
||
|
||
it('filters order list with order status and business-key fields', async () => {
|
||
vi.mocked(service.fetchReservationOrders).mockResolvedValue(createOrderListResult())
|
||
|
||
const wrapper = await mountWithPlugins(ReservationOrderListView)
|
||
await vi.dynamicImportSettled()
|
||
|
||
await wrapper.find('select[name="order_status"]').setValue('LOGIC_DELETED')
|
||
await vi.dynamicImportSettled()
|
||
await wrapper.find('input[name="group_code"]').setValue('GRP-009')
|
||
await vi.dynamicImportSettled()
|
||
await wrapper.find('input[name="confirmation_number"]').setValue('CNF-009')
|
||
await vi.dynamicImportSettled()
|
||
|
||
expect(service.fetchReservationOrders).toHaveBeenLastCalledWith(
|
||
expect.objectContaining({
|
||
order_status: 'LOGIC_DELETED',
|
||
group_code: 'GRP-009',
|
||
confirmation_number: 'CNF-009',
|
||
page_num: 1,
|
||
}),
|
||
)
|
||
})
|
||
|
||
it('paginates order list requests with backend page metadata', async () => {
|
||
vi.mocked(service.fetchReservationOrders)
|
||
.mockResolvedValueOnce(createOrderListResult('GRP-001', { page_num: 1, page_size: 20, total: 41 }))
|
||
.mockResolvedValueOnce(createOrderListResult('GRP-002', { page_num: 2, page_size: 20, total: 41 }))
|
||
|
||
const wrapper = await mountWithPlugins(ReservationOrderListView)
|
||
await vi.dynamicImportSettled()
|
||
|
||
expect(wrapper.text()).toContain('第 1 / 3 页')
|
||
await wrapper.findAll('button').find((button) => button.text().includes('下一页'))?.trigger('click')
|
||
await vi.dynamicImportSettled()
|
||
|
||
expect(service.fetchReservationOrders).toHaveBeenLastCalledWith(
|
||
expect.objectContaining({
|
||
page_num: 2,
|
||
page_size: 20,
|
||
}),
|
||
)
|
||
expect(wrapper.text()).toContain('GRP-002')
|
||
expect(wrapper.text()).toContain('第 2 / 3 页')
|
||
})
|
||
|
||
it('renders backend-normalized order pagination metadata', async () => {
|
||
vi.mocked(service.fetchReservationOrders).mockResolvedValue(
|
||
createOrderListResult('GRP-001', { page_num: 3, page_size: 10, total: 31 }),
|
||
)
|
||
|
||
const wrapper = await mountWithPlugins(ReservationOrderListView)
|
||
await vi.dynamicImportSettled()
|
||
|
||
expect(wrapper.text()).toContain('第 3 / 4 页')
|
||
})
|
||
|
||
it('reloads the order list when the selected hotel changes', async () => {
|
||
vi.mocked(service.fetchReservationOrders)
|
||
.mockResolvedValueOnce(createOrderListResult('GRP-001'))
|
||
.mockResolvedValueOnce(createOrderListResult('GRP-002'))
|
||
|
||
const wrapper = await mountWithPlugins(ReservationOrderListView)
|
||
await vi.dynamicImportSettled()
|
||
const authStore = useAuthStore()
|
||
|
||
authStore.setSelectedHotelId('HOTEL-BKK')
|
||
await vi.dynamicImportSettled()
|
||
|
||
expect(service.fetchReservationOrders).toHaveBeenCalledTimes(2)
|
||
expect(service.fetchReservationOrders).toHaveBeenLastCalledWith(
|
||
expect.objectContaining({
|
||
page_num: 1,
|
||
}),
|
||
)
|
||
expect(wrapper.text()).toContain('GRP-002')
|
||
})
|
||
|
||
it('renders task list items returned by the backend', async () => {
|
||
vi.mocked(service.fetchReservationTaskList).mockResolvedValue(createTaskListResult('GRP-001', 'Booking Request'))
|
||
|
||
const wrapper = await mountWithPlugins(ReservationTaskListView)
|
||
await vi.dynamicImportSettled()
|
||
|
||
expect(wrapper.text()).toContain('GRP-001')
|
||
expect(wrapper.text()).toContain('Booking Request')
|
||
expect(wrapper.text()).toContain('新预订')
|
||
expect(wrapper.text()).toContain('查看邮件会话')
|
||
expect(wrapper.text()).not.toContain('接口待补')
|
||
expect(wrapper.text()).not.toContain('NEW_BOOKING / NEW_BOOKING')
|
||
})
|
||
|
||
it('renders task source received time in the source email column', async () => {
|
||
const taskListResult = createTaskListResult('GRP-001', 'Booking Request')
|
||
taskListResult.items[0] = {
|
||
...taskListResult.items[0]!,
|
||
source_sender_summary: 'guest@example.test',
|
||
source_received_at: '2026-07-08T03:00:00Z',
|
||
}
|
||
vi.mocked(service.fetchReservationTaskList).mockResolvedValue(taskListResult)
|
||
|
||
const wrapper = await mountWithPlugins(ReservationTaskListView)
|
||
await vi.dynamicImportSettled()
|
||
|
||
expect(wrapper.text()).toContain('来源时间')
|
||
expect(wrapper.text()).toContain('2026-07-08 10:00:00')
|
||
})
|
||
|
||
it('filters source-message-only tasks with S10 and S99 subtypes', async () => {
|
||
vi.mocked(service.fetchReservationTaskList).mockResolvedValue(createSourceMessageOnlyTaskListResult('S10'))
|
||
|
||
const wrapper = await mountWithPlugins(ReservationTaskListView)
|
||
await vi.dynamicImportSettled()
|
||
|
||
const taskTypeOptions = wrapper.find('select[name="task_type"]').findAll('option')
|
||
.map((option) => option.attributes('value'))
|
||
const taskSubtypeOptions = wrapper.find('select[name="task_subtype"]').findAll('option')
|
||
.map((option) => option.attributes('value'))
|
||
|
||
expect(taskTypeOptions).toEqual([
|
||
'',
|
||
'NEW_BOOKING',
|
||
'UPDATE_BOOKING',
|
||
'CANCEL_BOOKING',
|
||
'MANUAL_REVIEW',
|
||
'SOURCE_MESSAGE_ONLY',
|
||
])
|
||
expect(taskTypeOptions).not.toContain('INFORMATIONAL_MESSAGE')
|
||
expect(taskSubtypeOptions).toEqual([
|
||
'',
|
||
'NEW_BOOKING',
|
||
'UPDATE_BOOKING',
|
||
'CANCEL_BOOKING',
|
||
'RATE_CHANGE',
|
||
'UPDATE_STAY_DATES',
|
||
'UPDATE_ROOM_TYPE',
|
||
'UPDATE_FIX_CHARGE',
|
||
'NEW_GROUP_BLOCK',
|
||
'NEW_FIT_RESERVATION',
|
||
'MANUAL_REVIEW',
|
||
'S10',
|
||
'S99',
|
||
])
|
||
expect(taskSubtypeOptions).not.toContain('S000')
|
||
expect(taskSubtypeOptions).not.toContain('S999')
|
||
expect(taskSubtypeOptions).not.toContain('linked_parent_release_after_child_split')
|
||
expect(wrapper.find('select[name="task_type"]').text()).toContain('来源消息只读')
|
||
expect(wrapper.find('select[name="task_subtype"]').text()).toContain('纯信息类邮件(S10)')
|
||
expect(wrapper.find('select[name="task_subtype"]').text()).toContain('无法形成业务素材包(S99)')
|
||
|
||
await wrapper.find('select[name="task_type"]').setValue('SOURCE_MESSAGE_ONLY')
|
||
await vi.dynamicImportSettled()
|
||
await wrapper.find('select[name="task_subtype"]').setValue('S99')
|
||
await vi.dynamicImportSettled()
|
||
|
||
expect(service.fetchReservationTaskList).toHaveBeenLastCalledWith(
|
||
expect.objectContaining({
|
||
task_type: 'SOURCE_MESSAGE_ONLY',
|
||
task_subtype: 'S99',
|
||
page_num: 1,
|
||
}),
|
||
)
|
||
const lastTaskRequest = vi.mocked(service.fetchReservationTaskList).mock.calls[
|
||
vi.mocked(service.fetchReservationTaskList).mock.calls.length - 1
|
||
]?.[0] ?? {}
|
||
expect(Object.keys(lastTaskRequest).sort()).toEqual([
|
||
'keyword',
|
||
'order_status',
|
||
'page_num',
|
||
'page_size',
|
||
'queue_participation',
|
||
'task_status',
|
||
'task_subtype',
|
||
'task_type',
|
||
])
|
||
expect(lastTaskRequest).not.toHaveProperty('result_type')
|
||
expect(lastTaskRequest).not.toHaveProperty('route_code')
|
||
expect(lastTaskRequest).not.toHaveProperty('system_process_category')
|
||
})
|
||
|
||
it('renders R08 parent group cancel allotment as a business card', async () => {
|
||
vi.mocked(service.fetchReservationTaskList).mockResolvedValue(createParentGroupCancelAllotmentTaskListResult())
|
||
|
||
const wrapper = await mountWithPlugins(ReservationTaskListView)
|
||
await vi.dynamicImportSettled()
|
||
|
||
expect(wrapper.text()).toContain('PARENT-2608-BLOCK')
|
||
expect(wrapper.text()).toContain('Parent Group')
|
||
expect(wrapper.text()).toContain('取消 Parent Group 配额')
|
||
expect(wrapper.text()).toContain('取消 Parent Group 整块配额')
|
||
expect(wrapper.text()).toContain('查看订单')
|
||
expect(wrapper.text()).not.toContain('适配契约异常')
|
||
expect(wrapper.text()).not.toContain('linked_parent_release_after_child_split')
|
||
})
|
||
|
||
it('renders source-message-only task rows without a visible order action', async () => {
|
||
vi.mocked(service.fetchReservationTaskList).mockResolvedValue(createSourceMessageOnlyTaskListResult('S10'))
|
||
|
||
const wrapper = await mountWithPlugins(ReservationTaskListView)
|
||
await vi.dynamicImportSettled()
|
||
|
||
expect(wrapper.text()).toContain('来源消息只读')
|
||
expect(wrapper.text()).toContain('纯信息类邮件(S10)')
|
||
expect(wrapper.text()).toContain('来源消息通知')
|
||
expect(wrapper.text()).toContain('FYI only')
|
||
expect(wrapper.text()).toContain('查看任务')
|
||
expect(wrapper.text()).toContain('查看邮件会话')
|
||
expect(wrapper.text()).not.toContain('查看订单')
|
||
expect(wrapper.text()).not.toContain('29990')
|
||
})
|
||
|
||
it('does not treat a business task as source-message-only only because subtype is S10', async () => {
|
||
const result = createTaskListResult('GRP-S10-BUSINESS', 'Business booking request')
|
||
result.items[0] = {
|
||
...result.items[0]!,
|
||
task_type: 'NEW_BOOKING',
|
||
task_subtype: 'S10',
|
||
result_type: 'normal_task',
|
||
ai_task_type: 'NEW_BOOKING',
|
||
route_code: 'S10',
|
||
system_process_category: 'BUSINESS_TASK',
|
||
card_name: 'New Booking',
|
||
}
|
||
vi.mocked(service.fetchReservationTaskList).mockResolvedValue(result)
|
||
|
||
const wrapper = await mountWithPlugins(ReservationTaskListView)
|
||
await vi.dynamicImportSettled()
|
||
|
||
const rowText = wrapper.find('tbody').text()
|
||
expect(rowText).toContain('GRP-S10-BUSINESS')
|
||
expect(rowText).toContain('20001')
|
||
expect(rowText).toContain('查看订单')
|
||
expect(rowText).not.toContain('来源消息只读')
|
||
expect(rowText).not.toContain('不关联可见订单')
|
||
})
|
||
|
||
it('filters and paginates task list with supported backend fields', async () => {
|
||
vi.mocked(service.fetchReservationTaskList)
|
||
.mockResolvedValueOnce(createTaskListResult('GRP-001', 'Booking Request', { page_num: 1, page_size: 20, total: 45 }))
|
||
.mockResolvedValueOnce(createTaskListResult('GRP-001', 'Booking Request', { page_num: 1, page_size: 20, total: 45 }))
|
||
.mockResolvedValueOnce(createTaskListResult('GRP-001', 'Booking Request', { page_num: 1, page_size: 20, total: 45 }))
|
||
.mockResolvedValueOnce(createTaskListResult('GRP-001', 'Booking Request', { page_num: 1, page_size: 20, total: 45 }))
|
||
.mockResolvedValueOnce(createTaskListResult('GRP-001', 'Booking Request', { page_num: 1, page_size: 20, total: 45 }))
|
||
.mockResolvedValueOnce(createTaskListResult('GRP-002', 'Booking Update', { page_num: 2, page_size: 20, total: 45 }))
|
||
|
||
const wrapper = await mountWithPlugins(ReservationTaskListView)
|
||
await vi.dynamicImportSettled()
|
||
|
||
expect(wrapper.text()).not.toContain('订单状态筛选待后端接入')
|
||
expect(wrapper.find('select[name="order_status"]').attributes('disabled')).toBeUndefined()
|
||
expect(wrapper.find('select[name="task_status"]').text()).toContain('执行中')
|
||
expect(wrapper.find('select[name="task_status"]').text()).toContain('失败')
|
||
|
||
await wrapper.find('select[name="task_status"]').setValue('FAILED')
|
||
await vi.dynamicImportSettled()
|
||
await wrapper.find('select[name="task_subtype"]').setValue('RATE_CHANGE')
|
||
await vi.dynamicImportSettled()
|
||
await wrapper.find('select[name="order_status"]').setValue('ENDED')
|
||
await vi.dynamicImportSettled()
|
||
await wrapper.find('select[name="queue_participation"]').setValue('true')
|
||
await vi.dynamicImportSettled()
|
||
|
||
const taskListCalls = vi.mocked(service.fetchReservationTaskList).mock.calls
|
||
const filteredTaskRequest = taskListCalls[taskListCalls.length - 1]?.[0] ?? {}
|
||
expect(filteredTaskRequest).toMatchObject({
|
||
task_status: 'FAILED',
|
||
task_subtype: 'RATE_CHANGE',
|
||
order_status: 'ENDED',
|
||
queue_participation: true,
|
||
page_num: 1,
|
||
})
|
||
|
||
await wrapper.findAll('button').find((button) => button.text().includes('下一页'))?.trigger('click')
|
||
await vi.dynamicImportSettled()
|
||
|
||
expect(service.fetchReservationTaskList).toHaveBeenLastCalledWith(
|
||
expect.objectContaining({
|
||
task_status: 'FAILED',
|
||
task_subtype: 'RATE_CHANGE',
|
||
order_status: 'ENDED',
|
||
queue_participation: true,
|
||
page_num: 2,
|
||
page_size: 20,
|
||
}),
|
||
)
|
||
expect(wrapper.text()).toContain('GRP-002')
|
||
expect(wrapper.text()).toContain('第 2 / 3 页')
|
||
})
|
||
|
||
it('renders backend-normalized task pagination metadata', async () => {
|
||
vi.mocked(service.fetchReservationTaskList).mockResolvedValue(
|
||
createTaskListResult('GRP-001', 'Booking Request', { page_num: 2, page_size: 10, total: 21 }),
|
||
)
|
||
|
||
const wrapper = await mountWithPlugins(ReservationTaskListView)
|
||
await vi.dynamicImportSettled()
|
||
|
||
expect(wrapper.text()).toContain('第 2 / 3 页')
|
||
})
|
||
|
||
it('reloads the task list when the selected hotel changes', async () => {
|
||
vi.mocked(service.fetchReservationTaskList)
|
||
.mockResolvedValueOnce(createTaskListResult('GRP-001', 'Booking Request'))
|
||
.mockResolvedValueOnce(createTaskListResult('GRP-002', 'Booking Update'))
|
||
|
||
const wrapper = await mountWithPlugins(ReservationTaskListView)
|
||
await vi.dynamicImportSettled()
|
||
const authStore = useAuthStore()
|
||
|
||
authStore.setSelectedHotelId('HOTEL-BKK')
|
||
await vi.dynamicImportSettled()
|
||
|
||
expect(service.fetchReservationTaskList).toHaveBeenCalledTimes(2)
|
||
expect(service.fetchReservationTaskList).toHaveBeenLastCalledWith(
|
||
expect.objectContaining({
|
||
page_num: 1,
|
||
}),
|
||
)
|
||
expect(wrapper.text()).toContain('Booking Update')
|
||
})
|
||
|
||
it('reloads order detail when the selected hotel changes', async () => {
|
||
vi.mocked(service.fetchReservationOrderDetail)
|
||
.mockResolvedValueOnce(createOrderDetailResult('GRP-001'))
|
||
.mockResolvedValueOnce(createOrderDetailResult('GRP-002'))
|
||
|
||
const wrapper = await mountWithPlugins(ReservationOrderDetailView, '/reservation/orders/20001', {
|
||
ReservationTaskDetailPanel: {
|
||
template: '<section />',
|
||
},
|
||
})
|
||
await vi.dynamicImportSettled()
|
||
const authStore = useAuthStore()
|
||
|
||
authStore.setSelectedHotelId('HOTEL-BKK')
|
||
await vi.dynamicImportSettled()
|
||
|
||
expect(service.fetchReservationOrderDetail).toHaveBeenCalledTimes(2)
|
||
expect(service.fetchReservationOrderDetail).toHaveBeenLastCalledWith('20001')
|
||
expect(wrapper.text()).toContain('GRP-002')
|
||
})
|
||
|
||
it('keeps the latest task list response when filters change quickly', async () => {
|
||
const firstRequest = createDeferred<Awaited<ReturnType<typeof service.fetchReservationTaskList>>>()
|
||
const secondRequest = createDeferred<Awaited<ReturnType<typeof service.fetchReservationTaskList>>>()
|
||
vi.mocked(service.fetchReservationTaskList)
|
||
.mockReturnValueOnce(firstRequest.promise)
|
||
.mockReturnValueOnce(secondRequest.promise)
|
||
|
||
const wrapper = await mountWithPlugins(ReservationTaskListView)
|
||
await wrapper.find('input[type="search"]').setValue('fresh')
|
||
expect(service.fetchReservationTaskList).toHaveBeenCalledTimes(2)
|
||
|
||
secondRequest.resolve(createTaskListResult('GRP-NEW', 'New Booking Request'))
|
||
await flushPromises()
|
||
expect(wrapper.text()).toContain('GRP-NEW')
|
||
|
||
firstRequest.resolve(createTaskListResult('GRP-OLD', 'Old Booking Request'))
|
||
await flushPromises()
|
||
expect(wrapper.text()).toContain('GRP-NEW')
|
||
expect(wrapper.text()).not.toContain('GRP-OLD')
|
||
})
|
||
|
||
it('renders full source message conversation returned by the backend', async () => {
|
||
vi.mocked(service.fetchSourceMessageConversation).mockResolvedValue(createSourceMessageConversationResult())
|
||
|
||
const wrapper = await mountWithPlugins(
|
||
ReservationSourceMessageConversationView,
|
||
'/reservation/source-messages/30001/conversation',
|
||
)
|
||
await vi.dynamicImportSettled()
|
||
|
||
expect(service.fetchSourceMessageConversation).toHaveBeenCalledWith('30001')
|
||
expect(wrapper.text()).toContain('Booking Request')
|
||
expect(wrapper.text()).toContain('清洗后邮件正文')
|
||
expect(wrapper.text()).toContain('rooming-list.xlsx')
|
||
expect(wrapper.find('.message-body').html()).toContain('<strong>清洗后邮件正文</strong>')
|
||
expect(wrapper.find('.message-body').html()).not.toContain('<script>')
|
||
expect(wrapper.find('.message-body').html()).not.toContain('onclick')
|
||
expect(wrapper.text()).not.toContain('原始邮件正文')
|
||
expect(wrapper.text()).not.toContain('接口待接入')
|
||
})
|
||
|
||
it('falls back to text when sanitized html is unavailable', async () => {
|
||
const conversationResult = createSourceMessageConversationResult()
|
||
conversationResult.messages[0]!.html_body = '<strong>原始 HTML 不上屏</strong>'
|
||
conversationResult.messages[0]!.html_body_sanitized = null
|
||
conversationResult.messages[0]!.html_render_mode = 'TEXT_ONLY'
|
||
conversationResult.messages[0]!.text_body = '纯文本回退正文'
|
||
vi.mocked(service.fetchSourceMessageConversation).mockResolvedValue(conversationResult)
|
||
|
||
const wrapper = await mountWithPlugins(
|
||
ReservationSourceMessageConversationView,
|
||
'/reservation/source-messages/30001/conversation',
|
||
)
|
||
await vi.dynamicImportSettled()
|
||
|
||
expect(wrapper.text()).toContain('纯文本回退正文')
|
||
expect(wrapper.text()).not.toContain('原始 HTML 不上屏')
|
||
expect(wrapper.find('.message-body--html').exists()).toBe(false)
|
||
})
|
||
|
||
it('does not expose unsafe attachment URLs as links', async () => {
|
||
const conversationResult = createSourceMessageConversationResult()
|
||
conversationResult.messages[0]!.attachments = [
|
||
createAttachment({
|
||
fileName: 'unsafe.html',
|
||
externalUrl: 'javascript:alert(1)',
|
||
externalMediaId: 'unsafe-media',
|
||
}),
|
||
]
|
||
vi.mocked(service.fetchSourceMessageConversation).mockResolvedValue(conversationResult)
|
||
|
||
const wrapper = await mountWithPlugins(
|
||
ReservationSourceMessageConversationView,
|
||
'/reservation/source-messages/30001/conversation',
|
||
)
|
||
await vi.dynamicImportSettled()
|
||
|
||
expect(wrapper.text()).toContain('unsafe.html')
|
||
const unsafeLink = wrapper.findAll('a').find((link) => link.text().includes('unsafe.html'))
|
||
expect(unsafeLink).toBeUndefined()
|
||
})
|
||
|
||
it('does not warn about duplicate attachment keys when unnamed media changes', async () => {
|
||
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||
const firstResult = createSourceMessageConversationResult()
|
||
firstResult.messages[0]!.attachments = [
|
||
createAttachment({
|
||
fileName: null,
|
||
externalUrl: null,
|
||
externalMediaId: null,
|
||
}),
|
||
]
|
||
const secondResult = createSourceMessageConversationResult()
|
||
secondResult.messages[0]!.attachments = [
|
||
createAttachment({
|
||
fileName: null,
|
||
externalUrl: null,
|
||
externalMediaId: null,
|
||
}),
|
||
createAttachment({
|
||
fileName: null,
|
||
externalUrl: null,
|
||
externalMediaId: null,
|
||
}),
|
||
]
|
||
vi.mocked(service.fetchSourceMessageConversation)
|
||
.mockResolvedValueOnce(firstResult)
|
||
.mockResolvedValueOnce(secondResult)
|
||
|
||
const wrapper = await mountWithPlugins(
|
||
ReservationSourceMessageConversationView,
|
||
'/reservation/source-messages/30001/conversation',
|
||
)
|
||
await vi.dynamicImportSettled()
|
||
await wrapper.vm.$router.push('/reservation/source-messages/30002/conversation')
|
||
await vi.dynamicImportSettled()
|
||
|
||
expect(wrapper.text()).toContain('未命名附件')
|
||
expect([...consoleWarnSpy.mock.calls, ...consoleErrorSpy.mock.calls].flat().join('\n')).not.toContain(
|
||
'Duplicate keys',
|
||
)
|
||
consoleWarnSpy.mockRestore()
|
||
consoleErrorSpy.mockRestore()
|
||
})
|
||
})
|