Files
th-hotel-simple/client/src/tests/reservationService.spec.ts
2026-07-12 09:23:03 +08:00

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