接入前端真实接口并标记邮件HTML清洗
This commit is contained in:
@@ -74,6 +74,7 @@ function mountRenderer(readOnly = false) {
|
||||
'room.type': '豪华 Q1A',
|
||||
},
|
||||
readOnly,
|
||||
validationErrors: {},
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
@@ -107,4 +108,34 @@ describe('ReservationTaskFieldRenderer', () => {
|
||||
expect(wrapper.find('textarea').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('王建国')
|
||||
})
|
||||
|
||||
it('shows validation errors next to their fields', () => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'zh-CN',
|
||||
messages: {
|
||||
'zh-CN': zhCN,
|
||||
},
|
||||
})
|
||||
|
||||
const wrapper = mount(ReservationTaskFieldRenderer, {
|
||||
props: {
|
||||
fields,
|
||||
modelValue: {
|
||||
'guest.name': '',
|
||||
'room.type': '豪华 Q1A',
|
||||
},
|
||||
readOnly: false,
|
||||
validationErrors: {
|
||||
'guest.name': '客人姓名为必填项',
|
||||
},
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('客人姓名为必填项')
|
||||
expect(wrapper.find('textarea').attributes('aria-invalid')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
150
client/src/tests/reservationFieldRules.spec.ts
Normal file
150
client/src/tests/reservationFieldRules.spec.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ReservationTaskFieldResult } from '@/types/reservation'
|
||||
import {
|
||||
buildEditableFieldValues,
|
||||
groupReservationFields,
|
||||
validateReservationFieldValues,
|
||||
} from '@/utils/reservationFieldRules'
|
||||
|
||||
function createField(overrides: Partial<ReservationTaskFieldResult>): ReservationTaskFieldResult {
|
||||
return {
|
||||
row_number: 1,
|
||||
card_name: 'New Booking',
|
||||
display_area: '基础信息',
|
||||
field_path: 'guest.name',
|
||||
display_name: '客人姓名',
|
||||
visible: 'Y',
|
||||
editable: 'Y',
|
||||
input_editable: 'Y',
|
||||
select_editable: 'N',
|
||||
date_picker: 'N',
|
||||
number_input: 'N',
|
||||
file_display: 'N',
|
||||
table_editable: 'N',
|
||||
enum_options: null,
|
||||
required_rule: null,
|
||||
display_condition: null,
|
||||
validation_rule: null,
|
||||
write_path: null,
|
||||
opera_write_participation: 'N',
|
||||
opera_parameter_mapping: null,
|
||||
notes: null,
|
||||
value: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('validateReservationFieldValues', () => {
|
||||
it('validates required, enum, date, and number fields from backend metadata', () => {
|
||||
const errors = validateReservationFieldValues(
|
||||
[
|
||||
createField({
|
||||
field_path: 'guest.name',
|
||||
display_name: '客人姓名',
|
||||
required_rule: 'Y',
|
||||
}),
|
||||
createField({
|
||||
field_path: 'room.type',
|
||||
display_name: '房型',
|
||||
select_editable: 'Y',
|
||||
enum_options: '豪华 Q1A, 行政套房',
|
||||
}),
|
||||
createField({
|
||||
field_path: 'stay.arrival_date',
|
||||
display_name: '入住日期',
|
||||
date_picker: 'Y',
|
||||
}),
|
||||
createField({
|
||||
field_path: 'room.count',
|
||||
display_name: '房间数',
|
||||
number_input: 'Y',
|
||||
}),
|
||||
],
|
||||
{
|
||||
'guest.name': '',
|
||||
'room.type': '不存在的房型',
|
||||
'stay.arrival_date': '2026/07/08',
|
||||
'room.count': 'abc',
|
||||
},
|
||||
)
|
||||
|
||||
expect(errors).toMatchObject({
|
||||
'guest.name': {
|
||||
code: 'required',
|
||||
field_name: '客人姓名',
|
||||
},
|
||||
'room.type': {
|
||||
code: 'enum',
|
||||
field_name: '房型',
|
||||
},
|
||||
'stay.arrival_date': {
|
||||
code: 'date',
|
||||
field_name: '入住日期',
|
||||
},
|
||||
'room.count': {
|
||||
code: 'number',
|
||||
field_name: '房间数',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('groups only fields that match the current display condition', () => {
|
||||
const groups = groupReservationFields(
|
||||
[
|
||||
createField({
|
||||
field_path: 'case_keys.group_code',
|
||||
display_name: 'Group Code',
|
||||
display_condition: '对象为Group Block或Allotment时展示',
|
||||
}),
|
||||
createField({
|
||||
field_path: 'case_keys.confirmation_number',
|
||||
display_name: 'Confirmation No.',
|
||||
display_condition: '对象为FIT Reservation时展示',
|
||||
}),
|
||||
],
|
||||
'基础信息',
|
||||
{
|
||||
'extracted_fields.booking_object_type': 'Group Block',
|
||||
'case_keys.group_code': 'GRP-001',
|
||||
},
|
||||
)
|
||||
|
||||
expect(groups.flatMap((group) => group.fields.map((field) => field.field_path))).toEqual(['case_keys.group_code'])
|
||||
})
|
||||
|
||||
it('builds a backend-safe editable payload without readonly or inactive fields', () => {
|
||||
const payload = buildEditableFieldValues(
|
||||
[
|
||||
createField({
|
||||
field_path: 'visible_reason',
|
||||
display_name: '生成原因',
|
||||
editable: 'N',
|
||||
input_editable: 'N',
|
||||
required_rule: 'Y',
|
||||
}),
|
||||
createField({
|
||||
field_path: 'case_keys.group_code',
|
||||
display_name: 'Group Code',
|
||||
display_condition: '对象为Group Block或Allotment时展示',
|
||||
}),
|
||||
createField({
|
||||
field_path: 'case_keys.confirmation_number',
|
||||
display_name: 'Confirmation No.',
|
||||
display_condition: '对象为FIT Reservation时展示',
|
||||
}),
|
||||
],
|
||||
{
|
||||
visible_reason: 'AI extracted this task.',
|
||||
'case_keys.group_code': 'GRP-001',
|
||||
'case_keys.confirmation_number': '',
|
||||
'extracted_fields.booking_object_type': 'Group Block',
|
||||
},
|
||||
false,
|
||||
)
|
||||
|
||||
expect(payload).toEqual({
|
||||
'case_keys.group_code': 'GRP-001',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
EndpointPendingError,
|
||||
fetchReservationOrderDetail,
|
||||
fetchReservationOrders,
|
||||
fetchReservationTaskDetail,
|
||||
@@ -69,13 +68,54 @@ describe('reservationService real API mode', () => {
|
||||
})
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/reservation/tasks?task_status=PENDING_CONFIRM&page_num=1&page_size=20',
|
||||
'/api/reservation/tasks?hotel_id=HOTEL-TEST&task_status=PENDING_CONFIRM&page_num=1&page_size=20',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
)
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0]?.source_subject).toBe('Booking Request')
|
||||
})
|
||||
|
||||
it('fetches the order list from the backend by default', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
mockJsonResponse({
|
||||
items: [
|
||||
{
|
||||
order_id: '20001',
|
||||
hotel_id: 'HOTEL-TEST',
|
||||
order_status: 'ACTIVE',
|
||||
display_order_key: 'GRP-001',
|
||||
temporary_order_no: null,
|
||||
confirmation_number: null,
|
||||
group_code: 'GRP-001',
|
||||
display_name: 'GRP-001',
|
||||
open_task_count: 2,
|
||||
next_processable_task_id: '10002',
|
||||
created_at: '2026-07-08T03:00:00Z',
|
||||
updated_at: '2026-07-08T03:10:00Z',
|
||||
},
|
||||
],
|
||||
page: {
|
||||
page_num: 1,
|
||||
page_size: 20,
|
||||
total: 1,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const result = await fetchReservationOrders({
|
||||
keyword: 'GRP-001',
|
||||
page_num: 1,
|
||||
page_size: 20,
|
||||
})
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/reservation/orders?hotel_id=HOTEL-TEST&keyword=GRP-001&page_num=1&page_size=20',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
)
|
||||
expect(result.items[0]?.display_order_key).toBe('GRP-001')
|
||||
expect(result.items[0]?.next_processable_task_id).toBe('10002')
|
||||
})
|
||||
|
||||
it('fetches order detail from the backend by default', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
mockJsonResponse({
|
||||
@@ -100,7 +140,7 @@ describe('reservationService real API mode', () => {
|
||||
const result = await fetchReservationOrderDetail('20001')
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/reservation/orders/20001?include_tasks=true&include_source_summary=true',
|
||||
'/api/reservation/orders/20001?hotel_id=HOTEL-TEST&include_tasks=true&include_source_summary=true',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
)
|
||||
expect(result.order.display_name).toBe('GRP-001')
|
||||
@@ -141,11 +181,61 @@ describe('reservationService real API mode', () => {
|
||||
expect(result.task_id).toBe('10001')
|
||||
})
|
||||
|
||||
it('marks order list as pending instead of returning fixture data', async () => {
|
||||
await expect(fetchReservationOrders()).rejects.toBeInstanceOf(EndpointPendingError)
|
||||
})
|
||||
it('fetches source message conversation from the backend by default', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
mockJsonResponse({
|
||||
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: '<p>完整邮件正文</p>',
|
||||
html_sanitize_required: true,
|
||||
inline_images: [],
|
||||
attachments: [],
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
it('marks source message conversation as pending instead of returning fixture data', async () => {
|
||||
await expect(fetchSourceMessageConversation('30001')).rejects.toBeInstanceOf(EndpointPendingError)
|
||||
const result = await fetchSourceMessageConversation('30001')
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/source-messages/30001/conversation',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
)
|
||||
expect(result.conversation.external_conversation_id).toBe('thread-30001')
|
||||
expect(result.messages[0]?.text_body).toBe('完整邮件正文')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import ReservationTaskDetailPanel from '@/components/reservation/ReservationTask
|
||||
import type {
|
||||
ReservationTaskAuditLogResult,
|
||||
ReservationTaskDetailResult,
|
||||
ReservationTaskFieldResult,
|
||||
ReservationTaskPayloadMutationResult,
|
||||
} from '@/types/reservation'
|
||||
|
||||
@@ -24,8 +25,8 @@ vi.mock('@/services/reservationService', async (importOriginal) => {
|
||||
|
||||
const service = await import('@/services/reservationService')
|
||||
|
||||
function createTaskDetail(): ReservationTaskDetailResult {
|
||||
return {
|
||||
function createTaskDetail(overrides: Partial<ReservationTaskDetailResult> = {}): ReservationTaskDetailResult {
|
||||
const detail: ReservationTaskDetailResult = {
|
||||
task_id: '10001',
|
||||
order_id: '20001',
|
||||
source_message_id: '30001',
|
||||
@@ -52,6 +53,10 @@ function createTaskDetail(): ReservationTaskDetailResult {
|
||||
fields: [],
|
||||
opera_operations: [],
|
||||
}
|
||||
return {
|
||||
...detail,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function createMutationResult(taskStatus = 'PENDING_CONFIRM'): ReservationTaskPayloadMutationResult {
|
||||
@@ -81,6 +86,34 @@ function createAudit(auditId: string, action: string): ReservationTaskAuditLogRe
|
||||
}
|
||||
}
|
||||
|
||||
function createRequiredField(overrides: Partial<ReservationTaskFieldResult> = {}): ReservationTaskFieldResult {
|
||||
return {
|
||||
row_number: 1,
|
||||
card_name: 'New Booking',
|
||||
display_area: '客人信息',
|
||||
field_path: 'guest.name',
|
||||
display_name: '客人姓名',
|
||||
visible: 'Y',
|
||||
editable: 'Y',
|
||||
input_editable: 'Y',
|
||||
select_editable: 'N',
|
||||
date_picker: 'N',
|
||||
number_input: 'N',
|
||||
file_display: 'N',
|
||||
table_editable: 'N',
|
||||
enum_options: null,
|
||||
required_rule: 'Y',
|
||||
display_condition: null,
|
||||
validation_rule: null,
|
||||
write_path: null,
|
||||
opera_write_participation: 'N',
|
||||
opera_parameter_mapping: null,
|
||||
notes: null,
|
||||
value: '',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function mountPanel() {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
@@ -107,7 +140,9 @@ async function mountPanel() {
|
||||
global: {
|
||||
plugins: [i18n, router],
|
||||
stubs: {
|
||||
RouterLink: true,
|
||||
RouterLink: {
|
||||
template: '<a><slot /></a>',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -158,6 +193,59 @@ describe('ReservationTaskDetailPanel', () => {
|
||||
expect(findButton(wrapper, '确认').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('blocks confirmation when frontend field validation fails', async () => {
|
||||
vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue(
|
||||
createTaskDetail({
|
||||
fields: [createRequiredField()],
|
||||
}),
|
||||
)
|
||||
|
||||
const wrapper = await mountPanel()
|
||||
await findButton(wrapper, '确认').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(service.confirmReservationTask).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('请先修正字段')
|
||||
expect(wrapper.text()).toContain('客人姓名为必填项')
|
||||
})
|
||||
|
||||
it('keeps remaining validation errors and clears the summary after fields are fixed', async () => {
|
||||
vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue(
|
||||
createTaskDetail({
|
||||
fields: [
|
||||
createRequiredField(),
|
||||
createRequiredField({
|
||||
field_path: 'room.type',
|
||||
display_name: '房型',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const wrapper = await mountPanel()
|
||||
await findButton(wrapper, '确认').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('请先修正字段')
|
||||
expect(wrapper.text()).toContain('客人姓名为必填项')
|
||||
expect(wrapper.text()).toContain('房型为必填项')
|
||||
|
||||
const textareas = wrapper.findAll('textarea')
|
||||
await textareas[0]!.setValue('王建国')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('请先修正字段')
|
||||
expect(wrapper.text()).not.toContain('客人姓名为必填项')
|
||||
expect(wrapper.text()).toContain('房型为必填项')
|
||||
|
||||
await textareas[1]!.setValue('豪华 Q1A')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).not.toContain('请先修正字段')
|
||||
expect(wrapper.text()).not.toContain('客人姓名为必填项')
|
||||
expect(wrapper.text()).not.toContain('房型为必填项')
|
||||
})
|
||||
|
||||
it('refreshes audit records after saving a draft', async () => {
|
||||
vi.mocked(service.fetchReservationTaskAudits)
|
||||
.mockResolvedValueOnce({
|
||||
@@ -180,4 +268,69 @@ describe('ReservationTaskDetailPanel', () => {
|
||||
expect(wrapper.text()).toContain('DRAFT_SAVED')
|
||||
expect(wrapper.text()).not.toContain('TASK_LOADED')
|
||||
})
|
||||
|
||||
it('saves only editable active fields to match backend task field validation', async () => {
|
||||
vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue(
|
||||
createTaskDetail({
|
||||
fields: [
|
||||
createRequiredField({
|
||||
field_path: 'visible_reason',
|
||||
display_name: '生成原因',
|
||||
editable: 'N',
|
||||
input_editable: 'N',
|
||||
required_rule: 'Y',
|
||||
value: 'AI extracted this task.',
|
||||
}),
|
||||
createRequiredField({
|
||||
field_path: 'case_keys.group_code',
|
||||
display_name: 'Group Code',
|
||||
display_condition: '对象为Group Block或Allotment时展示',
|
||||
value: 'GRP-001',
|
||||
}),
|
||||
createRequiredField({
|
||||
field_path: 'case_keys.confirmation_number',
|
||||
display_name: 'Confirmation No.',
|
||||
display_condition: '对象为FIT Reservation时展示',
|
||||
value: null,
|
||||
}),
|
||||
createRequiredField({
|
||||
field_path: 'extracted_fields.booking_object_type',
|
||||
display_name: '订单对象类型',
|
||||
select_editable: 'Y',
|
||||
input_editable: 'N',
|
||||
enum_options: 'Group Block, FIT Reservation',
|
||||
value: 'Group Block',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
vi.mocked(service.saveReservationTaskDraft).mockResolvedValue(createMutationResult())
|
||||
|
||||
const wrapper = await mountPanel()
|
||||
await findButton(wrapper, '保存草稿').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(service.saveReservationTaskDraft).toHaveBeenCalledWith(
|
||||
'10001',
|
||||
{
|
||||
field_values: {
|
||||
'case_keys.group_code': 'GRP-001',
|
||||
'extracted_fields.booking_object_type': 'Group Block',
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('opens the source conversation by source message id without external conversation id', async () => {
|
||||
vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue(
|
||||
createTaskDetail({
|
||||
external_conversation_id: null,
|
||||
}),
|
||||
)
|
||||
|
||||
const wrapper = await mountPanel()
|
||||
|
||||
expect(wrapper.text()).toContain('查看邮件会话')
|
||||
expect(wrapper.text()).not.toContain('接口待补')
|
||||
})
|
||||
})
|
||||
|
||||
85
client/src/tests/reservationTaskQueue.spec.ts
Normal file
85
client/src/tests/reservationTaskQueue.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
|
||||
import zhCN from '@/i18n/locales/zh-CN'
|
||||
import ReservationTaskQueue from '@/components/reservation/ReservationTaskQueue.vue'
|
||||
import type { ReservationOrderTaskTimelineItem } from '@/types/reservation'
|
||||
|
||||
function createTaskQueueItem(overrides: Partial<ReservationOrderTaskTimelineItem> = {}): ReservationOrderTaskTimelineItem {
|
||||
return {
|
||||
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',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function mountQueue(items: ReservationOrderTaskTimelineItem[]) {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'zh-CN',
|
||||
messages: {
|
||||
'zh-CN': zhCN,
|
||||
},
|
||||
})
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: { template: '<div />' } },
|
||||
{ path: '/reservation/source-messages/:sourceMessageId/conversation', component: { template: '<div />' } },
|
||||
],
|
||||
})
|
||||
await router.push('/')
|
||||
await router.isReady()
|
||||
|
||||
return mount(ReservationTaskQueue, {
|
||||
props: {
|
||||
items,
|
||||
activeTaskId: '10001',
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n, router],
|
||||
stubs: {
|
||||
RouterLink: {
|
||||
template: '<a><slot /></a>',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('ReservationTaskQueue', () => {
|
||||
it('opens the source conversation by source message id without external conversation id', async () => {
|
||||
const wrapper = await mountQueue([createTaskQueueItem()])
|
||||
|
||||
expect(wrapper.text()).toContain('查看邮件会话')
|
||||
expect(wrapper.text()).not.toContain('接口待补')
|
||||
})
|
||||
|
||||
it('shows readonly reason when a queued task cannot be processed', async () => {
|
||||
const wrapper = await mountQueue([
|
||||
createTaskQueueItem({
|
||||
can_process: false,
|
||||
readonly_reason_code: 'PREVIOUS_TASK_NOT_FINISHED',
|
||||
}),
|
||||
])
|
||||
|
||||
expect(wrapper.text()).toContain('不可处理')
|
||||
expect(wrapper.text()).toContain('PREVIOUS_TASK_NOT_FINISHED')
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import { createI18n } from 'vue-i18n'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
|
||||
import zhCN from '@/i18n/locales/zh-CN'
|
||||
import { EndpointPendingError } from '@/services/reservationService'
|
||||
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'
|
||||
@@ -13,6 +13,7 @@ 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(),
|
||||
@@ -21,7 +22,44 @@ vi.mock('@/services/reservationService', async (importOriginal) => {
|
||||
|
||||
const service = await import('@/services/reservationService')
|
||||
|
||||
function createTaskListResult(displayOrderKey: string, sourceSubject: string) {
|
||||
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,
|
||||
},
|
||||
) {
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
@@ -48,11 +86,142 @@ function createTaskListResult(displayOrderKey: string, sourceSubject: string) {
|
||||
updated_at: '2026-07-08T03:10:00Z',
|
||||
},
|
||||
],
|
||||
page: {
|
||||
page_num: 1,
|
||||
page_size: 20,
|
||||
total: 1,
|
||||
page,
|
||||
}
|
||||
}
|
||||
|
||||
function createOrderDetailResult() {
|
||||
return {
|
||||
order: {
|
||||
order_id: '20001',
|
||||
hotel_id: 'HOTEL-TEST',
|
||||
order_status: 'ACTIVE',
|
||||
temporary_order_no: null,
|
||||
confirmation_number: null,
|
||||
group_code: 'GRP-001',
|
||||
block_code: null,
|
||||
allotment_code: null,
|
||||
display_name: 'GRP-001',
|
||||
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<ReturnType<typeof createSourceMessageConversationResultBase>> = {},
|
||||
) {
|
||||
const result = createSourceMessageConversationResultBase()
|
||||
return {
|
||||
...result,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function createSourceMessageConversationResultBase() {
|
||||
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: '<strong>不应直接渲染</strong>',
|
||||
html_sanitize_required: true,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +235,7 @@ function createDeferred<T>() {
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
async function mountWithPlugins(component: object, initialPath = '/') {
|
||||
async function mountWithPlugins(component: object, initialPath = '/', stubs: Record<string, unknown> = {}) {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'zh-CN',
|
||||
@@ -90,7 +259,10 @@ async function mountWithPlugins(component: object, initialPath = '/') {
|
||||
global: {
|
||||
plugins: [i18n, router],
|
||||
stubs: {
|
||||
RouterLink: true,
|
||||
RouterLink: {
|
||||
template: '<a><slot /></a>',
|
||||
},
|
||||
...stubs,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -98,21 +270,72 @@ async function mountWithPlugins(component: object, initialPath = '/') {
|
||||
|
||||
describe('reservation P0 views', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(service.fetchReservationOrderDetail).mockReset()
|
||||
vi.mocked(service.fetchReservationOrders).mockReset()
|
||||
vi.mocked(service.fetchReservationTaskList).mockReset()
|
||||
vi.mocked(service.fetchSourceMessageConversation).mockReset()
|
||||
})
|
||||
|
||||
it('shows pending state for the order list endpoint', async () => {
|
||||
vi.mocked(service.fetchReservationOrders).mockRejectedValue(
|
||||
new EndpointPendingError('GET /api/reservation/orders is pending backend implementation.'),
|
||||
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()).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('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('接口待接入')
|
||||
expect(wrapper.text()).not.toContain('#ORD-083')
|
||||
expect(wrapper.text()).toContain('第 3 / 4 页')
|
||||
})
|
||||
|
||||
it('renders task list items returned by the backend', async () => {
|
||||
@@ -123,7 +346,62 @@ describe('reservation P0 views', () => {
|
||||
|
||||
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('接口待补')
|
||||
})
|
||||
|
||||
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-002', 'Booking Update', { page_num: 2, page_size: 20, total: 45 }))
|
||||
|
||||
const wrapper = await mountWithPlugins(ReservationTaskListView)
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const selects = wrapper.findAll('select')
|
||||
expect(wrapper.text()).toContain('订单状态筛选待后端接入')
|
||||
expect(selects[2]!.attributes('disabled')).toBeDefined()
|
||||
|
||||
await selects[1]!.setValue('READY')
|
||||
await vi.dynamicImportSettled()
|
||||
await selects[3]!.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: 'READY',
|
||||
queue_participation: true,
|
||||
page_num: 1,
|
||||
})
|
||||
expect(filteredTaskRequest).not.toHaveProperty('order_status')
|
||||
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('下一页'))?.trigger('click')
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(service.fetchReservationTaskList).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
task_status: 'READY',
|
||||
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('keeps the latest task list response when filters change quickly', async () => {
|
||||
@@ -147,10 +425,8 @@ describe('reservation P0 views', () => {
|
||||
expect(wrapper.text()).not.toContain('GRP-OLD')
|
||||
})
|
||||
|
||||
it('shows pending state for the source message conversation endpoint', async () => {
|
||||
vi.mocked(service.fetchSourceMessageConversation).mockRejectedValue(
|
||||
new EndpointPendingError('GET /api/source-messages/30001/conversation is pending backend implementation.'),
|
||||
)
|
||||
it('renders full source message conversation returned by the backend', async () => {
|
||||
vi.mocked(service.fetchSourceMessageConversation).mockResolvedValue(createSourceMessageConversationResult())
|
||||
|
||||
const wrapper = await mountWithPlugins(
|
||||
ReservationSourceMessageConversationView,
|
||||
@@ -159,7 +435,76 @@ describe('reservation P0 views', () => {
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(service.fetchSourceMessageConversation).toHaveBeenCalledWith('30001')
|
||||
expect(wrapper.text()).toContain('接口待接入')
|
||||
expect(wrapper.text()).not.toContain('完整邮件正文')
|
||||
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.text()).not.toContain('接口待接入')
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user