接入前端P0页面真实接口

This commit is contained in:
andy
2026-07-08 16:20:06 +08:00
parent 5c5b8e33b0
commit 66a613d6cd
45 changed files with 8719 additions and 0 deletions

View File

@@ -0,0 +1,40 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue'
import zhCN from '@/i18n/locales/zh-CN'
function mountWithI18n(status: string) {
const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
messages: {
'zh-CN': zhCN,
},
})
return mount(ReservationStatusBadge, {
props: {
status,
},
global: {
plugins: [i18n],
},
})
}
describe('ReservationStatusBadge', () => {
it('renders stable status code through i18n label', () => {
const wrapper = mountWithI18n('PENDING_CONFIRM')
expect(wrapper.text()).toContain('待确认')
expect(wrapper.classes()).toContain('status-badge--info')
})
it('falls back to the raw code for unknown status', () => {
const wrapper = mountWithI18n('UNKNOWN_STATUS')
expect(wrapper.text()).toContain('UNKNOWN_STATUS')
})
})

View File

@@ -0,0 +1,110 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import ReservationTaskFieldRenderer from '@/components/reservation/ReservationTaskFieldRenderer.vue'
import zhCN from '@/i18n/locales/zh-CN'
import type { ReservationTaskFieldResult } from '@/types/reservation'
const fields: ReservationTaskFieldResult[] = [
{
row_number: 1,
card_name: 'New Booking',
display_area: '客人信息',
field_path: 'guest.name',
display_name: '客人姓名',
visible: 'Y',
editable: 'Y',
input_editable: 'Y',
select_editable: 'N',
date_picker: 'N',
number_input: 'N',
file_display: 'N',
table_editable: 'N',
enum_options: null,
required_rule: 'Y',
display_condition: null,
validation_rule: null,
write_path: 'reservation.guest.name',
opera_write_participation: 'Y',
opera_parameter_mapping: 'profile.name',
notes: null,
value: '王建国',
},
{
row_number: 2,
card_name: 'New Booking',
display_area: '预订信息',
field_path: 'room.type',
display_name: '房型',
visible: 'Y',
editable: 'Y',
input_editable: 'N',
select_editable: 'Y',
date_picker: 'N',
number_input: 'N',
file_display: 'N',
table_editable: 'N',
enum_options: '豪华 Q1A, 行政套房',
required_rule: 'Y',
display_condition: null,
validation_rule: null,
write_path: 'reservation.room.type',
opera_write_participation: 'Y',
opera_parameter_mapping: 'roomType',
notes: null,
value: '豪华 Q1A',
},
]
function mountRenderer(readOnly = false) {
const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
messages: {
'zh-CN': zhCN,
},
})
return mount(ReservationTaskFieldRenderer, {
props: {
fields,
modelValue: {
'guest.name': '王建国',
'room.type': '豪华 Q1A',
},
readOnly,
},
global: {
plugins: [i18n],
},
})
}
describe('ReservationTaskFieldRenderer', () => {
it('emits updates using field_path as key', async () => {
const wrapper = mountRenderer()
const input = wrapper.find('textarea')
await input.setValue('Mark Lee')
expect(wrapper.emitted('update:modelValue')?.[0]?.[0]).toMatchObject({
'guest.name': 'Mark Lee',
'room.type': '豪华 Q1A',
})
})
it('renders editable select from enum options', () => {
const wrapper = mountRenderer()
expect(wrapper.find('select').exists()).toBe(true)
expect(wrapper.findAll('option')).toHaveLength(2)
})
it('renders static values when the task is read only', () => {
const wrapper = mountRenderer(true)
expect(wrapper.find('textarea').exists()).toBe(false)
expect(wrapper.text()).toContain('王建国')
})
})

View File

