接入前端真实接口并标记邮件HTML清洗

This commit is contained in:
andy
2026-07-08 23:37:03 +08:00
parent c83935a26c
commit a0cb3a05d7
25 changed files with 2581 additions and 157 deletions

View File

@@ -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')
})
})

View 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',
})
})
})

View File

@@ -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('完整邮件正文')
})
})

View File

@@ -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('接口待补')
})
})

View 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')
})
})

View File

@@ -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()
})
})