Files
Wyndham-RSVN/client/src/tests/reservationViews.spec.ts
T

873 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
})
})