修复 V4 确认接口边界

This commit is contained in:
andy
2026-07-19 10:58:58 +07:00
parent fdb16cc4c1
commit 080266c633
7 changed files with 216 additions and 17 deletions

View File

@@ -259,6 +259,78 @@ class ReservationV4CommandControllerTest {
assertAuditCount("V4_SOURCE_NOTIFICATION_ACK", "v4-command-admin", notification.id().toString(), 1);
}
@Test
void shouldAckS99SourceNotification() throws Exception {
ReservationV4SourceNotificationSnapshot notification = seedSourceNotification(
HOTEL_ID,
"mail-v4-command-s99-ack-001",
"S99",
Instant.parse("2026-07-19T01:35:00Z"));
performAuthorized(mockMvc, adminToken(), post("/api/reservation/source-notifications/{notificationId}/ack",
notification.id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.notification.notification_status").value("ACKED"))
.andExpect(jsonPath("$.notification.route_code").value("S99"));
assertAuditCount("V4_SOURCE_NOTIFICATION_ACK", "v4-command-admin", notification.id().toString(), 1);
}
@Test
void shouldRejectUnsupportedRouteSourceNotificationAck() throws Exception {
ReservationV4SourceNotificationSnapshot notification = seedSourceNotification(
HOTEL_ID,
"mail-v4-command-unsupported-ack-001",
"S88",
Instant.parse("2026-07-19T01:36:00Z"));
performAuthorized(mockMvc, adminToken(), post("/api/reservation/source-notifications/{notificationId}/ack",
notification.id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0
}
"""))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.error_code").value("V4_SOURCE_NOTIFICATION_ROUTE_NOT_ACKABLE"));
}
@Test
void shouldTreatRepeatedSourceNotificationAckAsIdempotentWithoutExtraAudit() throws Exception {
ReservationV4SourceNotificationSnapshot notification = seedSourceNotification(
HOTEL_ID,
"mail-v4-command-repeat-ack-001",
"S10",
Instant.parse("2026-07-19T01:37:00Z"));
performAuthorized(mockMvc, adminToken(), post("/api/reservation/source-notifications/{notificationId}/ack",
notification.id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0
}
"""))
.andExpect(status().isOk());
performAuthorized(mockMvc, adminToken(), post("/api/reservation/source-notifications/{notificationId}/ack",
notification.id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 1
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.notification.notification_status").value("ACKED"));
assertAuditCount("V4_SOURCE_NOTIFICATION_ACK", "v4-command-admin", notification.id().toString(), 1);
}
@Test
void shouldRejectV4CommandsWhenPermissionMissing() throws Exception {
SeededOrderTask seeded = seedOrderTask(

View File

@@ -250,6 +250,23 @@ class ReservationV4QueryControllerTest {
.andExpect(content().string(not(containsString("https://private.example.test"))));
}
@Test
void shouldNotExposeOrderTaskAsConfirmableWhenOnlyReviewCardsRemain() throws Exception {
ReservationV4OrderTaskSnapshot orderTask = seedReviewRequiredOrderTask(
"mail-v4-query-review-only-001",
Instant.parse("2026-07-18T03:05:00Z"));
performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTask.id())
.param("hotel_id", HOTEL_ID))
.andExpect(status().isOk())
.andExpect(jsonPath("$.availability.read_only").value(true))
.andExpect(jsonPath("$.availability.confirmable").value(false))
.andExpect(jsonPath("$.availability.readonly_reason_code").value("REVIEW_API_PENDING"))
.andExpect(jsonPath("$.basic_information_card.availability.confirmable").value(false))
.andExpect(jsonPath("$.basic_information_card.availability.readonly_reason_code")
.value("REVIEW_API_PENDING"));
}
@Test
void shouldReturnV4OrderTaskListWithCardStatusFilter() throws Exception {
ReservationV4OrderTaskSnapshot orderTask = seedOrderTask("mail-v4-query-list-001",
@@ -478,6 +495,38 @@ class ReservationV4QueryControllerTest {
return orderTask;
}
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);
ReservationV4OrderTaskSnapshot orderTask = workflowRepository.findOrCreateOrderTask(new ReservationV4OrderTaskDraft(
HOTEL_ID,
source.inboxId(),
990000000000000301L,
"order-review-only",
1,
null,
"GROUP",
"GROUP_CODE",
"GRP-V4-REVIEW-ONLY-001",
ReservationV4TargetResolutionStatus.RESOLVED.name(),
ReservationV4OrderTaskStatus.OPEN.name(),
LocalDateTime.ofInstant(receivedAt, ZoneOffset.UTC),
now));
insertCard(orderTask, ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name(), null, 0, 10,
ReservationV4CardStatus.READONLY.name(), null, """
{"card_type":"SOURCE_MESSAGE_DISPLAY","source_message":{"subject":"V4 Query Review"}}
""");
insertCard(orderTask, ReservationV4CardType.BASIC_INFORMATION.name(), null, 0, 20,
ReservationV4CardStatus.REVIEW_REQUIRED.name(), "PENDING", """
{"card_type":"BASIC_INFORMATION","missing_fields":["account_code"]}
""");
insertCard(orderTask, ReservationV4CardType.ROOM_INFORMATION.name(), "NEW_BOOKING", 1, 30,
ReservationV4CardStatus.REVIEW_REQUIRED.name(), "PENDING", """
{"card_type":"ROOM_INFORMATION","event_type":"NEW_BOOKING","missing_fields":["room_items"]}
""");
return orderTask;
}
private ReservationV4SourceNotificationSnapshot seedSourceNotification(
String externalMessageId,
String routeCode,