Files
Wyndham-RSVN/client/src/tests/reservationViews.spec.ts
T
2026-07-20 15:16:21 +07:00

1133 lines
42 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 {
ReservationOrderDetailResult,
ReservationOrderListResult,
ReservationV4OrderTaskListResult,
ReservationV4WorkbenchListResult,
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(),
fetchReservationV4OrderTasks: vi.fn(),
fetchReservationV4WorkbenchItems: 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,
},
orderOverrides: Partial<ReservationOrderListResult['items'][number]> = {},
): ReservationOrderListResult {
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,
open_work_item_count: 2,
next_processable_task_id: '10002',
created_at: '2026-07-08T03:00:00Z',
updated_at: '2026-07-08T03:10:00Z',
...orderOverrides,
},
],
page,
}
}
function createV4WorkbenchResult(
displayOrderKey = 'GRP-001',
sourceSubject = 'Booking Request',
page: { page_num: number; page_size: number; total: number } = {
page_num: 1,
page_size: 20,
total: 1,
},
): ReservationV4WorkbenchListResult {
return {
items: [
{
item_type: 'ORDER_TASK',
target_id: displayOrderKey === 'GRP-NEW' ? '9002' : '9001',
hotel_id: 'HOTEL-TEST',
source_message_id: '30001',
source_message_summary: {
source_message_id: '30001',
hotel_id: 'HOTEL-TEST',
external_message_id: 'msg-30001',
external_conversation_id: 'thread-30001',
subject: sourceSubject,
sender_summary: 'guest@example.test',
received_at: '2026-07-08T03:00:00Z',
source_sent_at: null,
conversation_message_count: 2,
},
source_received_at: '2026-07-08T03:00:00Z',
display_order_key: displayOrderKey,
card_counts: {
total_count: 3,
readonly_count: 1,
pending_confirm_count: 1,
review_required_count: 1,
confirmed_count: 0,
},
next_action_card_id: '9101',
notification_status: null,
order_task_status: 'OPEN',
display_status: 'OPEN',
readonly_reason_code: null,
created_at: '2026-07-08T03:00:00Z',
updated_at: '2026-07-08T03:10:00Z',
},
],
page,
}
}
function createV4SourceNotificationWorkbenchResult(routeCode: 'S10' | 'S99' = 'S10'): ReservationV4WorkbenchListResult {
return {
items: [
{
item_type: 'SOURCE_NOTIFICATION',
target_id: routeCode === 'S10' ? '7001' : '7002',
hotel_id: 'HOTEL-TEST',
source_message_id: routeCode === 'S10' ? '39990' : '39999',
source_message_summary: {
source_message_id: routeCode === 'S10' ? '39990' : '39999',
hotel_id: 'HOTEL-TEST',
external_message_id: `msg-${routeCode}`,
external_conversation_id: 'thread-source-only',
subject: routeCode === 'S10' ? 'FYI only' : 'Unclear email',
sender_summary: 'guest@example.test',
received_at: '2026-07-08T03:00:00Z',
source_sent_at: null,
conversation_message_count: 1,
},
source_received_at: '2026-07-08T03:00:00Z',
display_order_key: null,
card_counts: null,
next_action_card_id: null,
notification_status: 'ACK_REQUIRED',
order_task_status: null,
display_status: 'ACK_REQUIRED',
readonly_reason_code: null,
created_at: '2026-07-08T03:00:00Z',
updated_at: '2026-07-08T03:10:00Z',
},
],
page: {
page_num: 1,
page_size: 20,
total: 1,
},
}
}
function createV4OrderTaskListResult(
displayOrderKey = 'GRP-002',
sourceSubject = 'Booking Update',
page: { page_num: number; page_size: number; total: number } = {
page_num: 1,
page_size: 20,
total: 1,
},
): ReservationV4OrderTaskListResult {
return {
items: [
{
order_task: {
order_task_id: displayOrderKey === 'GRP-NEW' ? '9002' : '9001',
hotel_id: 'HOTEL-TEST',
source_message_id: '30002',
ai_batch_id: '8001',
order_ref: 'order-1',
order_context_index: 1,
order_id: '20001',
target_booking_type: 'GROUP',
target_locator_type: 'GROUP_CODE',
target_locator_value: displayOrderKey,
target_resolution_status: 'RESOLVED',
order_task_status: 'OPEN',
display_order_key: displayOrderKey,
source_received_at: '2026-07-08T04:00:00Z',
version: 0,
created_at: '2026-07-08T04:00:00Z',
updated_at: '2026-07-08T04:10:00Z',
},
source_message_summary: {
source_message_id: '30002',
hotel_id: 'HOTEL-TEST',
external_message_id: 'msg-30002',
external_conversation_id: 'thread-30002',
subject: sourceSubject,
sender_summary: 'agent@example.test',
received_at: '2026-07-08T04:00:00Z',
source_sent_at: null,
conversation_message_count: 3,
},
card_counts: {
total_count: 2,
readonly_count: 1,
pending_confirm_count: 1,
review_required_count: 0,
confirmed_count: 0,
},
next_action_card_id: '9102',
display_status: 'OPEN',
availability: {
blocked: false,
read_only: false,
editable: true,
confirmable: true,
reviewable: false,
ackable: false,
readonly_reason_code: null,
blocked_by_order_task_id: null,
blocked_by_card_id: null,
blocked_reason: null,
},
},
],
page,
}
}
function createOrderDetailResult(displayName = 'GRP-001'): ReservationOrderDetailResult {
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',
},
],
v4_order_tasks: [
{
order_task_id: '9001',
order_ref: 'order-ref-1',
order_task_status: 'OPEN',
card_counts: {
total_count: 3,
readonly_count: 1,
pending_confirm_count: 1,
review_required_count: 1,
confirmed_count: 0,
},
cards: [
{
card_id: '9100',
card_type: 'SOURCE_MESSAGE_DISPLAY',
event_type: null,
source_event_index: 0,
card_sort_order: 0,
card_status: 'READONLY',
review_status: null,
confirmed_by: null,
confirmed_at: null,
latest_activity_at: '2026-07-08T05:05:00Z',
ai_payload_json: 'unsafe ai payload must stay hidden',
display_payload_json: 'unsafe display payload must stay hidden',
confirmed_payload_json: 'unsafe confirmed payload must stay hidden',
text_body: 'unsafe mail body must stay hidden',
attachment_url: 'https://oss.example.test/private.xlsx',
},
{
card_id: '9101',
card_type: 'BASIC_INFORMATION',
event_type: 'NEW_BOOKING',
source_event_index: 1,
card_sort_order: 10,
card_status: 'CONFIRMED',
review_status: null,
confirmed_by: 'frontend-query-admin',
confirmed_at: '2026-07-08T11:10:00Z',
latest_activity_at: '2026-07-08T11:10:00Z',
},
{
card_id: '9104',
card_type: 'PAYMENT',
event_type: 'PAYMENT',
source_event_index: 3,
card_sort_order: 60,
card_status: 'REVIEW_REQUIRED',
review_status: 'PENDING',
confirmed_by: null,
confirmed_at: null,
latest_activity_at: '2026-07-08T11:20:00Z',
},
],
source_message_summary: {
source_message_id: '30003',
hotel_id: 'HOTEL-TEST',
external_message_id: 'm-v4-1',
external_conversation_id: 'thread-v4-1',
subject: 'V4 Booking Package',
sender_summary: 'v4@example.test',
received_at: '2026-07-08T05:00:00Z',
source_sent_at: null,
conversation_message_count: 1,
},
source_received_at: '2026-07-08T05:00:00Z',
created_at: '2026-07-08T05:00:00Z',
updated_at: '2026-07-08T05:10:00Z',
latest_activity_at: '2026-07-08T05:10:00Z',
},
],
order_overview: {
account_code: 'QBD_TRAVEL',
account_name: 'Q.B.D. TRAVEL GROUP CO., LTD',
market_code: 'LEISURE',
source_code: 'TRAVEL_AGENT',
arrival_date: '2026-07-26',
departure_date: '2026-07-29',
rate_code: 'BAR',
room_items: [
{
room_type_code: 'RM1',
room_count: 2,
},
{
room_type_code: 'RM2',
room_count: 1,
},
],
trace_card_status: 'CONFIRMED',
rooming_list_card_status: 'PENDING_CONFIRM',
payment_card_status: 'REVIEW_REQUIRED',
latest_confirmed_at: '2026-07-08T11:20:00Z',
ai_payload_json: 'unsafe overview payload must stay hidden',
},
next_v4_action: {
order_task_id: '9001',
card_id: '9104',
action_type: 'REVIEW',
action_status: 'REVIEW_REQUIRED',
open_order_task_count: 1,
},
related_source_messages: [
{
source_message_id: '30003',
hotel_id: 'HOTEL-TEST',
external_message_id: 'm-v4-1',
external_conversation_id: 'thread-v4-1',
subject: 'V4 Booking Package',
sender_summary: 'v4@example.test',
received_at: '2026-07-08T05:00:00Z',
source_sent_at: null,
conversation_message_count: 1,
html_body: '<strong>unsafe related mail body</strong>',
attachment_url: 'https://oss.example.test/related.xlsx',
},
],
warnings: [],
} as unknown as ReservationOrderDetailResult
}
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/order-tasks/:orderTaskId', component: { template: '<div />' } },
{ path: '/reservation/source-notifications/:notificationId', 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: {
props: ['to'],
template: '<a :href="to"><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.fetchReservationV4OrderTasks).mockReset()
vi.mocked(service.fetchReservationV4WorkbenchItems).mockReset()
vi.mocked(service.fetchSourceMessageConversation).mockReset()
})
it('renders the V4 order overview, next action, related source messages and safe card timeline', async () => {
vi.mocked(service.fetchReservationOrderDetail).mockResolvedValue(createOrderDetailResult())
const wrapper = await mountWithPlugins(ReservationOrderDetailView, '/reservation/orders/20001')
await vi.dynamicImportSettled()
expect(wrapper.text()).toContain('V4 当前确认快照')
expect(wrapper.text()).toContain('QBD_TRAVEL')
expect(wrapper.text()).toContain('Q.B.D. TRAVEL GROUP CO., LTD')
expect(wrapper.text()).toContain('2026-07-26')
expect(wrapper.text()).toContain('2026-07-29')
expect(wrapper.text()).toContain('RM1 × 2')
expect(wrapper.text()).toContain('下一步处理')
expect(wrapper.find('a[href="/reservation/order-tasks/9001"]').text()).toContain('进入处理页')
expect(wrapper.find('.primary-link[href="/reservation/order-tasks/9001"]').exists()).toBe(true)
expect(wrapper.text()).toContain('关联来源消息')
expect(wrapper.text()).toContain('v4@example.test')
expect(wrapper.text()).toContain('查看邮件会话')
expect(wrapper.find('a[href="/reservation/source-messages/30003/conversation"]').exists()).toBe(true)
expect(wrapper.text()).toContain(zhCN.order.v4TaskTimeline)
expect(wrapper.text()).toContain('order-ref-1')
expect(wrapper.text()).toContain('V4 Booking Package')
expect(wrapper.find('.v4-timeline-item__main[href="/reservation/order-tasks/9001"]').exists()).toBe(true)
expect(wrapper.text()).toContain('基础信息卡')
expect(wrapper.text()).toContain('frontend-query-admin')
expect(wrapper.text()).toContain('待复核')
expect(wrapper.text()).not.toContain('unsafe ai payload must stay hidden')
expect(wrapper.text()).not.toContain('unsafe display payload must stay hidden')
expect(wrapper.text()).not.toContain('unsafe confirmed payload must stay hidden')
expect(wrapper.text()).not.toContain('unsafe mail body must stay hidden')
expect(wrapper.text()).not.toContain('https://oss.example.test')
expect(wrapper.findComponent({ name: 'ReservationTaskDetailPanel' }).exists()).toBe(false)
expect(wrapper.text()).not.toContain('来源邮件摘要字段待后端补充')
})
it('shows empty states when an order has no V4 overview, next action, source messages or task timeline', async () => {
const result = createOrderDetailResult()
result.order_overview = null
result.next_v4_action = {
order_task_id: null,
card_id: null,
action_type: 'NONE',
action_status: null,
open_order_task_count: 0,
}
result.related_source_messages = []
result.v4_order_tasks = []
vi.mocked(service.fetchReservationOrderDetail).mockResolvedValue(result)
const wrapper = await mountWithPlugins(ReservationOrderDetailView, '/reservation/orders/20001')
await vi.dynamicImportSettled()
expect(wrapper.text()).toContain('暂无已确认的 V4 订单快照')
expect(wrapper.text()).toContain('暂无下一步 V4 处理动作')
expect(wrapper.text()).toContain('暂无关联来源消息')
expect(wrapper.text()).toContain(zhCN.order.v4TaskTimelineEmpty)
expect(wrapper.find('a[href^="/reservation/order-tasks/"]').exists()).toBe(false)
})
it('renders order list items returned by the backend', async () => {
vi.mocked(service.fetchReservationOrders).mockResolvedValue(createOrderListResult())
const wrapper = await mountWithPlugins(ReservationOrderListView)
await vi.dynamicImportSettled()
const rowText = wrapper.find('tbody').text()
expect(wrapper.text()).toContain('GRP-001')
expect(rowText).not.toContain('20001')
expect(rowText).not.toContain('有效')
expect(wrapper.text()).not.toContain('未关闭任务')
expect(wrapper.text()).toContain('继续处理')
expect(wrapper.text()).not.toContain('接口待接入')
})
it('shows the backend unified open work item count in the order list', async () => {
vi.mocked(service.fetchReservationOrders).mockResolvedValue(createOrderListResult('GRP-001', {
page_num: 1,
page_size: 20,
total: 1,
}, {
open_task_count: 99,
v4_open_order_task_count: 88,
open_work_item_count: 3,
}))
const wrapper = await mountWithPlugins(ReservationOrderListView)
await vi.dynamicImportSettled()
const headers = wrapper.findAll('thead th').map((header) => header.text())
const cells = wrapper.findAll('tbody td').map((cell) => cell.text())
expect(headers).toContain('待处理')
expect(cells[1]).toBe('3')
expect(cells[1]).not.toContain('99')
expect(cells[1]).not.toContain('88')
})
it('routes continue action to the V4 order task when the backend returns next_v4_order_task_id', async () => {
vi.mocked(service.fetchReservationOrders).mockResolvedValue(createOrderListResult('GRP-001', {
page_num: 1,
page_size: 20,
total: 1,
}, {
next_v4_order_task_id: '9001',
next_v4_action_card_id: '9101',
next_v4_action_type: 'CONFIRM',
next_v4_action_status: 'PENDING_CONFIRM',
v4_open_order_task_count: 1,
}))
const wrapper = await mountWithPlugins(ReservationOrderListView)
await vi.dynamicImportSettled()
expect(wrapper.find('a[href="/reservation/orders/20001"]').text()).toContain('查看详情')
expect(wrapper.find('a[href="/reservation/order-tasks/9001"]').text()).toContain('继续处理')
expect(wrapper.find('a[href="/reservation/tasks/10002"]').exists()).toBe(false)
expect(wrapper.text()).toContain('待确认')
})
it('shows review state for V4 review entrypoints in the order list', async () => {
vi.mocked(service.fetchReservationOrders).mockResolvedValue(createOrderListResult('GRP-001', {
page_num: 1,
page_size: 20,
total: 1,
}, {
next_v4_order_task_id: '9002',
next_v4_action_card_id: '9102',
next_v4_action_type: 'REVIEW',
next_v4_action_status: 'REVIEW_REQUIRED',
v4_open_order_task_count: 1,
}))
const wrapper = await mountWithPlugins(ReservationOrderListView)
await vi.dynamicImportSettled()
expect(wrapper.find('a[href="/reservation/order-tasks/9002"]').text()).toContain('继续处理')
expect(wrapper.text()).toContain('待复核')
})
it('falls back to the legacy task detail route when there is no V4 order task entrypoint', async () => {
vi.mocked(service.fetchReservationOrders).mockResolvedValue(createOrderListResult())
const wrapper = await mountWithPlugins(ReservationOrderListView)
await vi.dynamicImportSettled()
expect(wrapper.find('a[href="/reservation/orders/20001"]').text()).toContain('查看详情')
expect(wrapper.find('a[href="/reservation/tasks/10002"]').text()).toContain('继续处理')
expect(wrapper.find('a[href="/reservation/order-tasks/9001"]').exists()).toBe(false)
})
it('shows no open task state when neither V4 nor legacy entrypoint exists', async () => {
vi.mocked(service.fetchReservationOrders).mockResolvedValue(createOrderListResult('GRP-001', {
page_num: 1,
page_size: 20,
total: 1,
}, {
open_task_count: 0,
open_work_item_count: 0,
next_processable_task_id: null,
next_v4_order_task_id: null,
next_v4_action_card_id: null,
next_v4_action_type: 'NONE',
next_v4_action_status: null,
v4_open_order_task_count: 0,
}))
const wrapper = await mountWithPlugins(ReservationOrderListView)
await vi.dynamicImportSettled()
expect(wrapper.find('a[href="/reservation/orders/20001"]').text()).toContain('查看详情')
expect(wrapper.find('a[href^="/reservation/tasks/"]').exists()).toBe(false)
expect(wrapper.find('a[href^="/reservation/order-tasks/"]').exists()).toBe(false)
expect(wrapper.text()).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 V4 workbench items returned by the backend', async () => {
vi.mocked(service.fetchReservationV4WorkbenchItems).mockResolvedValue(createV4WorkbenchResult('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('待确认 1')
expect(wrapper.text()).toContain('待复核 1')
expect(wrapper.text()).toContain('查看邮件会话')
expect(wrapper.text()).not.toContain('接口待补')
expect(service.fetchReservationV4WorkbenchItems).toHaveBeenCalledWith(
expect.objectContaining({
page_num: 1,
page_size: 20,
}),
)
expect(service.fetchReservationTaskList).not.toHaveBeenCalled()
})
it('renders task source received time in the source email column', async () => {
vi.mocked(service.fetchReservationV4WorkbenchItems).mockResolvedValue(createV4WorkbenchResult('GRP-001', 'Booking Request'))
const wrapper = await mountWithPlugins(ReservationTaskListView)
await vi.dynamicImportSettled()
expect(wrapper.text()).toContain('来源时间')
expect(wrapper.text()).toContain('2026-07-08 10:00:00')
expect(wrapper.text()).toContain('guest@example.test')
})
it('renders V4 S10/S99 source notification rows without a visible order action', async () => {
vi.mocked(service.fetchReservationV4WorkbenchItems).mockResolvedValue(createV4SourceNotificationWorkbenchResult('S10'))
const wrapper = await mountWithPlugins(ReservationTaskListView)
await vi.dynamicImportSettled()
expect(wrapper.text()).toContain('FYI only')
expect(wrapper.text()).toContain('来源通知')
expect(wrapper.text()).toContain('待确认已读')
expect(wrapper.text()).toContain('查看通知')
expect(wrapper.text()).toContain('查看邮件会话')
expect(wrapper.text()).not.toContain('查看订单')
})
it('filters V4 workbench with supported backend fields only', async () => {
vi.mocked(service.fetchReservationV4WorkbenchItems)
.mockResolvedValueOnce(createV4WorkbenchResult('GRP-001', 'Booking Request', { page_num: 1, page_size: 20, total: 45 }))
.mockResolvedValueOnce(createV4SourceNotificationWorkbenchResult('S10'))
const wrapper = await mountWithPlugins(ReservationTaskListView)
await vi.dynamicImportSettled()
const itemTypeOptions = wrapper.find('select[name="item_type"]').findAll('option')
.map((option) => option.attributes('value'))
expect(itemTypeOptions).toEqual(['', 'ORDER_TASK', 'SOURCE_NOTIFICATION'])
expect(wrapper.find('select[name="item_type"]').text()).toContain('来源通知')
expect(wrapper.find('select[name="item_type"]').text()).not.toContain('INFORMATIONAL_MESSAGE')
await wrapper.find('select[name="item_type"]').setValue('SOURCE_NOTIFICATION')
await vi.dynamicImportSettled()
expect(service.fetchReservationV4WorkbenchItems).toHaveBeenLastCalledWith(
expect.objectContaining({
item_type: 'SOURCE_NOTIFICATION',
page_num: 1,
}),
)
const lastTaskRequest = vi.mocked(service.fetchReservationV4WorkbenchItems).mock.calls[
vi.mocked(service.fetchReservationV4WorkbenchItems).mock.calls.length - 1
]?.[0] ?? {}
expect(Object.keys(lastTaskRequest).sort()).toEqual(['item_type', 'keyword', 'page_num', 'page_size'])
expect(lastTaskRequest).not.toHaveProperty('task_type')
expect(lastTaskRequest).not.toHaveProperty('task_subtype')
expect(lastTaskRequest).not.toHaveProperty('result_type')
expect(lastTaskRequest).not.toHaveProperty('route_code')
})
it('filters and paginates V4 business order tasks with supported backend fields', async () => {
vi.mocked(service.fetchReservationV4WorkbenchItems)
.mockResolvedValueOnce(createV4WorkbenchResult('GRP-001', 'Booking Request', { page_num: 1, page_size: 20, total: 45 }))
vi.mocked(service.fetchReservationV4OrderTasks)
.mockResolvedValueOnce(createV4OrderTaskListResult('GRP-001', 'Booking Request', { page_num: 1, page_size: 20, total: 45 }))
.mockResolvedValueOnce(createV4OrderTaskListResult('GRP-001', 'Booking Request', { page_num: 1, page_size: 20, total: 45 }))
.mockResolvedValueOnce(createV4OrderTaskListResult('GRP-001', 'Booking Request', { page_num: 1, page_size: 20, total: 45 }))
.mockResolvedValueOnce(createV4OrderTaskListResult('GRP-002', 'Booking Update', { page_num: 2, page_size: 20, total: 45 }))
const wrapper = await mountWithPlugins(ReservationTaskListView)
await vi.dynamicImportSettled()
expect(wrapper.find('select[name="order_task_status"]').attributes('disabled')).toBeDefined()
expect(wrapper.find('select[name="card_status"]').attributes('disabled')).toBeDefined()
await wrapper.find('select[name="item_type"]').setValue('ORDER_TASK')
await vi.dynamicImportSettled()
expect(wrapper.find('select[name="order_task_status"]').attributes('disabled')).toBeUndefined()
expect(wrapper.find('select[name="card_status"]').attributes('disabled')).toBeUndefined()
await wrapper.find('select[name="order_task_status"]').setValue('OPEN')
await vi.dynamicImportSettled()
await wrapper.find('select[name="card_status"]').setValue('REVIEW_REQUIRED')
await vi.dynamicImportSettled()
const taskListCalls = vi.mocked(service.fetchReservationV4OrderTasks).mock.calls
const filteredTaskRequest = taskListCalls[taskListCalls.length - 1]?.[0] ?? {}
expect(filteredTaskRequest).toMatchObject({
order_task_status: 'OPEN',
card_status: 'REVIEW_REQUIRED',
page_num: 1,
})
await wrapper.findAll('button').find((button) => button.text().includes('下一页'))?.trigger('click')
await vi.dynamicImportSettled()
expect(service.fetchReservationV4OrderTasks).toHaveBeenLastCalledWith(
expect.objectContaining({
order_task_status: 'OPEN',
card_status: 'REVIEW_REQUIRED',
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.fetchReservationV4WorkbenchItems).mockResolvedValue(
createV4WorkbenchResult('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.fetchReservationV4WorkbenchItems)
.mockResolvedValueOnce(createV4WorkbenchResult('GRP-001', 'Booking Request'))
.mockResolvedValueOnce(createV4WorkbenchResult('GRP-002', 'Booking Update'))
const wrapper = await mountWithPlugins(ReservationTaskListView)
await vi.dynamicImportSettled()
const authStore = useAuthStore()
authStore.setSelectedHotelId('HOTEL-BKK')
await vi.dynamicImportSettled()
expect(service.fetchReservationV4WorkbenchItems).toHaveBeenCalledTimes(2)
expect(service.fetchReservationV4WorkbenchItems).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.fetchReservationV4WorkbenchItems>>>()
const secondRequest = createDeferred<Awaited<ReturnType<typeof service.fetchReservationV4WorkbenchItems>>>()
vi.mocked(service.fetchReservationV4WorkbenchItems)
.mockReturnValueOnce(firstRequest.promise)
.mockReturnValueOnce(secondRequest.promise)
const wrapper = await mountWithPlugins(ReservationTaskListView)
await wrapper.find('input[type="search"]').setValue('fresh')
expect(service.fetchReservationV4WorkbenchItems).toHaveBeenCalledTimes(2)
secondRequest.resolve(createV4WorkbenchResult('GRP-NEW', 'New Booking Request'))
await flushPromises()
expect(wrapper.text()).toContain('GRP-NEW')
firstRequest.resolve(createV4WorkbenchResult('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()
})
})