修复V4任务详情来源邮件展示

This commit is contained in:
andy
2026-07-21 10:29:03 +07:00
parent b44ab94113
commit c28130b3da
8 changed files with 488 additions and 35 deletions

View File

@@ -11,6 +11,7 @@ import type {
ReservationV4OrderTaskDetailResult,
ReservationV4SourceNotificationDetailResult,
ReservationV4TaskCardResult,
SourceMessageConversationResult,
} from '@/types/reservation'
import ReservationV4OrderTaskDetailView from '@/views/reservation/ReservationV4OrderTaskDetailView.vue'
import ReservationV4SourceNotificationDetailView from '@/views/reservation/ReservationV4SourceNotificationDetailView.vue'
@@ -27,6 +28,7 @@ vi.mock('@/services/reservationService', async (importOriginal) => {
fetchReservationV4RateCodeLookups: vi.fn(),
fetchReservationV4RoomTypeLookups: vi.fn(),
fetchReservationV4SourceNotificationDetail: vi.fn(),
fetchSourceMessageConversation: vi.fn(),
resolveReservationV4OrderTaskCardReview: vi.fn(),
}
})
@@ -43,7 +45,9 @@ describe('reservation V4 pages', () => {
vi.mocked(service.fetchReservationV4RateCodeLookups).mockReset()
vi.mocked(service.fetchReservationV4RoomTypeLookups).mockReset()
vi.mocked(service.fetchReservationV4SourceNotificationDetail).mockReset()
vi.mocked(service.fetchSourceMessageConversation).mockReset()
vi.mocked(service.resolveReservationV4OrderTaskCardReview).mockReset()
vi.mocked(service.fetchSourceMessageConversation).mockResolvedValue(createSourceMessageConversationResult())
mockCatalogLookups()
})
@@ -150,6 +154,139 @@ describe('reservation V4 pages', () => {
expect(wrapper.text()).not.toContain('oss://bucket')
})
it('renders V4 source message display at the bottom with only the current email body collapsed', async () => {
const detail = createOrderTaskDetail()
detail.source_message_card!.fields = [
createField('/source_message/ai_payload_json', {
display_name: 'AI payload',
value: 'AI_PAYLOAD_SHOULD_NOT_RENDER',
}),
createField('/source_message/raw_evidence', {
display_name: 'Raw evidence',
value: 'RAW_EVIDENCE_SHOULD_NOT_RENDER',
}),
createField('/source_message/attachment_url', {
display_name: 'Attachment URL',
value: 'https://oss.example/private/source-field.pdf',
}),
]
const currentBody = `Current trigger email body opening. ${'Current booking detail. '.repeat(24)}CURRENT_BODY_TAIL`
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
vi.mocked(service.fetchSourceMessageConversation).mockResolvedValue(createSourceMessageConversationResult({
sourceMessageId: '30001',
currentBody,
}))
const wrapper = await mountWithPlugins(
ReservationV4OrderTaskDetailView,
'/reservation/order-tasks/9001',
)
await flushPromises()
expect(service.fetchSourceMessageConversation).toHaveBeenCalledWith('30001')
const pageText = wrapper.text()
const basicIndex = pageText.indexOf(zhCN.taskV4.basicInformationCard)
const businessIndex = pageText.indexOf(zhCN.taskV4.businessCards)
const sourceIndex = pageText.indexOf(zhCN.taskV4.sourceMessageCard)
expect(basicIndex).toBeGreaterThanOrEqual(0)
expect(businessIndex).toBeGreaterThan(basicIndex)
expect(sourceIndex).toBeGreaterThan(businessIndex)
const sections = wrapper.findAll('.th-section')
expect(sections[sections.length - 1]?.text()).toContain(zhCN.taskV4.sourceMessageCard)
expect(pageText).toContain('Current trigger email body opening.')
expect(pageText).not.toContain('OLDER_MESSAGE_BODY')
expect(pageText).not.toContain('REPLY_MESSAGE_BODY')
expect(pageText).not.toContain('CURRENT_BODY_TAIL')
expect(pageText).not.toContain('AI_PAYLOAD_SHOULD_NOT_RENDER')
expect(pageText).not.toContain('RAW_EVIDENCE_SHOULD_NOT_RENDER')
expect(pageText).not.toContain('https://oss.example/private/source-field.pdf')
const expandButton = wrapper
.findAll('button')
.find((button) => button.text().includes(zhCN.taskV4.sourceMessage.showFullBody))
expect(expandButton).toBeTruthy()
await expandButton!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('CURRENT_BODY_TAIL')
expect(wrapper.text()).toContain(zhCN.taskV4.sourceMessage.collapseBody)
})
it('falls back to source summary when current source message body cannot be loaded', async () => {
const detail = createOrderTaskDetail()
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
vi.mocked(service.fetchSourceMessageConversation).mockRejectedValue(new ApiError('Forbidden', 403, {
code: 'SOURCE_MESSAGE_ORIGINAL_READ_FORBIDDEN',
message: 'Forbidden',
}))
const wrapper = await mountWithPlugins(
ReservationV4OrderTaskDetailView,
'/reservation/order-tasks/9001',
)
await flushPromises()
expect(wrapper.text()).toContain('Please book one twin room.')
expect(wrapper.text()).toContain(zhCN.taskV4.sourceMessage.bodyLoadFailed)
expect(wrapper.text()).toContain(zhCN.task.viewConversation)
})
it('renders only sanitized source message html and never raw html body', async () => {
const detail = createOrderTaskDetail()
const sanitizedHtml = `<p>Sanitized current email opening. ${'Sanitized detail. '.repeat(24)}SANITIZED_HTML_TAIL</p>`
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
vi.mocked(service.fetchSourceMessageConversation).mockResolvedValue(createSourceMessageConversationResult({
sourceMessageId: '30001',
currentBody: '',
sanitizedHtml,
rawHtml: '<script>RAW_HTML_SHOULD_NOT_RENDER</script>',
htmlRenderMode: 'SANITIZED_HTML',
}))
const wrapper = await mountWithPlugins(
ReservationV4OrderTaskDetailView,
'/reservation/order-tasks/9001',
)
await flushPromises()
expect(wrapper.text()).toContain('Sanitized current email opening.')
expect(wrapper.text()).not.toContain('SANITIZED_HTML_TAIL')
expect(wrapper.text()).not.toContain('RAW_HTML_SHOULD_NOT_RENDER')
const expandButton = wrapper
.findAll('button')
.find((button) => button.text().includes(zhCN.taskV4.sourceMessage.showFullBody))
expect(expandButton).toBeTruthy()
await expandButton!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('SANITIZED_HTML_TAIL')
expect(wrapper.html()).toContain('<p>Sanitized current email opening.')
expect(wrapper.html()).not.toContain('RAW_HTML_SHOULD_NOT_RENDER')
})
it('does not render sanitized html when backend marks the email body as text only', async () => {
const detail = createOrderTaskDetail()
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
vi.mocked(service.fetchSourceMessageConversation).mockResolvedValue(createSourceMessageConversationResult({
sourceMessageId: '30001',
currentBody: 'Plain text fallback body.',
sanitizedHtml: '<p>SANITIZED_HTML_SHOULD_NOT_RENDER</p>',
rawHtml: '<p>RAW_HTML_SHOULD_NOT_RENDER</p>',
htmlRenderMode: 'TEXT_ONLY',
}))
const wrapper = await mountWithPlugins(
ReservationV4OrderTaskDetailView,
'/reservation/order-tasks/9001',
)
await flushPromises()
expect(wrapper.text()).toContain('Plain text fallback body.')
expect(wrapper.text()).not.toContain('SANITIZED_HTML_SHOULD_NOT_RENDER')
expect(wrapper.text()).not.toContain('RAW_HTML_SHOULD_NOT_RENDER')
})
it('renders New Booking Room Information as a business form and confirms stable final values', async () => {
const detail = createOrderTaskDetail({
businessEventType: 'NEW_BOOKING',
@@ -393,6 +530,8 @@ describe('reservation V4 pages', () => {
const roomCard = wrapper.find('[data-testid="room-information-card"]')
expect(roomCard.text()).toContain(zhCN.taskV4.roomInformation.changeSummary)
expect(roomCard.text()).toContain(zhCN.taskV4.roomInformation.currentValues)
expect(roomCard.text()).toContain(zhCN.taskV4.roomInformation.proposedValues)
expect(roomCard.text()).toContain('2026-07-26')
expect(roomCard.text()).toContain('2026-07-27')
expect(roomCard.text()).toContain('3')
@@ -986,6 +1125,81 @@ function createOrderTaskDetail(options: {
}
}
function createSourceMessageConversationResult(options: {
sourceMessageId?: string
currentBody?: string
sanitizedHtml?: string | null
rawHtml?: string | null
htmlRenderMode?: SourceMessageConversationResult['messages'][number]['html_render_mode']
} = {}): SourceMessageConversationResult {
const sourceMessageId = options.sourceMessageId ?? '30001'
const currentBody = options.currentBody ?? 'Current trigger email body.'
return {
conversation: {
hotel_id: 'HOTEL-TEST',
channel: 'EMAIL',
external_conversation_id: 'thread-1',
subject: 'Booking Request',
message_count: 3,
first_received_at: '2026-07-08T02:00:00Z',
last_received_at: '2026-07-08T04:00:00Z',
},
messages: [
createConversationMessage({
id: '29999',
text_body: 'OLDER_MESSAGE_BODY',
received_at: '2026-07-08T02:00:00Z',
}),
createConversationMessage({
id: sourceMessageId,
text_body: currentBody,
html_body: options.rawHtml ?? null,
html_body_sanitized: options.sanitizedHtml ?? null,
html_render_mode: options.htmlRenderMode,
received_at: '2026-07-08T03:00:00Z',
attachments: [
{
mediaType: 'ATTACHMENT',
fileName: 'current-message.pdf',
contentType: 'application/pdf',
sizeBytes: 2048,
externalUrl: 'https://oss.example/private/current-message.pdf',
externalMediaId: 'media-current',
},
],
}),
createConversationMessage({
id: '30002',
text_body: 'REPLY_MESSAGE_BODY',
received_at: '2026-07-08T04:00:00Z',
}),
],
}
}
function createConversationMessage(
overrides: Partial<SourceMessageConversationResult['messages'][number]> & { id: string },
): SourceMessageConversationResult['messages'][number] {
return {
id: overrides.id,
external_message_id: overrides.external_message_id ?? `external-${overrides.id}`,
external_conversation_id: overrides.external_conversation_id ?? 'thread-1',
sender_summary: overrides.sender_summary ?? 'guest@example.test',
subject: overrides.subject ?? 'Booking Request',
received_at: overrides.received_at ?? '2026-07-08T03:00:00Z',
source_sent_at: overrides.source_sent_at ?? null,
text_body: overrides.text_body ?? null,
html_body: overrides.html_body ?? null,
html_body_sanitized: overrides.html_body_sanitized ?? null,
html_sanitize_required: overrides.html_sanitize_required ?? false,
html_render_mode: overrides.html_render_mode ?? (overrides.html_body_sanitized ? 'SANITIZED_HTML' : 'TEXT_ONLY'),
inline_images: overrides.inline_images ?? [],
attachments: overrides.attachments ?? [],
related_orders: overrides.related_orders ?? [],
related_tasks: overrides.related_tasks ?? [],
}
}
function createRoomInformationDisplayPayload(overrides: {
event_type?: string
booking_type?: string