接入前端真实接口并标记邮件HTML清洗
This commit is contained in:
@@ -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