428 lines
13 KiB
TypeScript
428 lines
13 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import {
|
|
convertReservationManualReviewTask,
|
|
fetchReservationOrderDetail,
|
|
fetchReservationOrders,
|
|
fetchReservationTaskDetail,
|
|
fetchReservationTaskList,
|
|
fetchSourceMessageConversation,
|
|
resolveReservationManualReviewTask,
|
|
} from '@/services/reservationService'
|
|
import { setReservationHotelIdProvider } from '@/config/reservationConfig'
|
|
|
|
const jsonHeaders = {
|
|
headers: {
|
|
get: (name: string) => (name.toLowerCase() === 'content-type' ? 'application/json' : null),
|
|
},
|
|
}
|
|
|
|
function mockJsonResponse(payload: unknown): Response {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => payload,
|
|
text: async () => JSON.stringify(payload),
|
|
...jsonHeaders,
|
|
} as Response
|
|
}
|
|
|
|
describe('reservationService real API mode', () => {
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks()
|
|
setReservationHotelIdProvider(null)
|
|
})
|
|
|
|
it('fetches the task list from the backend by default', async () => {
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
mockJsonResponse({
|
|
items: [
|
|
{
|
|
task_id: '10001',
|
|
order_id: '20001',
|
|
hotel_id: 'HOTEL-TEST',
|
|
display_order_key: 'GRP-001',
|
|
temporary_order_no: null,
|
|
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',
|
|
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 fetchReservationTaskList({
|
|
task_status: 'PENDING_CONFIRM',
|
|
order_status: 'ACTIVE',
|
|
page_num: 1,
|
|
page_size: 20,
|
|
})
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'/api/reservation/tasks?task_status=PENDING_CONFIRM&order_status=ACTIVE&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('passes task subtype and terminal task status filters to the backend', async () => {
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
mockJsonResponse({
|
|
items: [],
|
|
page: {
|
|
page_num: 1,
|
|
page_size: 20,
|
|
total: 0,
|
|
},
|
|
}),
|
|
)
|
|
|
|
await fetchReservationTaskList({
|
|
task_subtype: 'RATE_CHANGE',
|
|
task_status: 'FAILED',
|
|
page_num: 1,
|
|
page_size: 20,
|
|
})
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'/api/reservation/tasks?task_subtype=RATE_CHANGE&task_status=FAILED&page_num=1&page_size=20',
|
|
expect.objectContaining({ method: 'GET' }),
|
|
)
|
|
})
|
|
|
|
it('uses the selected auth hotel when available', async () => {
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
mockJsonResponse({
|
|
items: [],
|
|
page: {
|
|
page_num: 1,
|
|
page_size: 20,
|
|
total: 0,
|
|
},
|
|
}),
|
|
)
|
|
setReservationHotelIdProvider(() => 'HOTEL-BKK')
|
|
|
|
await fetchReservationTaskList({
|
|
page_num: 1,
|
|
page_size: 20,
|
|
})
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'/api/reservation/tasks?hotel_id=HOTEL-BKK&page_num=1&page_size=20',
|
|
expect.objectContaining({ method: 'GET' }),
|
|
)
|
|
})
|
|
|
|
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?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('passes order status and business-key filters to the backend', async () => {
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
mockJsonResponse({
|
|
items: [],
|
|
page: {
|
|
page_num: 2,
|
|
page_size: 10,
|
|
total: 0,
|
|
},
|
|
}),
|
|
)
|
|
|
|
await fetchReservationOrders({
|
|
order_status: 'LOGIC_DELETED',
|
|
group_code: 'GRP-001',
|
|
confirmation_number: 'CNF-001',
|
|
keyword: 'VIP',
|
|
page_num: 2,
|
|
page_size: 10,
|
|
})
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'/api/reservation/orders?order_status=LOGIC_DELETED&group_code=GRP-001&confirmation_number=CNF-001&keyword=VIP&page_num=2&page_size=10',
|
|
expect.objectContaining({ method: 'GET' }),
|
|
)
|
|
})
|
|
|
|
it('converts a manual review task through the backend endpoint', async () => {
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
mockJsonResponse({
|
|
task_id: '10001',
|
|
order_id: '20002',
|
|
original_order_id: '20001',
|
|
system_task_type: 'UPDATE_BOOKING',
|
|
task_card_type: 'UPDATE_BOOKING',
|
|
task_status: 'PENDING_CONFIRM',
|
|
original_order_logic_deleted: true,
|
|
}),
|
|
)
|
|
|
|
const result = await convertReservationManualReviewTask('10001', {
|
|
target_task_type: 'UPDATE_BOOKING',
|
|
target_order_id: '20002',
|
|
reason: 'Matched by group code',
|
|
})
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'/api/reservation/tasks/10001/manual-review-conversions',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: expect.objectContaining({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify({
|
|
target_task_type: 'UPDATE_BOOKING',
|
|
target_order_id: '20002',
|
|
reason: 'Matched by group code',
|
|
}),
|
|
}),
|
|
)
|
|
expect(result.system_task_type).toBe('UPDATE_BOOKING')
|
|
expect(result.original_order_logic_deleted).toBe(true)
|
|
})
|
|
|
|
it('resolves a type-known manual review task on the original card', async () => {
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
mockJsonResponse({
|
|
task_id: '10001',
|
|
order_id: '20001',
|
|
task_status: 'READY',
|
|
review_status: 'RESOLVED',
|
|
review_resolution: {
|
|
confirmed_order_id: '20001',
|
|
field_overrides: [
|
|
{
|
|
field_pointer: '/extracted_fields/room_items/0/pms_room_type_code',
|
|
field_path: 'extracted_fields.room_items.0.pms_room_type_code',
|
|
legacy_field_path: 'extracted_fields.pms_room_type_code',
|
|
value: 'RM3',
|
|
},
|
|
],
|
|
},
|
|
confirmed_payload: {
|
|
field_values: {
|
|
'extracted_fields.room_items.0.pms_room_type_code': 'RM3',
|
|
},
|
|
legacy_field_values: {
|
|
'extracted_fields.pms_room_type_code': 'RM3',
|
|
},
|
|
},
|
|
opera_operations: [],
|
|
}),
|
|
)
|
|
|
|
const result = await resolveReservationManualReviewTask('10001', {
|
|
confirmed_order_id: '20001',
|
|
reason: '确认 PMS 房型代码',
|
|
field_overrides: [
|
|
{
|
|
field_pointer: '/extracted_fields/room_items/0/pms_room_type_code',
|
|
field_path: 'extracted_fields.room_items.0.pms_room_type_code',
|
|
value: 'RM3',
|
|
},
|
|
],
|
|
})
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'/api/reservation/tasks/10001/manual-review-resolutions',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: expect.objectContaining({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify({
|
|
confirmed_order_id: '20001',
|
|
reason: '确认 PMS 房型代码',
|
|
field_overrides: [
|
|
{
|
|
field_pointer: '/extracted_fields/room_items/0/pms_room_type_code',
|
|
field_path: 'extracted_fields.room_items.0.pms_room_type_code',
|
|
value: 'RM3',
|
|
},
|
|
],
|
|
}),
|
|
}),
|
|
)
|
|
expect(result.task_status).toBe('READY')
|
|
expect(result.review_status).toBe('RESOLVED')
|
|
})
|
|
|
|
it('fetches order detail from the backend by default', async () => {
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
mockJsonResponse({
|
|
order: {
|
|
order_id: '20001',
|
|
hotel_id: 'HOTEL-TEST',
|
|
order_status: 'ACTIVE',
|
|
temporary_order_no: null,
|
|
confirmation_number: 'CNF123456',
|
|
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: [],
|
|
warnings: [],
|
|
}),
|
|
)
|
|
|
|
const result = await fetchReservationOrderDetail('20001')
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'/api/reservation/orders/20001?include_tasks=true&include_source_summary=true',
|
|
expect.objectContaining({ method: 'GET' }),
|
|
)
|
|
expect(result.order.display_name).toBe('GRP-001')
|
|
})
|
|
|
|
it('fetches task detail from the backend by default', async () => {
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
mockJsonResponse({
|
|
task_id: '10001',
|
|
order_id: '20001',
|
|
source_message_id: '30001',
|
|
system_task_type: 'NEW_BOOKING',
|
|
task_card_type: 'NEW_BOOKING',
|
|
task_status: 'PENDING_CONFIRM',
|
|
field_contract_version: '20260708-3.0',
|
|
draft_payload: null,
|
|
confirmed_payload: null,
|
|
availability: {
|
|
blocked: false,
|
|
read_only: false,
|
|
editable: true,
|
|
confirmable: true,
|
|
executable: false,
|
|
blocked_by_task_id: null,
|
|
blocked_reason: null,
|
|
},
|
|
fields: [],
|
|
opera_operations: [],
|
|
}),
|
|
)
|
|
|
|
const result = await fetchReservationTaskDetail('10001')
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'/api/reservation/tasks/10001',
|
|
expect.objectContaining({ method: 'GET' }),
|
|
)
|
|
expect(result.task_id).toBe('10001')
|
|
})
|
|
|
|
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_body_sanitized: '<p>完整邮件正文</p>',
|
|
html_sanitize_required: true,
|
|
html_render_mode: 'SANITIZED_HTML',
|
|
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',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
)
|
|
|
|
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('完整邮件正文')
|
|
})
|
|
})
|