@@ -0,0 +1,70 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ReservationOperaPanel from '@/components/reservation/ReservationOperaPanel.vue'
import zhCN from '@/i18n/locales/zh-CN'
import type { ReservationOperaOperationResult } from '@/types/reservation'
vi.mock('@/services/reservationService', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/services/reservationService')>()
return {
...actual,
executeReservationOperaOperation: vi.fn(),
retryReservationOperaOperation: vi.fn(),
}
})
const service = await import('@/services/reservationService')
function createOperation(): ReservationOperaOperationResult {
return {
operation_id: 'op-1',
task_id: '10001',
operation_sequence: 1,
operation_code: 'CREATE_RESERVATION',
operation_name: 'Create reservation',
operation_status: 'PENDING',
attempt_count: 0,
last_error_message: null,
attempts: [],
}
}
function mountPanel() {
const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
messages: {
'zh-CN': zhCN,
},
})
return mount(ReservationOperaPanel, {
props: {
taskId: '10001',
operations: [createOperation()],
executable: true,
},
global: {
plugins: [i18n],
},
})
}
describe('ReservationOperaPanel', () => {
beforeEach(() => {
vi.mocked(service.executeReservationOperaOperation).mockReset()
vi.mocked(service.retryReservationOperaOperation).mockReset()
})
it('shows operation errors when execute fails', async () => {
vi.mocked(service.executeReservationOperaOperation).mockRejectedValue(new Error('OPERA is unavailable'))
const wrapper = mountPanel()
await wrapper.find('button[title="执行"]').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('OPERA is unavailable')
expect(wrapper.find('button[title="执行"]').attributes('disabled')).toBeUndefined()
})
})

View File

@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import { router } from '@/router'
describe('reservation router', () => {
it('exposes the P0 frontend routes', () => {
expect(router.resolve('/reservation/orders').name).toBe('reservation-order-list')
expect(router.resolve('/reservation/tasks').name).toBe('reservation-task-list')
expect(router.resolve('/reservation/orders/20001').name).toBe('reservation-order-detail')
expect(router.resolve('/reservation/tasks/10001').name).toBe('reservation-task-detail')
expect(router.resolve('/reservation/source-messages/30001/conversation').name).toBe(
'reservation-source-message-conversation',
)
})
})

View File

@@ -0,0 +1,151 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
EndpointPendingError,
fetchReservationOrderDetail,
fetchReservationOrders,
fetchReservationTaskDetail,
fetchReservationTaskList,
fetchSourceMessageConversation,
} from '@/services/reservationService'
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()
})
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',
page_num: 1,
page_size: 20,
})
expect(fetchMock).toHaveBeenCalledWith(
'/api/reservation/tasks?task_status=PENDING_CONFIRM&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('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('marks order list as pending instead of returning fixture data', async () => {
await expect(fetchReservationOrders()).rejects.toBeInstanceOf(EndpointPendingError)
})
it('marks source message conversation as pending instead of returning fixture data', async () => {
await expect(fetchSourceMessageConversation('30001')).rejects.toBeInstanceOf(EndpointPendingError)
})
})

View File

