修复V4房型信息展示安全边界

This commit is contained in:
andy
2026-07-21 00:58:10 +07:00
parent c1163457a9
commit 1d593f05fd
11 changed files with 2122 additions and 62 deletions

View File

@@ -550,14 +550,17 @@ class ReservationFrontendQueryControllerTest {
ReservationV4CardStatus.CONFIRMED.name(), null, "{}");
confirmV4TaskCard(roomCard.id(), """
{
"business_fields": {
"arrival_date": "2026-07-26",
"departure_date": "2026-07-29",
"rate_code": "BAR",
"room_items": [
{"room_type_code": "RM1", "room_count": 2},
{"room_type_code": "RM2", "room_count": 1}
]
"room_information": {
"final_values": {
"arrival_date": "2026-07-26",
"departure_date": "2026-07-29",
"nights": 3,
"rate_code": "BAR",
"room_items": [
{"room_type_code": "RM1", "room_count": 2},
{"room_type_code": "RM2", "room_count": 1}
]
}
}
}
""", "frontend-query-admin", Instant.parse("2026-07-08T11:20:00Z"));

View File

@@ -2,6 +2,7 @@ package cn.nianxx.thhotel.workflows.reservation.control;
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.loginToken;
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.performAuthorized;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
@@ -324,6 +325,209 @@ class ReservationV4CommandControllerTest {
.doesNotExist());
}
@Test
void shouldConfirmRoomInformationStableFinalValuesAndDropReadonlySubmittedFields() throws Exception {
SeededOrderTask seeded = seedOrderTaskWithBusinessCard(
HOTEL_ID,
"mail-v4-command-confirm-room-info-stable-001",
Instant.parse("2026-07-19T01:13:20Z"),
null,
"""
{
"card_type":"ROOM_INFORMATION",
"event_type":"NEW_BOOKING",
"target_order":{"booking_type":"GROUP","locator_type":"GROUP_CODE","locator_value":"GRP-V4-COMMAND-RI-001"},
"business_fields":{
"event_type":"NEW_BOOKING",
"raw_evidence":"nested raw evidence should not persist",
"attachment_url":"https://oss.example.test/nested-command-private.pdf",
"pms_raw_response":{"debug":"NESTED-COMMAND-SHOULD-NOT-PERSIST"},
"arrival_date":"2026-07-26",
"departure_date":"2026-07-29",
"rate_code":"GROUP",
"room_items":[{"room_type_code":"TWN","room_count":2,"raw_evidence":"NESTED-ROOM-RAW","pms_room_type_code":"NESTED-PMS-SHOULD-NOT-PERSIST"}]
}
}
""");
confirmBasicCard(seeded);
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
seeded.orderTask().id(),
seeded.businessCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0,
"confirmed_payload": {
"room_information": {
"final_values": {
"group_block_name": "前端修正团队名",
"arrival_date": "2026-07-27",
"departure_date": "2026-07-30",
"nights": 99,
"rate_code": "GROUP",
"breakfast_included": false,
"group_booking_status": "DEF",
"room_items": [
{
"room_type_code": "DBL",
"room_count": 3,
"pms_room_type_code": "SHOULD-NOT-PERSIST"
}
],
"adult": 2,
"target_order": {"locator_value": "SHOULD-NOT-PERSIST"}
}
}
}
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.event_type")
.value("NEW_BOOKING"))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.booking_type")
.value("GROUP"))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.group_block_name")
.value("前端修正团队名"))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.arrival_date")
.value("2026-07-27"))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.nights")
.value(3))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.breakfast_included")
.value(true))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.group_booking_status")
.value("DEF"))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.room_items[0].room_type_code")
.value("DBL"))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.room_items[0].room_count")
.value(3))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.room_items[0].pms_room_type_code")
.doesNotExist())
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.adult")
.doesNotExist())
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.target_order")
.doesNotExist())
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.raw_evidence")
.doesNotExist())
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.attachment_url")
.doesNotExist())
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.pms_raw_response")
.doesNotExist())
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.room_items[0].raw_evidence")
.doesNotExist())
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.room_items[0].pms_room_type_code")
.doesNotExist())
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.business_fields").doesNotExist())
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.target_order").doesNotExist())
.andExpect(content().string(not(containsString("nested raw evidence"))))
.andExpect(content().string(not(containsString("https://oss.example.test/nested-command-private.pdf"))))
.andExpect(content().string(not(containsString("NESTED-COMMAND-SHOULD-NOT-PERSIST"))))
.andExpect(content().string(not(containsString("NESTED-ROOM-RAW"))))
.andExpect(content().string(not(containsString("NESTED-PMS-SHOULD-NOT-PERSIST"))));
}
@Test
void shouldRejectRoomInformationConfirmWhenDatesAreInvalid() throws Exception {
SeededOrderTask seeded = seedOrderTaskWithBusinessCard(
HOTEL_ID,
"mail-v4-command-confirm-room-info-invalid-date-001",
Instant.parse("2026-07-19T01:13:25Z"),
null,
"""
{
"card_type":"ROOM_INFORMATION",
"event_type":"NEW_BOOKING",
"target_order":{"booking_type":"GROUP","locator_type":"GROUP_CODE","locator_value":"GRP-V4-COMMAND-RI-DATE-001"},
"business_fields":{
"event_type":"NEW_BOOKING",
"arrival_date":"2026-07-26",
"departure_date":"2026-07-29",
"rate_code":"GROUP",
"room_items":[{"room_type_code":"TWN","room_count":2}]
}
}
""");
confirmBasicCard(seeded);
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
seeded.orderTask().id(),
seeded.businessCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0,
"confirmed_payload": {
"room_information": {
"final_values": {
"arrival_date": "2026-07-30",
"departure_date": "2026-07-29",
"rate_code": "GROUP",
"group_block_name": "GRP-V4-COMMAND-RI-DATE-001",
"room_items": [
{"room_type_code": "TWN", "room_count": 2}
]
}
}
}
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("V4_FIELD_VALIDATION_FAILED"))
.andExpect(jsonPath("$.details[?(@=='room_information.final_values.departure_date: 离店日期不能早于入住日期。')]")
.exists());
}
@Test
void shouldRequireBreakfastWhenFitRateCodeCannotDeriveBreakfast() throws Exception {
SeededOrderTask seeded = seedOrderTaskWithBusinessCard(
HOTEL_ID,
"mail-v4-command-confirm-room-info-fit-breakfast-001",
Instant.parse("2026-07-19T01:13:30Z"),
null,
"""
{
"card_type":"ROOM_INFORMATION",
"event_type":"NEW_BOOKING",
"target_order":{"booking_type":"FIT","locator_type":"BOOKING_CODE","locator_value":"FIT-V4-COMMAND-RI-001"},
"business_fields":{
"event_type":"NEW_BOOKING",
"guest_name":"LI CHUNHONG",
"arrival_date":"2026-07-26",
"departure_date":"2026-07-29",
"rate_code":"FIT",
"room_items":[{"room_type_code":"KING","room_count":1}]
}
}
""");
confirmBasicCard(seeded);
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
seeded.orderTask().id(),
seeded.businessCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0,
"confirmed_payload": {
"room_information": {
"final_values": {
"arrival_date": "2026-07-26",
"departure_date": "2026-07-29",
"rate_code": "FIT",
"room_items": [
{"room_type_code": "KING", "room_count": 1}
]
}
}
}
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("V4_FIELD_VALIDATION_FAILED"))
.andExpect(jsonPath("$.details[?(@=='room_information.final_values.breakfast_included: 含早不能为空。')]")
.exists());
}
@Test
void shouldRejectRepeatedCardConfirm() throws Exception {
SeededOrderTask seeded = seedOrderTask(
@@ -538,6 +742,120 @@ class ReservationV4CommandControllerTest {
.andExpect(jsonPath("$.business_cards[0].review_resolution.reason").value("修正房型目录"));
}
@Test
void shouldResolveRoomInformationReviewWithStablePointer() throws Exception {
SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard(
HOTEL_ID,
"mail-v4-command-review-room-info-stable-001",
Instant.parse("2026-07-19T01:22:06Z"),
990000000000070104L,
ReservationV4CardStatus.PENDING_CONFIRM.name(),
ReservationV4CardStatus.REVIEW_REQUIRED.name(),
"""
{
"card_type":"ROOM_INFORMATION",
"event_type":"NEW_BOOKING",
"target_order":{"booking_type":"GROUP","locator_type":"GROUP_CODE","locator_value":"GRP-V4-REVIEW-RI-001"},
"business_fields":{
"event_type":"NEW_BOOKING",
"arrival_date":"2026-08-01",
"departure_date":"2026-08-03",
"rate_code":"GROUP",
"room_items":[{"room_type_code":"UNKNOWN_TYPE","room_count":2}]
}
}
""",
"""
[
{
"field_path": "room_information.final_values.room_items.0.room_type_code",
"field_pointer": "/room_information/final_values/room_items/0/room_type_code",
"message": "房型代码不在第一版目录中。",
"detail": "room_information.final_values.room_items.0.room_type_code: 房型代码不在第一版目录中。"
}
]
""");
confirmBasicCard(seeded);
performAuthorized(mockMvc, adminToken(), post(
"/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution",
seeded.orderTask().id(),
seeded.businessCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0,
"reason": "按稳定 Room Information 模型修正房型",
"field_overrides": [
{
"field_pointer": "/room_information/final_values/room_items/0/room_type_code",
"value": "RM2"
}
]
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.room_items[0].room_type_code")
.value("RM2"))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.nights")
.value(2))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.business_fields").doesNotExist())
.andExpect(jsonPath("$.business_cards[0].review_resolution.field_overrides[0].field_pointer")
.value("/room_information/final_values/room_items/0/room_type_code"));
}
@Test
void shouldRejectRoomInformationReviewWhenPointerTargetsDerivedReadonlyField() throws Exception {
SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard(
HOTEL_ID,
"mail-v4-command-review-room-info-readonly-001",
Instant.parse("2026-07-19T01:22:07Z"),
990000000000070105L,
ReservationV4CardStatus.PENDING_CONFIRM.name(),
ReservationV4CardStatus.REVIEW_REQUIRED.name(),
"""
{
"card_type":"ROOM_INFORMATION",
"event_type":"NEW_BOOKING",
"target_order":{"booking_type":"GROUP","locator_type":"GROUP_CODE","locator_value":"GRP-V4-REVIEW-RI-002"},
"business_fields":{
"event_type":"NEW_BOOKING",
"arrival_date":"2026-08-01",
"departure_date":"2026-08-03",
"rate_code":"GROUP",
"room_items":[{"room_type_code":"TWN","room_count":2}]
}
}
""",
"""
[
{
"field_path": "room_information.final_values.nights",
"field_pointer": "/room_information/final_values/nights",
"message": "Nights 由日期派生,不能人工写入。",
"detail": "room_information.final_values.nights: Nights 由日期派生,不能人工写入。"
}
]
""");
confirmBasicCard(seeded);
performAuthorized(mockMvc, adminToken(), post(
"/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution",
seeded.orderTask().id(),
seeded.businessCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0,
"field_overrides": [
{"field_pointer": "/room_information/final_values/nights", "value": 7}
]
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("V4_REVIEW_POINTER_READONLY"));
}
@Test
void shouldRejectReviewResolutionWhenResolvedOrderTaskRebindsToDifferentOrder() throws Exception {
Long currentOrderId = 990000000000070111L;

View File

@@ -28,6 +28,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTask
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.enums.AiResultType;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationAiSystemProcessCategory;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationSystemTaskType;
@@ -252,9 +253,9 @@ class ReservationV4QueryControllerTest {
.value(contains(true)))
.andExpect(jsonPath("$.business_cards[0].card_type").value("ROOM_INFORMATION"))
.andExpect(jsonPath("$.business_cards[0].display_payload.event_type").value("NEW_BOOKING"))
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_items/0/room_type_code')].control_type")
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/room_items/0/room_type_code')].control_type")
.value(contains("select")))
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_items/0/room_count')].control_type")
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/room_items/0/room_count')].control_type")
.value(contains("number")))
.andExpect(jsonPath("$.business_cards[0].ai_payload_json").doesNotExist())
.andExpect(jsonPath("$.availability.read_only").value(false))
@@ -264,6 +265,273 @@ class ReservationV4QueryControllerTest {
.andExpect(content().string(not(containsString("https://private.example.test"))));
}
@Test
void shouldReturnRoomInformationDisplayModelForNewGroupBooking() throws Exception {
ReservationV4OrderTaskSnapshot orderTask = seedOrderTaskWithBusinessPayload(
"mail-v4-room-info-new-group-001",
Instant.parse("2026-07-18T03:01:00Z"),
"GROUP",
"GROUP_CODE",
"GRP-V4-RI-GROUP-001",
"NEW_BOOKING",
ReservationV4CardStatus.CONFIRMED.name(),
ReservationV4CardStatus.PENDING_CONFIRM.name(),
"""
{
"event_type":"NEW_BOOKING",
"target_order":{"booking_type":"GROUP","locator_type":"GROUP_CODE","locator_value":"GRP-V4-RI-GROUP-001"},
"raw_evidence":"raw evidence should not leak",
"attachment_url":"https://oss.example.test/private-room-info.pdf",
"pms_raw_response":{"confirmation_number":"SHOULD-NOT-LEAK"},
"business_fields":{
"event_type":"NEW_BOOKING",
"raw_evidence":"nested raw evidence should not leak",
"attachment_url":"https://oss.example.test/nested-private-room-info.pdf",
"pms_raw_response":{"debug":"NESTED-SHOULD-NOT-LEAK"},
"arrival_date":"2026-07-26",
"departure_date":"2026-07-29",
"rate_code":"GROUP",
"room_items":[{"room_type_code":"TWN","room_count":2,"pms_room_type_code":"NESTED-PMS-SHOULD-NOT-LEAK","raw_evidence":"NESTED-ROOM-RAW"}]
}
}
""",
null);
performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTask.id())
.param("hotel_id", HOTEL_ID))
.andExpect(status().isOk())
.andExpect(jsonPath("$.business_cards[0].display_payload.room_information.event_type")
.value("NEW_BOOKING"))
.andExpect(jsonPath("$.business_cards[0].display_payload.room_information.booking_type")
.value("GROUP"))
.andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.group_block_name")
.value("GRP-V4-RI-GROUP-001"))
.andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.arrival_date")
.value("2026-07-26"))
.andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.departure_date")
.value("2026-07-29"))
.andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.nights")
.value(3))
.andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.rate_code")
.value("GROUP"))
.andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.breakfast_included")
.value(true))
.andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.group_booking_status")
.value("TEN"))
.andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.group_booking_status_label")
.value("TEN-Tentative"))
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/group_block_name')].editable")
.value(contains(true)))
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/arrival_date')].control_type")
.value(contains("date")))
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/rate_code')].options_source")
.value(contains("reservation_v4_rate_code_catalog")))
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/room_items/0/room_type_code')].options_source")
.value(contains("reservation_v4_room_type_catalog")))
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/target_order/locator_value')]")
.doesNotExist())
.andExpect(jsonPath("$.business_cards[0].display_payload.target_order").doesNotExist())
.andExpect(jsonPath("$.business_cards[0].display_payload.business_fields").doesNotExist())
.andExpect(jsonPath("$.business_cards[0].display_payload.raw_evidence").doesNotExist())
.andExpect(jsonPath("$.business_cards[0].display_payload.attachment_url").doesNotExist())
.andExpect(jsonPath("$.business_cards[0].display_payload.pms_raw_response").doesNotExist())
.andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.raw_evidence").doesNotExist())
.andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.attachment_url").doesNotExist())
.andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.pms_raw_response").doesNotExist())
.andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.room_items[0].pms_room_type_code").doesNotExist())
.andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.room_items[0].raw_evidence").doesNotExist())
.andExpect(content().string(not(containsString("ai_payload_json"))))
.andExpect(content().string(not(containsString("raw evidence"))))
.andExpect(content().string(not(containsString("nested raw evidence"))))
.andExpect(content().string(not(containsString("https://oss.example.test/private-room-info.pdf"))))
.andExpect(content().string(not(containsString("https://oss.example.test/nested-private-room-info.pdf"))))
.andExpect(content().string(not(containsString("SHOULD-NOT-LEAK"))))
.andExpect(content().string(not(containsString("NESTED-SHOULD-NOT-LEAK"))))
.andExpect(content().string(not(containsString("NESTED-PMS-SHOULD-NOT-LEAK"))))
.andExpect(content().string(not(containsString("NESTED-ROOM-RAW"))));
}
@Test
void shouldReturnRoomInformationUpdateCurrentProposedFinalAndNightsChangeSummary() throws Exception {
Long orderId = 990000000000777001L;
seedConfirmedRoomInformationProjection(
orderId,
"mail-v4-room-info-current-projection-001",
Instant.parse("2026-07-18T02:50:00Z"),
"""
{
"room_information":{
"final_values":{
"group_block_name":"GRP-V4-RI-UPDATE-001",
"arrival_date":"2026-07-26",
"departure_date":"2026-07-29",
"nights":3,
"rate_code":"GROUP",
"breakfast_included":true,
"group_booking_status":"TEN",
"room_items":[{"room_type_code":"TWN","room_count":2}]
}
}
}
""");
ReservationV4OrderTaskSnapshot orderTask = seedOrderTaskWithBusinessPayload(
"mail-v4-room-info-update-001",
Instant.parse("2026-07-18T03:02:00Z"),
orderId,
"GROUP",
"GROUP_CODE",
"GRP-V4-RI-UPDATE-001",
"UPDATE_BOOKING",
ReservationV4CardStatus.CONFIRMED.name(),
ReservationV4CardStatus.PENDING_CONFIRM.name(),
"""
{
"event_type":"UPDATE_BOOKING",
"target_order":{"booking_type":"GROUP","locator_type":"GROUP_CODE","locator_value":"GRP-V4-RI-UPDATE-001"},
"business_fields":{
"event_type":"UPDATE_BOOKING",
"after":{
"arrival_date":"2026-07-27",
"departure_date":"2026-07-31",
"rate_code":"BAR",
"room_items":[{"room_type_code":"DBL","room_count":3}]
}
}
}
""",
null);
performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTask.id())
.param("hotel_id", HOTEL_ID))
.andExpect(status().isOk())
.andExpect(jsonPath("$.business_cards[0].display_payload.room_information.current_values.arrival_date")
.value("2026-07-26"))
.andExpect(jsonPath("$.business_cards[0].display_payload.room_information.proposed_values.arrival_date")
.value("2026-07-27"))
.andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.arrival_date")
.value("2026-07-27"))
.andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.departure_date")
.value("2026-07-31"))
.andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.nights")
.value(4))
.andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.rate_code")
.value("GROUP"))
.andExpect(jsonPath("$.business_cards[0].display_payload.room_information.change_summary[?(@.field=='rate_code')]")
.doesNotExist())
.andExpect(jsonPath("$.business_cards[0].display_payload.room_information.change_summary[?(@.field=='nights')].before")
.value(contains(3)))
.andExpect(jsonPath("$.business_cards[0].display_payload.room_information.change_summary[?(@.field=='nights')].after")
.value(contains(4)))
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/rate_code')]")
.doesNotExist())
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/room_items/0/room_count')].editable")
.value(contains(true)));
}
@Test
void shouldOnlyMarkRoomInformationReviewProblemFieldsEditable() throws Exception {
ReservationV4OrderTaskSnapshot orderTask = seedOrderTaskWithBusinessPayload(
"mail-v4-room-info-review-fields-001",
Instant.parse("2026-07-18T03:02:30Z"),
"GROUP",
"GROUP_CODE",
"GRP-V4-RI-REVIEW-FIELDS-001",
"NEW_BOOKING",
ReservationV4CardStatus.CONFIRMED.name(),
ReservationV4CardStatus.REVIEW_REQUIRED.name(),
"""
{
"event_type":"NEW_BOOKING",
"target_order":{"booking_type":"GROUP","locator_type":"GROUP_CODE","locator_value":"GRP-V4-RI-REVIEW-FIELDS-001"},
"business_fields":{
"event_type":"NEW_BOOKING",
"arrival_date":"2026-08-01",
"departure_date":"2026-08-03",
"rate_code":"GROUP",
"room_items":[{"room_type_code":"UNKNOWN_TYPE","room_count":2}]
}
}
""",
null,
"""
[
{
"field_path": "room_information.final_values.room_items.0.room_type_code",
"field_pointer": "/room_information/final_values/room_items/0/room_type_code",
"message": "房型代码不在第一版目录中。",
"detail": "room_information.final_values.room_items.0.room_type_code: 房型代码不在第一版目录中。"
}
]
""");
performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTask.id())
.param("hotel_id", HOTEL_ID))
.andExpect(status().isOk())
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/room_items/0/room_type_code')].editable")
.value(contains(true)))
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/arrival_date')].editable")
.value(contains(false)))
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/departure_date')].editable")
.value(contains(false)))
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/rate_code')].editable")
.value(contains(false)));
}
@Test
void shouldReturnReadonlyRoomInformationDisplayModelForCancelBooking() throws Exception {
Long orderId = 990000000000777002L;
seedConfirmedRoomInformationProjection(
orderId,
"mail-v4-room-info-current-projection-002",
Instant.parse("2026-07-18T02:55:00Z"),
"""
{
"room_information":{
"final_values":{
"group_block_name":"GRP-V4-RI-CANCEL-001",
"arrival_date":"2026-08-01",
"departure_date":"2026-08-05",
"nights":4,
"rate_code":"GROUP",
"breakfast_included":true,
"group_booking_status":"DEF",
"room_items":[{"room_type_code":"KING","room_count":1}]
}
}
}
""");
ReservationV4OrderTaskSnapshot orderTask = seedOrderTaskWithBusinessPayload(
"mail-v4-room-info-cancel-001",
Instant.parse("2026-07-18T03:03:00Z"),
orderId,
"GROUP",
"GROUP_CODE",
"GRP-V4-RI-CANCEL-001",
"CANCEL_BOOKING",
ReservationV4CardStatus.CONFIRMED.name(),
ReservationV4CardStatus.PENDING_CONFIRM.name(),
"""
{
"event_type":"CANCEL_BOOKING",
"target_order":{"booking_type":"GROUP","locator_type":"GROUP_CODE","locator_value":"GRP-V4-RI-CANCEL-001"},
"business_fields":{"event_type":"CANCEL_BOOKING"}
}
""",
null);
performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTask.id())
.param("hotel_id", HOTEL_ID))
.andExpect(status().isOk())
.andExpect(jsonPath("$.business_cards[0].display_payload.room_information.event_type")
.value("CANCEL_BOOKING"))
.andExpect(jsonPath("$.business_cards[0].display_payload.room_information.current_values.arrival_date")
.value("2026-08-01"))
.andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.room_items[0].room_type_code")
.value("KING"))
.andExpect(jsonPath("$.business_cards[0].fields.length()").value(0))
.andExpect(jsonPath("$.business_cards[0].availability.confirmable").value(true));
}
@Test
void shouldExposeReviewCardsAsReviewableButNotConfirmable() throws Exception {
ReservationV4OrderTaskSnapshot orderTask = seedReviewRequiredOrderTask(
@@ -584,6 +852,157 @@ class ReservationV4QueryControllerTest {
Instant receivedAt,
String orderRef,
String targetLocatorValue) {
return seedOrderTaskWithBusinessPayload(
source,
aiBatchId,
receivedAt,
orderRef,
null,
"GROUP",
"GROUP_CODE",
targetLocatorValue,
"NEW_BOOKING",
ReservationV4CardStatus.PENDING_CONFIRM.name(),
ReservationV4CardStatus.PENDING_CONFIRM.name(),
"""
{"card_type":"ROOM_INFORMATION","event_type":"NEW_BOOKING","room_items":[{"room_type_code":"TWN","room_count":2}]}
""",
null);
}
private ReservationV4OrderTaskSnapshot seedOrderTaskWithBusinessPayload(
String externalMessageId,
Instant receivedAt,
String targetBookingType,
String targetLocatorType,
String targetLocatorValue,
String eventType,
String basicCardStatus,
String businessCardStatus,
String businessDisplayPayloadJson,
String confirmedPayloadJson) {
SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "V4 Query Business", receivedAt, HOTEL_ID);
return seedOrderTaskWithBusinessPayload(
source,
990000000000001001L + Math.abs(externalMessageId.hashCode()),
receivedAt,
"order-room-info-" + externalMessageId,
null,
targetBookingType,
targetLocatorType,
targetLocatorValue,
eventType,
basicCardStatus,
businessCardStatus,
businessDisplayPayloadJson,
confirmedPayloadJson);
}
private ReservationV4OrderTaskSnapshot seedOrderTaskWithBusinessPayload(
String externalMessageId,
Instant receivedAt,
String targetBookingType,
String targetLocatorType,
String targetLocatorValue,
String eventType,
String basicCardStatus,
String businessCardStatus,
String businessDisplayPayloadJson,
String confirmedPayloadJson,
String businessValidationErrorsJson) {
SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "V4 Query Business", receivedAt, HOTEL_ID);
return seedOrderTaskWithBusinessPayload(
source,
990000000000001001L + Math.abs(externalMessageId.hashCode()),
receivedAt,
"order-room-info-" + externalMessageId,
null,
targetBookingType,
targetLocatorType,
targetLocatorValue,
eventType,
basicCardStatus,
businessCardStatus,
businessDisplayPayloadJson,
confirmedPayloadJson,
businessValidationErrorsJson);
}
private ReservationV4OrderTaskSnapshot seedOrderTaskWithBusinessPayload(
String externalMessageId,
Instant receivedAt,
Long orderId,
String targetBookingType,
String targetLocatorType,
String targetLocatorValue,
String eventType,
String basicCardStatus,
String businessCardStatus,
String businessDisplayPayloadJson,
String confirmedPayloadJson) {
SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "V4 Query Business", receivedAt, HOTEL_ID);
return seedOrderTaskWithBusinessPayload(
source,
990000000000002001L + Math.abs(externalMessageId.hashCode()),
receivedAt,
"order-room-info-" + externalMessageId,
orderId,
targetBookingType,
targetLocatorType,
targetLocatorValue,
eventType,
basicCardStatus,
businessCardStatus,
businessDisplayPayloadJson,
confirmedPayloadJson);
}
private ReservationV4OrderTaskSnapshot seedOrderTaskWithBusinessPayload(
SourceMessageCaptureResult source,
Long aiBatchId,
Instant receivedAt,
String orderRef,
Long orderId,
String targetBookingType,
String targetLocatorType,
String targetLocatorValue,
String eventType,
String basicCardStatus,
String businessCardStatus,
String businessDisplayPayloadJson,
String confirmedPayloadJson) {
return seedOrderTaskWithBusinessPayload(
source,
aiBatchId,
receivedAt,
orderRef,
orderId,
targetBookingType,
targetLocatorType,
targetLocatorValue,
eventType,
basicCardStatus,
businessCardStatus,
businessDisplayPayloadJson,
confirmedPayloadJson,
null);
}
private ReservationV4OrderTaskSnapshot seedOrderTaskWithBusinessPayload(
SourceMessageCaptureResult source,
Long aiBatchId,
Instant receivedAt,
String orderRef,
Long orderId,
String targetBookingType,
String targetLocatorType,
String targetLocatorValue,
String eventType,
String basicCardStatus,
String businessCardStatus,
String businessDisplayPayloadJson,
String confirmedPayloadJson,
String businessValidationErrorsJson) {
LocalDateTime now = LocalDateTime.ofInstant(receivedAt.plusSeconds(10), ZoneOffset.UTC);
ReservationV4OrderTaskSnapshot orderTask = workflowRepository.findOrCreateOrderTask(new ReservationV4OrderTaskDraft(
HOTEL_ID,
@@ -591,9 +1010,9 @@ class ReservationV4QueryControllerTest {
aiBatchId,
orderRef,
1,
null,
"GROUP",
"GROUP_CODE",
orderId,
targetBookingType,
targetLocatorType,
targetLocatorValue,
ReservationV4TargetResolutionStatus.RESOLVED.name(),
ReservationV4OrderTaskStatus.OPEN.name(),
@@ -604,16 +1023,61 @@ class ReservationV4QueryControllerTest {
{"card_type":"SOURCE_MESSAGE_DISPLAY","source_message":{"subject":"V4 Query Business"}}
""");
insertCard(orderTask, ReservationV4CardType.BASIC_INFORMATION.name(), null, 0, 20,
ReservationV4CardStatus.PENDING_CONFIRM.name(), null, """
basicCardStatus, reviewStatusFor(basicCardStatus), """
{"card_type":"BASIC_INFORMATION","order_ref":"order-1","basic_information":{"account_code":"QBD_TRAVEL","market_code":"LEISURE","source_code":"TRAVEL_AGENT"}}
""");
insertCard(orderTask, ReservationV4CardType.ROOM_INFORMATION.name(), "NEW_BOOKING", 1, 30,
ReservationV4CardStatus.PENDING_CONFIRM.name(), null, """
{"card_type":"ROOM_INFORMATION","event_type":"NEW_BOOKING","room_items":[{"room_type_code":"TWN","room_count":2}]}
""");
ReservationV4TaskCardSnapshot businessCard = insertCard(
orderTask,
ReservationV4CardType.ROOM_INFORMATION.name(),
eventType,
1,
30,
businessCardStatus,
reviewStatusFor(businessCardStatus),
businessDisplayPayloadJson,
businessValidationErrorsJson);
if (confirmedPayloadJson != null) {
workflowRepository.confirmTaskCardWithVersion(
HOTEL_ID,
businessCard.id(),
businessCard.version(),
confirmedPayloadJson,
"v4-query-admin",
now);
workflowRepository.updateOrderTaskStatus(
HOTEL_ID,
orderTask.id(),
ReservationV4OrderTaskStatus.COMPLETED.name(),
now);
}
return orderTask;
}
private void seedConfirmedRoomInformationProjection(
Long orderId,
String externalMessageId,
Instant receivedAt,
String confirmedPayloadJson) {
seedOrderTaskWithBusinessPayload(
externalMessageId,
receivedAt,
orderId,
"GROUP",
"GROUP_CODE",
"GRP-V4-RI-PROJECTION",
"NEW_BOOKING",
ReservationV4CardStatus.CONFIRMED.name(),
ReservationV4CardStatus.PENDING_CONFIRM.name(),
"""
{"card_type":"ROOM_INFORMATION","event_type":"NEW_BOOKING","business_fields":{"event_type":"NEW_BOOKING"}}
""",
confirmedPayloadJson);
}
private String reviewStatusFor(String cardStatus) {
return ReservationV4CardStatus.REVIEW_REQUIRED.name().equals(cardStatus) ? "PENDING" : null;
}
private ReservationV4OrderTaskSnapshot seedReviewRequiredOrderTask(String externalMessageId, Instant receivedAt) {
SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "V4 Query Review", receivedAt, HOTEL_ID);
LocalDateTime now = LocalDateTime.ofInstant(receivedAt.plusSeconds(10), ZoneOffset.UTC);
@@ -669,7 +1133,7 @@ class ReservationV4QueryControllerTest {
LocalDateTime.ofInstant(receivedAt.plusSeconds(20), ZoneOffset.UTC)));
}
private void insertCard(
private ReservationV4TaskCardSnapshot insertCard(
ReservationV4OrderTaskSnapshot orderTask,
String cardType,
String eventType,
@@ -678,7 +1142,29 @@ class ReservationV4QueryControllerTest {
String cardStatus,
String reviewStatus,
String displayPayloadJson) {
workflowRepository.insertTaskCard(new ReservationV4TaskCardDraft(
return insertCard(
orderTask,
cardType,
eventType,
sourceEventIndex,
sortOrder,
cardStatus,
reviewStatus,
displayPayloadJson,
null);
}
private ReservationV4TaskCardSnapshot insertCard(
ReservationV4OrderTaskSnapshot orderTask,
String cardType,
String eventType,
Integer sourceEventIndex,
Integer sortOrder,
String cardStatus,
String reviewStatus,
String displayPayloadJson,
String validationErrorsJson) {
return workflowRepository.insertTaskCard(new ReservationV4TaskCardDraft(
HOTEL_ID,
orderTask.id(),
orderTask.sourceMessageId(),
@@ -693,7 +1179,7 @@ class ReservationV4QueryControllerTest {
{"private_url":"https://private.example.test/raw","raw":"must stay internal"}
""",
displayPayloadJson,
null,
validationErrorsJson,
orderTask.createdAt()));
}