实现 M002 订单任务入站与队列规则

This commit is contained in:
andy
2026-07-07 14:29:43 +08:00
parent 7e276469ef
commit f7d77b45bd
78 changed files with 11703 additions and 1 deletions

View File

@@ -0,0 +1,828 @@
package cn.nianxx.thhotel.workflows.reservation.control;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import cn.nianxx.thhotel.ThHotelApplication;
import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult;
import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;
import java.util.List;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
@SpringBootTest(
classes = ThHotelApplication.class,
properties = {
"superagent.task-result.hmac-secret=test-superagent-secret",
"superagent.task-result.clock-skew-seconds=300",
"superagent.task-result.nonce-ttl-seconds=600",
"superagent.task-result.max-body-bytes=12000"
})
@AutoConfigureMockMvc
@ActiveProfiles("test")
class SuperAgentTaskResultControllerTest {
private static final String ENDPOINT = "/api/integrations/superagent/task-results";
private static final String CLIENT_ID = "superagent-test-client";
private static final String SECRET = "test-superagent-secret";
@Autowired
private MockMvc mockMvc;
@Autowired
private SourceMessageCaptureService captureService;
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void shouldRejectRequestWithoutHmacHeaders() throws Exception {
String body = minimalBody("1", "New Booking", "normal_task", "new_fit_reservation", """
"case_keys": {},
"extracted_fields": {}
""");
mockMvc.perform(post(ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error_code").value("AUTH_HEADER_MISSING"))
.andExpect(content().string(not(containsString(SECRET))));
}
@Test
void shouldRejectRequestWithInvalidSignature() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-hmac-invalid-001");
String body = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """
"case_keys": {},
"extracted_fields": {}
""");
String timestamp = Instant.now().toString();
mockMvc.perform(post(ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(body)
.header("X-TH-Hotel-SuperAgent-Client-Id", CLIENT_ID)
.header("X-TH-Hotel-SuperAgent-Timestamp", timestamp)
.header("X-TH-Hotel-SuperAgent-Nonce", "nonce-invalid-signature")
.header("X-TH-Hotel-SuperAgent-Signature", "sha256=invalid"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error_code").value("AUTH_SIGNATURE_INVALID"))
.andExpect(content().string(not(containsString(SECRET))));
}
@Test
void shouldCreateTemporaryOrderTaskAndCardForNewBookingWithoutBusinessKey() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-new-booking-temp-001");
String body = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """
"case_keys": {},
"extracted_fields": {
"booking_object_type": "FIT Reservation",
"arrival_date": "2026-08-01",
"departure_date": "2026-08-02"
}
""");
mockMvc.perform(signedPost(body, "nonce-new-booking-temp-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.source_message_id").value(source.inboxId().toString()))
.andExpect(jsonPath("$.idempotent_replay").value(false))
.andExpect(jsonPath("$.accepted_count").value(1))
.andExpect(jsonPath("$.items[0].source_event_index").value(1))
.andExpect(jsonPath("$.items[0].array_index").value(1))
.andExpect(jsonPath("$.items[0].system_task_type").value("NEW_BOOKING"))
.andExpect(jsonPath("$.items[0].task_card_type").value("NEW_BOOKING"))
.andExpect(jsonPath("$.items[0].task_status").value("PENDING_CONFIRM"))
.andExpect(jsonPath("$.items[0].order_status").value("TEMPORARY"))
.andExpect(content().string(not(containsString(SECRET))));
Long transitionCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_ai_transition
WHERE source_message_id = ?
AND ai_task_type = 'New Booking'
AND system_task_type = 'NEW_BOOKING'
""", Long.class, source.inboxId());
Long temporaryOrderCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_order
WHERE source_message_id = ?
AND order_status = 'TEMPORARY'
AND order_key_type = 'TEMPORARY'
""", Long.class, source.inboxId());
Long taskCardCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_task_card
WHERE ai_payload_json LIKE '%New Booking%'
AND field_contract_version = 'code-v1'
""", Long.class);
org.assertj.core.api.Assertions.assertThat(transitionCount).isEqualTo(1L);
org.assertj.core.api.Assertions.assertThat(temporaryOrderCount).isEqualTo(1L);
org.assertj.core.api.Assertions.assertThat(taskCardCount).isGreaterThanOrEqualTo(1L);
}
@Test
void shouldReturnIdempotentReplayForSameBodyWithNewNonce() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-idempotent-replay-001");
String body = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """
"case_keys": {},
"extracted_fields": {}
""");
mockMvc.perform(signedPost(body, "nonce-idempotent-replay-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.idempotent_replay").value(false));
mockMvc.perform(signedPost(body, "nonce-idempotent-replay-002"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.idempotent_replay").value(true))
.andExpect(jsonPath("$.warnings[0].code").value("IDEMPOTENT_REPLAY"));
Long batchCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_ai_batch
WHERE source_message_id = ?
""", Long.class, source.inboxId());
org.assertj.core.api.Assertions.assertThat(batchCount).isEqualTo(1L);
}
@Test
void shouldRejectSameNonceReplayBeforeProcessingBodyAgain() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-nonce-replay-001");
String body = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """
"case_keys": {},
"extracted_fields": {}
""");
mockMvc.perform(signedPost(body, "nonce-replay-same"))
.andExpect(status().isCreated());
mockMvc.perform(signedPost(body, "nonce-replay-same"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.error_code").value("AUTH_NONCE_REPLAY"));
}
@Test
void shouldRejectDifferentPayloadForSameSourceMessage() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-idempotency-conflict-001");
String firstBody = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """
"case_keys": {},
"extracted_fields": {"arrival_date": "2026-08-01"}
""");
String changedBody = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """
"case_keys": {},
"extracted_fields": {"arrival_date": "2026-08-03"}
""");
mockMvc.perform(signedPost(firstBody, "nonce-idempotency-conflict-001"))
.andExpect(status().isCreated());
mockMvc.perform(signedPost(changedBody, "nonce-idempotency-conflict-002"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.error_code").value("IDEMPOTENCY_CONFLICT"));
}
@Test
void shouldRejectTaskResultWhenStableStringExceedsDatabaseLimit() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-field-too-long-001");
String body = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """
"case_keys": {},
"extracted_fields": {}
""").replace(
"\"catalog_code\": \"S01\"",
"\"catalog_code\": \"" + "S".repeat(33) + "\"");
mockMvc.perform(signedPost(body, "nonce-field-too-long-001"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("TASK_RESULT_FIELD_TOO_LONG"));
}
@Test
void shouldRejectSecurityHeaderWhenClientIdExceedsDatabaseLimit() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-client-id-too-long-001");
String body = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """
"case_keys": {},
"extracted_fields": {}
""");
String longClientId = "client-" + "x".repeat(129);
mockMvc.perform(signedPostWithClientId(body, "nonce-client-id-too-long-001", longClientId))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error_code").value("AUTH_HEADER_INVALID"))
.andExpect(content().string(not(containsString(SECRET))));
Long nonceCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM integration_superagent_task_result_nonce
WHERE nonce = 'nonce-client-id-too-long-001'
""", Long.class);
assertThat(nonceCount).isZero();
}
@Test
void shouldEnforceOnlyOneActiveOrderPerHotelAndBusinessKey() {
jdbcTemplate.update("""
INSERT INTO workflow_reservation_order (
id, hotel_id, order_key_type, order_business_key, active_business_key,
temporary_order_code, order_status, display_name, source_message_id,
version, created_at, updated_at
)
VALUES (
910000000000000001, 'HOTEL-TEST', 'GROUP_CODE', 'GRP-ACTIVE-UNIQUE-001',
'GRP-ACTIVE-UNIQUE-001', 'TMP-UNIQUE-001', 'ACTIVE', 'GRP-ACTIVE-UNIQUE-001',
910000000000000101, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
""");
assertThatThrownBy(() -> jdbcTemplate.update("""
INSERT INTO workflow_reservation_order (
id, hotel_id, order_key_type, order_business_key, active_business_key,
temporary_order_code, order_status, display_name, source_message_id,
version, created_at, updated_at
)
VALUES (
910000000000000002, 'HOTEL-TEST', 'GROUP_CODE', 'GRP-ACTIVE-UNIQUE-001',
'GRP-ACTIVE-UNIQUE-001', 'TMP-UNIQUE-002', 'ACTIVE', 'GRP-ACTIVE-UNIQUE-001',
910000000000000102, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
""")).isInstanceOf(DataIntegrityViolationException.class);
jdbcTemplate.update("""
INSERT INTO workflow_reservation_order (
id, hotel_id, order_key_type, order_business_key, active_business_key,
temporary_order_code, order_status, display_name, source_message_id,
version, created_at, updated_at
)
VALUES (
910000000000000003, 'HOTEL-TEST', 'CONFIRMATION_NUMBER', 'CNF-ACTIVE-UNIQUE-001',
'CNF-ACTIVE-UNIQUE-001', 'TMP-UNIQUE-003', 'ACTIVE', 'CNF-ACTIVE-UNIQUE-001',
910000000000000103, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
""");
assertThatThrownBy(() -> jdbcTemplate.update("""
INSERT INTO workflow_reservation_order (
id, hotel_id, order_key_type, order_business_key, active_business_key,
temporary_order_code, order_status, display_name, source_message_id,
version, created_at, updated_at
)
VALUES (
910000000000000004, 'HOTEL-TEST', 'CONFIRMATION_NUMBER', 'CNF-ACTIVE-UNIQUE-001',
'CNF-ACTIVE-UNIQUE-001', 'TMP-UNIQUE-004', 'ACTIVE', 'CNF-ACTIVE-UNIQUE-001',
910000000000000104, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
""")).isInstanceOf(DataIntegrityViolationException.class);
}
@Test
void shouldEnforceUniqueExecutionOrderForQueueTasksInSameOrder() {
jdbcTemplate.update("""
INSERT INTO workflow_reservation_order (
id, hotel_id, order_key_type, order_business_key, active_business_key,
temporary_order_code, order_status, display_name, source_message_id,
version, created_at, updated_at
)
VALUES (
910000000000000201, 'HOTEL-TEST', 'GROUP_CODE', 'GRP-QUEUE-UNIQUE-001',
'GRP-QUEUE-UNIQUE-001', 'TMP-QUEUE-UNIQUE-001', 'ACTIVE', 'GRP-QUEUE-UNIQUE-001',
910000000000000301, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
""");
jdbcTemplate.update("""
INSERT INTO workflow_reservation_task (
id, hotel_id, order_id, source_message_id, ai_transition_id,
result_type, ai_task_type, system_task_type, task_card_type,
task_status, queue_participation, execution_order,
blocked_until_parent_completed, version, created_at, updated_at
)
VALUES (
910000000000000202, 'HOTEL-TEST', 910000000000000201,
910000000000000301, 910000000000000401, 'normal_task',
'New Booking', 'NEW_BOOKING', 'NEW_BOOKING',
'PENDING_CONFIRM', 1, 1, 0, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
""");
assertThatThrownBy(() -> jdbcTemplate.update("""
INSERT INTO workflow_reservation_task (
id, hotel_id, order_id, source_message_id, ai_transition_id,
result_type, ai_task_type, system_task_type, task_card_type,
task_status, queue_participation, execution_order,
blocked_until_parent_completed, version, created_at, updated_at
)
VALUES (
910000000000000203, 'HOTEL-TEST', 910000000000000201,
910000000000000302, 910000000000000402, 'normal_task',
'Update Booking', 'UPDATE_BOOKING', 'UPDATE_BOOKING',
'PENDING_CONFIRM', 1, 1, 0, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
""")).isInstanceOf(DataIntegrityViolationException.class);
}
@Test
void shouldAttachUpdateBookingToExistingActiveOrderByGroupCode() throws Exception {
SourceMessageCaptureResult newSource = captureSourceMessage("mail-existing-order-new-001");
String newBookingBody = minimalBody(newSource.inboxId().toString(), "New Booking", "normal_task", "new_group_block", """
"case_keys": {"group_code": "GRP-M002-EXIST-001"},
"extracted_fields": {"booking_object_type": "Group Block"}
""");
MvcResult newBookingResult = mockMvc.perform(signedPost(newBookingBody, "nonce-existing-order-new-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.items[0].order_status").value("ACTIVE"))
.andReturn();
String existingOrderId = com.jayway.jsonpath.JsonPath.read(
newBookingResult.getResponse().getContentAsString(),
"$.items[0].order_id"
);
SourceMessageCaptureResult updateSource = captureSourceMessage("mail-existing-order-update-001");
String updateBody = minimalBody(updateSource.inboxId().toString(), "Update Booking", "normal_task", "update_stay_dates", """
"case_keys": {"group_code": "GRP-M002-EXIST-001"},
"extracted_fields": {"update_subtypes": ["update_stay_dates"]}
""");
mockMvc.perform(signedPost(updateBody, "nonce-existing-order-update-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.items[0].order_id").value(existingOrderId))
.andExpect(jsonPath("$.items[0].system_task_type").value("UPDATE_BOOKING"))
.andExpect(jsonPath("$.items[0].task_card_type").value("UPDATE_BOOKING"))
.andExpect(jsonPath("$.items[0].task_status").value("PENDING_CONFIRM"));
}
@Test
void shouldCreateReadOnlyMessageNotificationOnTemporaryOrderOutsideQueue() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-message-notification-001");
String body = minimalBody(
source.inboxId().toString(),
"Message Notification",
"informational_message",
"thank_you",
"""
"case_keys": {},
"informational_message": {
"message_type": "thank_you",
"summary": "Guest acknowledged the booking update."
}
""");
mockMvc.perform(signedPost(body, "nonce-message-notification-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.items[0].system_task_type").value("INFORMATIONAL_MESSAGE"))
.andExpect(jsonPath("$.items[0].task_card_type").value("MESSAGE_NOTIFICATION"))
.andExpect(jsonPath("$.items[0].task_status").value("COMPLETED"))
.andExpect(jsonPath("$.items[0].order_status").value("TEMPORARY"));
Long queueParticipationCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_task
WHERE source_message_id = ?
AND queue_participation = 0
AND task_status = 'COMPLETED'
""", Long.class, source.inboxId());
org.assertj.core.api.Assertions.assertThat(queueParticipationCount).isEqualTo(1L);
}
@Test
void shouldMarkLaterTaskReadOnlyUntilPreviousQueueTaskIsCompletedOrFailed() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-queue-readonly-001");
String body = twoTaskBody(source.inboxId().toString(), "GRP-M002-QUEUE-001");
MvcResult result = mockMvc.perform(signedPost(body, "nonce-queue-readonly-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accepted_count").value(2))
.andExpect(jsonPath("$.items[0].execution_order").value(1))
.andExpect(jsonPath("$.items[1].execution_order").value(2))
.andReturn();
String firstTaskId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[0].task_id");
String secondTaskId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[1].task_id");
mockMvc.perform(get("/api/reservation/tasks/{taskId}", secondTaskId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.task_id").value(secondTaskId))
.andExpect(jsonPath("$.availability.blocked").value(true))
.andExpect(jsonPath("$.availability.read_only").value(true))
.andExpect(jsonPath("$.availability.editable").value(false))
.andExpect(jsonPath("$.availability.confirmable").value(false))
.andExpect(jsonPath("$.availability.executable").value(false))
.andExpect(jsonPath("$.availability.blocked_by_task_id").value(firstTaskId));
jdbcTemplate.update("""
UPDATE workflow_reservation_task
SET task_status = 'READY'
WHERE id = ?
""", Long.valueOf(firstTaskId));
mockMvc.perform(get("/api/reservation/tasks/{taskId}", secondTaskId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.availability.blocked").value(true))
.andExpect(jsonPath("$.availability.blocked_by_task_id").value(firstTaskId));
jdbcTemplate.update("""
UPDATE workflow_reservation_task
SET task_status = 'EXECUTING'
WHERE id = ?
""", Long.valueOf(firstTaskId));
mockMvc.perform(get("/api/reservation/tasks/{taskId}", secondTaskId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.availability.blocked").value(true))
.andExpect(jsonPath("$.availability.blocked_by_task_id").value(firstTaskId));
jdbcTemplate.update("""
UPDATE workflow_reservation_task
SET task_status = 'FAILED'
WHERE id = ?
""", Long.valueOf(firstTaskId));
mockMvc.perform(get("/api/reservation/tasks/{taskId}", secondTaskId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.availability.blocked").value(false))
.andExpect(jsonPath("$.availability.read_only").value(false))
.andExpect(jsonPath("$.availability.editable").value(true))
.andExpect(jsonPath("$.availability.confirmable").value(true));
}
@Test
void shouldReturnTaskDetailFieldsFromTaskCardMatrix() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-detail-matrix-001");
String body = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """
"case_keys": {"confirmation_number": "CNF-MATRIX-001"},
"extracted_fields": {"booking_object_type": "FIT Reservation"}
""");
MvcResult result = mockMvc.perform(signedPost(body, "nonce-detail-matrix-001"))
.andExpect(status().isCreated())
.andReturn();
String taskId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[0].task_id");
mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.task_card_type").value("NEW_BOOKING"))
.andExpect(jsonPath("$.field_contract_version").value("code-v1"))
.andExpect(jsonPath("$.fields.length()").value(18))
.andExpect(jsonPath("$.fields[?(@.field_path=='case_keys.confirmation_number')].display_name")
.value(org.hamcrest.Matchers.contains("Confirmation No.")))
.andExpect(jsonPath("$.fields[?(@.field_path=='case_keys.confirmation_number')].editable")
.value(org.hamcrest.Matchers.contains("")))
.andExpect(jsonPath("$.fields[?(@.field_path=='case_keys.confirmation_number')].write_path")
.value(org.hamcrest.Matchers.contains("confirmed_payload_json.case_keys.confirmation_number")))
.andExpect(jsonPath("$.fields[?(@.field_path=='case_keys.confirmation_number')].value")
.value(org.hamcrest.Matchers.contains("CNF-MATRIX-001")));
}
@Test
void shouldConvertFallbackToUpdateBookingAndLogicDeleteEmptyTemporaryOrder() throws Exception {
SourceMessageCaptureResult targetSource = captureSourceMessage("mail-fallback-target-order-001");
String targetOrderBody = minimalBody(targetSource.inboxId().toString(), "New Booking", "normal_task", "new_group_block", """
"case_keys": {"group_code": "GRP-FALLBACK-TARGET-001"},
"extracted_fields": {"booking_object_type": "Group Block"}
""");
MvcResult targetOrderResult = mockMvc.perform(signedPost(targetOrderBody, "nonce-fallback-target-order-001"))
.andExpect(status().isCreated())
.andReturn();
String targetOrderId = com.jayway.jsonpath.JsonPath.read(
targetOrderResult.getResponse().getContentAsString(),
"$.items[0].order_id"
);
SourceMessageCaptureResult fallbackSource = captureSourceMessage("mail-fallback-convert-001");
String fallbackBody = minimalBody(fallbackSource.inboxId().toString(), "Fallback", "manual_review", "manual_review", """
"case_keys": {"group_code": "GRP-FALLBACK-CANDIDATE-001"},
"manual_review": {"reason_code": "business_boundary_unclear", "visible_reason": "Need human routing."},
"extracted_fields": {}
""");
MvcResult fallbackResult = mockMvc.perform(signedPost(fallbackBody, "nonce-fallback-convert-001"))
.andExpect(status().isCreated())
.andReturn();
String fallbackTaskId = com.jayway.jsonpath.JsonPath.read(
fallbackResult.getResponse().getContentAsString(),
"$.items[0].task_id"
);
String temporaryOrderId = com.jayway.jsonpath.JsonPath.read(
fallbackResult.getResponse().getContentAsString(),
"$.items[0].order_id"
);
mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-conversions", fallbackTaskId)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"target_task_type": "UPDATE_BOOKING",
"target_order_id": "%s"
}
""".formatted(targetOrderId)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.task_id").value(fallbackTaskId))
.andExpect(jsonPath("$.order_id").value(targetOrderId))
.andExpect(jsonPath("$.system_task_type").value("UPDATE_BOOKING"))
.andExpect(jsonPath("$.task_card_type").value("UPDATE_BOOKING"))
.andExpect(jsonPath("$.original_order_logic_deleted").value(true));
String tempOrderStatus = jdbcTemplate.queryForObject("""
SELECT order_status
FROM workflow_reservation_order
WHERE id = ?
""", String.class, Long.valueOf(temporaryOrderId));
Long auditCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_audit_log
WHERE task_id = ?
AND action = 'MANUAL_REVIEW_CONVERT'
""", Long.class, Long.valueOf(fallbackTaskId));
assertThat(tempOrderStatus).isEqualTo("LOGIC_DELETED");
assertThat(auditCount).isEqualTo(1L);
}
@Test
void shouldConvertFallbackToNewBookingAndKeepOriginalTemporaryOrder() throws Exception {
SourceMessageCaptureResult fallbackSource = captureSourceMessage("mail-fallback-convert-new-001");
String fallbackBody = minimalBody(fallbackSource.inboxId().toString(), "Fallback", "manual_review", "manual_review", """
"case_keys": {"confirmation_number": "CNF-FALLBACK-NEW-001"},
"manual_review": {"reason_code": "business_boundary_unclear", "visible_reason": "Need human routing."},
"extracted_fields": {}
""");
MvcResult fallbackResult = mockMvc.perform(signedPost(fallbackBody, "nonce-fallback-convert-new-001"))
.andExpect(status().isCreated())
.andReturn();
String fallbackTaskId = com.jayway.jsonpath.JsonPath.read(
fallbackResult.getResponse().getContentAsString(),
"$.items[0].task_id"
);
String temporaryOrderId = com.jayway.jsonpath.JsonPath.read(
fallbackResult.getResponse().getContentAsString(),
"$.items[0].order_id"
);
mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-conversions", fallbackTaskId)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"target_task_type": "NEW_BOOKING"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.task_id").value(fallbackTaskId))
.andExpect(jsonPath("$.order_id").value(temporaryOrderId))
.andExpect(jsonPath("$.system_task_type").value("NEW_BOOKING"))
.andExpect(jsonPath("$.task_card_type").value("NEW_BOOKING"))
.andExpect(jsonPath("$.original_order_logic_deleted").value(false));
String tempOrderStatus = jdbcTemplate.queryForObject("""
SELECT order_status
FROM workflow_reservation_order
WHERE id = ?
""", String.class, Long.valueOf(temporaryOrderId));
assertThat(tempOrderStatus).isEqualTo("ACTIVE");
String orderBusinessKey = jdbcTemplate.queryForObject("""
SELECT order_business_key
FROM workflow_reservation_order
WHERE id = ?
""", String.class, Long.valueOf(temporaryOrderId));
String activeBusinessKey = jdbcTemplate.queryForObject("""
SELECT active_business_key
FROM workflow_reservation_order
WHERE id = ?
""", String.class, Long.valueOf(temporaryOrderId));
String displayName = jdbcTemplate.queryForObject("""
SELECT display_name
FROM workflow_reservation_order
WHERE id = ?
""", String.class, Long.valueOf(temporaryOrderId));
assertThat(orderBusinessKey).isEqualTo("CNF-FALLBACK-NEW-001");
assertThat(activeBusinessKey).isEqualTo("CNF-FALLBACK-NEW-001");
assertThat(displayName).isEqualTo("CNF-FALLBACK-NEW-001");
}
@Test
void shouldConvertFallbackToCancelBookingWhenTargetOrderProvided() throws Exception {
SourceMessageCaptureResult targetSource = captureSourceMessage("mail-fallback-cancel-target-001");
String targetOrderBody = minimalBody(targetSource.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """
"case_keys": {"confirmation_number": "CNF-FALLBACK-CANCEL-001"},
"extracted_fields": {"booking_object_type": "FIT Reservation"}
""");
MvcResult targetOrderResult = mockMvc.perform(signedPost(targetOrderBody, "nonce-fallback-cancel-target-001"))
.andExpect(status().isCreated())
.andReturn();
String targetOrderId = com.jayway.jsonpath.JsonPath.read(
targetOrderResult.getResponse().getContentAsString(),
"$.items[0].order_id"
);
SourceMessageCaptureResult fallbackSource = captureSourceMessage("mail-fallback-convert-cancel-001");
String fallbackBody = minimalBody(fallbackSource.inboxId().toString(), "Fallback", "manual_review", "manual_review", """
"case_keys": {},
"manual_review": {"reason_code": "route_conflict", "visible_reason": "Need human routing."},
"extracted_fields": {}
""");
MvcResult fallbackResult = mockMvc.perform(signedPost(fallbackBody, "nonce-fallback-convert-cancel-001"))
.andExpect(status().isCreated())
.andReturn();
String fallbackTaskId = com.jayway.jsonpath.JsonPath.read(
fallbackResult.getResponse().getContentAsString(),
"$.items[0].task_id"
);
mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-conversions", fallbackTaskId)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"target_task_type": "CANCEL_BOOKING",
"target_order_id": "%s"
}
""".formatted(targetOrderId)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.task_id").value(fallbackTaskId))
.andExpect(jsonPath("$.order_id").value(targetOrderId))
.andExpect(jsonPath("$.system_task_type").value("CANCEL_BOOKING"))
.andExpect(jsonPath("$.task_card_type").value("CANCEL_BOOKING"))
.andExpect(jsonPath("$.original_order_logic_deleted").value(true));
}
@Test
void shouldRequireTargetOrderWhenConvertingFallbackToUpdateOrCancel() throws Exception {
SourceMessageCaptureResult fallbackSource = captureSourceMessage("mail-fallback-target-required-001");
String fallbackBody = minimalBody(fallbackSource.inboxId().toString(), "Fallback", "manual_review", "manual_review", """
"case_keys": {},
"manual_review": {"reason_code": "route_conflict", "visible_reason": "Need human routing."},
"extracted_fields": {}
""");
MvcResult fallbackResult = mockMvc.perform(signedPost(fallbackBody, "nonce-fallback-target-required-001"))
.andExpect(status().isCreated())
.andReturn();
String fallbackTaskId = com.jayway.jsonpath.JsonPath.read(
fallbackResult.getResponse().getContentAsString(),
"$.items[0].task_id"
);
mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-conversions", fallbackTaskId)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"target_task_type": "CANCEL_BOOKING"
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("TARGET_ORDER_REQUIRED"));
}
private SourceMessageCaptureResult captureSourceMessage(String externalMessageId) {
return captureService.capture(new CaptureSourceMessageCommand(
"HOTEL-TEST",
"AGENTBUS",
"EMAIL",
externalMessageId,
"thread-" + externalMessageId,
"frame-" + externalMessageId,
"session-m002",
Instant.parse("2026-07-07T08:00:00Z"),
"guest@example.test",
"M002 SuperAgent intake",
"Please handle booking message.",
"<html><body>Please handle booking message.</body></html>",
"{\"source\":{\"external_message_id\":\"" + externalMessageId + "\"}}",
"agentbus-outlook-v1",
List.of()
));
}
private org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder signedPost(
String body,
String nonce) throws Exception {
return signedPostWithClientId(body, nonce, CLIENT_ID);
}
private org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder signedPostWithClientId(
String body,
String nonce,
String clientId) throws Exception {
String timestamp = Instant.now().toString();
return post(ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(body)
.header("X-TH-Hotel-SuperAgent-Client-Id", clientId)
.header("X-TH-Hotel-SuperAgent-Timestamp", timestamp)
.header("X-TH-Hotel-SuperAgent-Nonce", nonce)
.header("X-TH-Hotel-SuperAgent-Signature", signature(body, nonce, timestamp, clientId));
}
private String minimalBody(
String sourceMessageId,
String taskType,
String resultType,
String taskSubtype,
String itemFields) {
return """
{
"source_message_id": "%s",
"ai_task_results": [
{
"source_event_index": 1,
"catalog_code": "S01",
"skill_id": "S01_new_booking_skill",
"result_type": "%s",
"task_type": "%s",
"task_subtype": "%s",
"current_or_history": "current",
"visible_reason": "AI extracted a task result.",
"relevant_message_excerpt": "Please handle booking message.",
"attachments": [],
"file_references": [],
"context_used": {},
%s,
"additional_operations": [],
"idempotency_key": null
}
],
"extraction_warnings": []
}
""".formatted(sourceMessageId, resultType, taskType, taskSubtype, itemFields);
}
private String twoTaskBody(String sourceMessageId, String groupCode) {
return """
{
"source_message_id": "%s",
"ai_task_results": [
{
"source_event_index": 1,
"catalog_code": "S01",
"skill_id": "S01_new_booking_skill",
"result_type": "normal_task",
"task_type": "New Booking",
"task_subtype": "new_group_block",
"current_or_history": "current",
"visible_reason": "AI extracted a new booking.",
"relevant_message_excerpt": "Please create the group booking.",
"attachments": [],
"file_references": [],
"context_used": {},
"case_keys": {"group_code": "%s"},
"extracted_fields": {"booking_object_type": "Group Block"},
"additional_operations": [],
"idempotency_key": null
},
{
"source_event_index": 2,
"catalog_code": "S02",
"skill_id": "S02_update_booking_amendment_skill",
"result_type": "normal_task",
"task_type": "Update Booking",
"task_subtype": "update_stay_dates",
"current_or_history": "current",
"visible_reason": "AI extracted an update.",
"relevant_message_excerpt": "Please update the group booking.",
"attachments": [],
"file_references": [],
"context_used": {},
"case_keys": {"group_code": "%s"},
"extracted_fields": {"update_subtypes": ["update_stay_dates"]},
"additional_operations": [],
"idempotency_key": null
}
],
"extraction_warnings": []
}
""".formatted(sourceMessageId, groupCode, groupCode);
}
private String signature(String body, String nonce, String timestamp) throws Exception {
return signature(body, nonce, timestamp, CLIENT_ID);
}
private String signature(String body, String nonce, String timestamp, String clientId) throws Exception {
String bodySha256 = sha256(body);
String canonical = "POST\n" + ENDPOINT + "\n" + timestamp + "\n" + nonce + "\n" + clientId + "\n" + bodySha256;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return "sha256=" + HexFormat.of().formatHex(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8)));
}
private String sha256(String body) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(body.getBytes(StandardCharsets.UTF_8)));
}
}

View File

@@ -0,0 +1,51 @@
package cn.nianxx.thhotel.workflows.reservation.control;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import cn.nianxx.thhotel.ThHotelApplication;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest(
classes = ThHotelApplication.class,
properties = {
"superagent.task-result.hmac-secret=test-superagent-secret",
"superagent.task-result.max-body-bytes=16"
})
@AutoConfigureMockMvc
@ActiveProfiles("test")
class SuperAgentTaskResultMaxBodyControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void shouldRejectBodyLargerThanConfiguredLimitBeforeParsingJson() throws Exception {
mockMvc.perform(post("/api/integrations/superagent/task-results")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"source_message_id\":\"too-large\"}"))
.andExpect(status().isPayloadTooLarge())
.andExpect(jsonPath("$.error_code").value("REQUEST_BODY_TOO_LARGE"))
.andExpect(content().string(not(containsString("test-superagent-secret"))));
}
@Test
void shouldRejectBodyLargerThanConfiguredLimitWhenContextPathIsConfigured() throws Exception {
mockMvc.perform(post("/hotel/api/integrations/superagent/task-results")
.contextPath("/hotel")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"source_message_id\":\"too-large-context-path\"}"))
.andExpect(status().isPayloadTooLarge())
.andExpect(jsonPath("$.error_code").value("REQUEST_BODY_TOO_LARGE"));
}
}