@@ -0,0 +1,183 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { createMemoryHistory, createRouter } from 'vue-router'
import zhCN from '@/i18n/locales/zh-CN'
import ReservationTaskDetailPanel from '@/components/reservation/ReservationTaskDetailPanel.vue'
import type {
ReservationTaskAuditLogResult,
ReservationTaskDetailResult,
ReservationTaskPayloadMutationResult,
} from '@/types/reservation'
vi.mock('@/services/reservationService', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/services/reservationService')>()
return {
...actual,
fetchReservationTaskDetail: vi.fn(),
fetchReservationTaskAudits: vi.fn(),
saveReservationTaskDraft: vi.fn(),
confirmReservationTask: vi.fn(),
}
})
const service = await import('@/services/reservationService')
function createTaskDetail(): ReservationTaskDetailResult {
return {
task_id: '10001',
order_id: '20001',
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: 'thread-30001',
conversation_message_count: 2,
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: [],
}
}
function createMutationResult(taskStatus = 'PENDING_CONFIRM'): ReservationTaskPayloadMutationResult {
return {
task_id: '10001',
order_id: '20001',
task_status: taskStatus,
draft_payload: {},
confirmed_payload: taskStatus === 'READY' ? {} : null,
opera_operations: [],
}
}
function createAudit(auditId: string, action: string): ReservationTaskAuditLogResult {
return {
audit_id: auditId,
order_id: '20001',
task_id: '10001',
operation_id: null,
actor_type: 'USER',
actor_id: 'andy',
action,
reason: null,
before_snapshot: null,
after_snapshot: null,
occurred_at: '2026-07-08T03:00:00Z',
}
}
async function mountPanel() {
const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
messages: {
'zh-CN': zhCN,
},
})
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/reservation/tasks/:taskId', component: { template: '<div />' } },
{ path: '/reservation/orders/:orderId', component: { template: '<div />' } },
{ path: '/reservation/source-messages/:sourceMessageId/conversation', component: { template: '<div />' } },
],
})
await router.push('/reservation/tasks/10001')
await router.isReady()
const wrapper = mount(ReservationTaskDetailPanel, {
props: {
taskId: '10001',
},
global: {
plugins: [i18n, router],
stubs: {
RouterLink: true,
},
},
})
await flushPromises()
return wrapper
}
function findButton(wrapper: VueWrapper, label: string) {
const button = wrapper.findAll('button').find((item) => item.text().includes(label))
if (!button) {
throw new Error(`Button ${label} was not found.`)
}
return button
}
describe('ReservationTaskDetailPanel', () => {
beforeEach(() => {
vi.mocked(service.fetchReservationTaskDetail).mockReset()
vi.mocked(service.fetchReservationTaskAudits).mockReset()
vi.mocked(service.saveReservationTaskDraft).mockReset()
vi.mocked(service.confirmReservationTask).mockReset()
vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue(createTaskDetail())
vi.mocked(service.fetchReservationTaskAudits).mockResolvedValue({
task_id: '10001',
items: [],
})
})
it('shows draft save errors instead of leaving an unhandled rejection', async () => {
vi.mocked(service.saveReservationTaskDraft).mockRejectedValue(new Error('Draft save failed'))
const wrapper = await mountPanel()
await findButton(wrapper, '保存草稿').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Draft save failed')
expect(findButton(wrapper, '保存草稿').attributes('disabled')).toBeUndefined()
})
it('shows confirm errors instead of leaving an unhandled rejection', async () => {
vi.mocked(service.confirmReservationTask).mockRejectedValue(new Error('Confirm failed'))
const wrapper = await mountPanel()
await findButton(wrapper, '确认').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Confirm failed')
expect(findButton(wrapper, '确认').attributes('disabled')).toBeUndefined()
})
it('refreshes audit records after saving a draft', async () => {
vi.mocked(service.fetchReservationTaskAudits)
.mockResolvedValueOnce({
task_id: '10001',
items: [createAudit('audit-1', 'TASK_LOADED')],
})
.mockResolvedValueOnce({
task_id: '10001',
items: [createAudit('audit-2', 'DRAFT_SAVED')],
})
vi.mocked(service.saveReservationTaskDraft).mockResolvedValue(createMutationResult())
const wrapper = await mountPanel()
expect(wrapper.text()).toContain('TASK_LOADED')
await findButton(wrapper, '保存草稿').trigger('click')
await flushPromises()
expect(service.fetchReservationTaskAudits).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('DRAFT_SAVED')
expect(wrapper.text()).not.toContain('TASK_LOADED')
})
})

View File

