接入前端真实接口并标记邮件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

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