@@ -0,0 +1,165 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { createMemoryHistory, createRouter } from 'vue-router'
import zhCN from '@/i18n/locales/zh-CN'
import { EndpointPendingError } from '@/services/reservationService'
import ReservationOrderListView from '@/views/reservation/ReservationOrderListView.vue'
import ReservationSourceMessageConversationView from '@/views/reservation/ReservationSourceMessageConversationView.vue'
import ReservationTaskListView from '@/views/reservation/ReservationTaskListView.vue'
vi.mock('@/services/reservationService', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/services/reservationService')>()
return {
...actual,
fetchReservationOrders: vi.fn(),
fetchReservationTaskList: vi.fn(),
fetchSourceMessageConversation: vi.fn(),
}
})
const service = await import('@/services/reservationService')
function createTaskListResult(displayOrderKey: string, sourceSubject: string) {
return {
items: [
{
task_id: displayOrderKey === 'GRP-NEW' ? '10002' : '10001',
order_id: '20001',
hotel_id: 'HOTEL-TEST',
display_order_key: displayOrderKey,
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: sourceSubject,
source_sender_summary: null,
source_received_at: null,
external_conversation_id: null,
conversation_message_count: null,
created_at: '2026-07-08T03:00:00Z',
updated_at: '2026-07-08T03:10:00Z',
},
],
page: {
page_num: 1,
page_size: 20,
total: 1,
},
}
}
function createDeferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve
reject = promiseReject
})
return { promise, resolve, reject }
}
async function mountWithPlugins(component: object, initialPath = '/') {
const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
messages: {
'zh-CN': zhCN,
},
})
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div />' } },
{ path: '/reservation/orders/:orderId', component: { template: '<div />' } },
{ path: '/reservation/tasks/:taskId', component: { template: '<div />' } },
{ path: '/reservation/source-messages/:sourceMessageId/conversation', component: { template: '<div />' } },
],
})
await router.push(initialPath)
await router.isReady()
return mount(component, {
global: {
plugins: [i18n, router],
stubs: {
RouterLink: true,
},
},
})
}
describe('reservation P0 views', () => {
beforeEach(() => {
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.'),
)
const wrapper = await mountWithPlugins(ReservationOrderListView)
await vi.dynamicImportSettled()
expect(wrapper.text()).toContain('接口待接入')
expect(wrapper.text()).not.toContain('#ORD-083')
})
it('renders task list items returned by the backend', async () => {
vi.mocked(service.fetchReservationTaskList).mockResolvedValue(createTaskListResult('GRP-001', 'Booking Request'))
const wrapper = await mountWithPlugins(ReservationTaskListView)
await vi.dynamicImportSettled()
expect(wrapper.text()).toContain('GRP-001')
expect(wrapper.text()).toContain('Booking Request')
expect(wrapper.text()).toContain('接口待补')
})
it('keeps the latest task list response when filters change quickly', async () => {
const firstRequest = createDeferred<Awaited<ReturnType<typeof service.fetchReservationTaskList>>>()
const secondRequest = createDeferred<Awaited<ReturnType<typeof service.fetchReservationTaskList>>>()
vi.mocked(service.fetchReservationTaskList)
.mockReturnValueOnce(firstRequest.promise)
.mockReturnValueOnce(secondRequest.promise)
const wrapper = await mountWithPlugins(ReservationTaskListView)
await wrapper.find('input[type="search"]').setValue('fresh')
expect(service.fetchReservationTaskList).toHaveBeenCalledTimes(2)
secondRequest.resolve(createTaskListResult('GRP-NEW', 'New Booking Request'))
await flushPromises()
expect(wrapper.text()).toContain('GRP-NEW')
firstRequest.resolve(createTaskListResult('GRP-OLD', 'Old Booking Request'))
await flushPromises()
expect(wrapper.text()).toContain('GRP-NEW')
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.'),
)
const wrapper = await mountWithPlugins(
ReservationSourceMessageConversationView,
'/reservation/source-messages/30001/conversation',
)
await vi.dynamicImportSettled()
expect(service.fetchSourceMessageConversation).toHaveBeenCalledWith('30001')
expect(wrapper.text()).toContain('接口待接入')
expect(wrapper.text()).not.toContain('完整邮件正文')
})
})