修正SuperAgent任务结果来源消息定位

This commit is contained in:
andy
2026-07-12 14:53:54 +08:00
parent bf796946f9
commit eee37315a0
18 changed files with 789 additions and 86 deletions

View File

@@ -132,6 +132,10 @@ class SuperAgentMcpControllerTest {
.andExpect(jsonPath("$.result.tools[4].name").value("th_hotel_submit_task_results"))
.andExpect(jsonPath("$.result.tools[4].inputSchema.required[0]").value("source_message_id"))
.andExpect(jsonPath("$.result.tools[4].inputSchema.required[1]").value("ai_task_results"))
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_provider.description")
.value(containsString("兼容字段")))
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_channel.description")
.value(containsString("OUTLOOK")))
.andExpect(jsonPath("$.result.tools[4].annotations.readOnlyHint").value(false))
.andExpect(jsonPath("$.result.tools[4].annotations.destructiveHint").value(true));
}

View File

@@ -42,7 +42,8 @@ import org.springframework.test.web.servlet.MvcResult;
"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"
"superagent.task-result.max-body-bytes=12000",
"superagent.task-result.allow-legacy-internal-source-message-id=true"
})
@AutoConfigureMockMvc
@ActiveProfiles("test")
@@ -646,6 +647,17 @@ class SuperAgentTaskResultControllerTest {
.andExpect(jsonPath("$.error_code").value("SOURCE_MESSAGE_NOT_FOUND"));
}
@Test
void shouldRejectTextResultAsAmbiguousWhenExternalSourceMessageMatchesMultipleChannels() throws Exception {
String externalMessageId = "mail-s999-ambiguous-channel-001";
captureSourceMessage(externalMessageId, "HOTEL-TEST", "EMAIL");
captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK");
mockMvc.perform(signedPlainPost("S999," + externalMessageId, "nonce-s999-ambiguous-channel-001"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.error_code").value("SOURCE_MESSAGE_AMBIGUOUS"));
}
@Test
void shouldCreateReadOnlySourceMessageOnlyTaskForStructuredS10Result() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-v3-s10-entry-result-001");
@@ -710,6 +722,29 @@ class SuperAgentTaskResultControllerTest {
assertThat(taskCount).isEqualTo(1L);
}
@Test
void shouldCreateStructuredS10ResultWhenSourceMessageStoredAsOutlookChannel() throws Exception {
String externalMessageId = "mail-v3-s10-outlook-channel-001";
SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK");
String body = structuredS10Body(externalMessageId);
mockMvc.perform(signedPost(body, "nonce-v3-s10-outlook-channel-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.source_message_id").value(externalMessageId))
.andExpect(jsonPath("$.items[0].route_code").value("S10"))
.andExpect(jsonPath("$.items[0].system_process_category").value("SOURCE_MESSAGE_NOTIFICATION"));
Long transitionCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_ai_transition
WHERE source_message_id = ?
AND result_type = 'source_message_review_notification'
AND route_code = 'S10'
AND system_process_category = 'SOURCE_MESSAGE_NOTIFICATION'
""", Long.class, source.inboxId());
assertThat(transitionCount).isEqualTo(1L);
}
@Test
void shouldCreateReadOnlySourceMessageOnlyTaskForStructuredS99Result() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-v3-s99-entry-result-001");
@@ -931,6 +966,170 @@ class SuperAgentTaskResultControllerTest {
assertThat(transitionCount).isEqualTo(1L);
}
@Test
void shouldAcceptV2TaskResultWhenSourceMessageStoredAsOutlookChannelWithoutSourceChannel() throws Exception {
String externalMessageId = "mail-v2-task-result-outlook-channel-001";
SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK");
String body = minimalBodyWithHotel(
externalMessageId,
"New Booking",
"normal_task",
"new_fit_reservation",
"""
"case_keys": {"confirmation_number": "CNF-V2-OUTLOOK-001"},
"extracted_fields": {"booking_object_type": "FIT Reservation"}
""");
mockMvc.perform(signedPost(body, "nonce-v2-task-result-outlook-channel-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accepted_count").value(1))
.andExpect(jsonPath("$.items[0].route_code").value("R01_NEW_FIT_RESERVATION_NORMAL"));
Long transitionCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_ai_transition
WHERE source_message_id = ?
AND route_code = 'R01_NEW_FIT_RESERVATION_NORMAL'
""", Long.class, source.inboxId());
assertThat(transitionCount).isEqualTo(1L);
}
@Test
void shouldAcceptV2TaskResultWhenLegacySourceChannelDoesNotMatchInboxChannel() throws Exception {
String externalMessageId = "mail-v2-task-result-legacy-email-channel-001";
SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK");
String body = minimalBodyWithHotelAndSourceMetadata(
externalMessageId,
"AGENTBUS",
"EMAIL",
"New Booking",
"normal_task",
"new_fit_reservation",
"""
"case_keys": {"confirmation_number": "CNF-V2-LEGACY-EMAIL-001"},
"extracted_fields": {"booking_object_type": "FIT Reservation"}
""");
mockMvc.perform(signedPost(body, "nonce-v2-task-result-legacy-email-channel-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accepted_count").value(1))
.andExpect(jsonPath("$.items[0].route_code").value("R01_NEW_FIT_RESERVATION_NORMAL"));
Long transitionCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_ai_transition
WHERE source_message_id = ?
AND route_code = 'R01_NEW_FIT_RESERVATION_NORMAL'
""", Long.class, source.inboxId());
assertThat(transitionCount).isEqualTo(1L);
}
@Test
void shouldRejectV2TaskResultAsAmbiguousEvenWhenLegacySourceChannelProvided() throws Exception {
String externalMessageId = "mail-v2-task-result-ambiguous-legacy-channel-001";
captureSourceMessage(externalMessageId, "HOTEL-TEST", "EMAIL");
captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK");
String body = minimalBodyWithHotelAndSourceMetadata(
externalMessageId,
"AGENTBUS",
"EMAIL",
"New Booking",
"normal_task",
"new_fit_reservation",
"""
"case_keys": {"confirmation_number": "CNF-V2-AMBIGUOUS-001"},
"extracted_fields": {"booking_object_type": "FIT Reservation"}
""");
mockMvc.perform(signedPost(body, "nonce-v2-task-result-ambiguous-legacy-channel-001"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.error_code").value("SOURCE_MESSAGE_AMBIGUOUS"));
}
@Test
void shouldPreferNumericExternalSourceMessageIdBeforeLegacyInternalId() throws Exception {
String externalMessageId = "1234567890123456789";
SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK");
String body = minimalBody(
externalMessageId,
"New Booking",
"normal_task",
"new_fit_reservation",
"""
"case_keys": {"confirmation_number": "CNF-V2-NUMERIC-001"},
"extracted_fields": {"booking_object_type": "FIT Reservation"}
""");
mockMvc.perform(signedPost(body, "nonce-v2-task-result-numeric-external-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accepted_count").value(1))
.andExpect(jsonPath("$.items[0].route_code").value("R01_NEW_FIT_RESERVATION_NORMAL"));
Long transitionCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_ai_transition
WHERE source_message_id = ?
AND route_code = 'R01_NEW_FIT_RESERVATION_NORMAL'
""", Long.class, source.inboxId());
assertThat(transitionCount).isEqualTo(1L);
}
@Test
void shouldAcceptSourceMessageOnlyTextResultWhenSourceMessageStoredAsOutlookChannel() throws Exception {
String externalMessageId = "mail-s000-outlook-channel-001";
SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK");
mockMvc.perform(signedPlainPost("S000," + externalMessageId, "nonce-s000-outlook-channel-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accepted_count").value(1))
.andExpect(jsonPath("$.items[0].system_process_category").value("SOURCE_MESSAGE_NOTIFICATION"));
Long transitionCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_ai_transition
WHERE source_message_id = ?
AND route_code = 'S000'
AND system_process_category = 'SOURCE_MESSAGE_NOTIFICATION'
""", Long.class, source.inboxId());
assertThat(transitionCount).isEqualTo(1L);
}
@Test
void shouldCreateBusinessTaskFromV3BusinessRootWhenSourceMessageStoredAsOutlookChannel() throws Exception {
String externalMessageId = """
AAMkAGY5YWM5OTNlLTQ1OTktNDQ1Yi05NTk5LTgwNDIzNTAzY2Q4YgBGAAAAAAApDY1IkXlgT51ii6WOFxDvBwAuw1c78vBZRqpnmlmQMZnLAAAAAAEMAAAuw1c78vBZRqpnmlmQMZnLAAKAkhoCAAA=
""".trim();
SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK");
String body = v3BusinessRootBody(externalMessageId, "CNF-V3-OUTLOOK-001");
mockMvc.perform(signedPost(body, "nonce-v3-business-root-outlook-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accepted_count").value(1))
.andExpect(jsonPath("$.items[0].route_code").value("R01_NEW_FIT_RESERVATION_NORMAL"))
.andExpect(jsonPath("$.items[0].system_process_category").value("BUSINESS_TASK"));
Long transitionCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_ai_transition
WHERE source_message_id = ?
AND route_code = 'R01_NEW_FIT_RESERVATION_NORMAL'
AND system_process_category = 'BUSINESS_TASK'
""", Long.class, source.inboxId());
assertThat(transitionCount).isEqualTo(1L);
}
@Test
void shouldRejectV3BusinessRootWhenExternalSourceMessageMatchesMultipleChannels() throws Exception {
String externalMessageId = "mail-v3-business-root-ambiguous-channel-001";
captureSourceMessage(externalMessageId, "HOTEL-TEST", "EMAIL");
captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK");
String body = v3BusinessRootBody(externalMessageId, "CNF-V3-AMBIGUOUS-001");
mockMvc.perform(signedPost(body, "nonce-v3-business-root-ambiguous-channel-001"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.error_code").value("SOURCE_MESSAGE_AMBIGUOUS"));
}
@Test
void shouldConfirmV3BusinessTaskWithoutLegacyVisibleReason() throws Exception {
captureSourceMessage("mail-v3-confirm-without-legacy-visible-reason-001");
@@ -2560,13 +2759,21 @@ class SuperAgentTaskResultControllerTest {
}
private SourceMessageCaptureResult captureSourceMessage(String externalMessageId, String hotelId) {
return captureSourceMessage(externalMessageId, hotelId, "EMAIL");
}
private SourceMessageCaptureResult captureSourceMessage(String externalMessageId, String hotelId, String channel) {
String providerFrameId = "frame-" + externalMessageId;
if (providerFrameId.length() > 128) {
providerFrameId = "frame-" + Integer.toHexString(externalMessageId.hashCode());
}
return captureService.capture(new CaptureSourceMessageCommand(
hotelId,
"AGENTBUS",
"EMAIL",
channel,
externalMessageId,
"thread-" + externalMessageId,
"frame-" + externalMessageId,
providerFrameId,
"session-m002",
Instant.parse("2026-07-07T08:00:00Z"),
"guest@example.test",
@@ -2686,6 +2893,44 @@ class SuperAgentTaskResultControllerTest {
""".formatted(sourceMessageId, resultType, taskType, taskSubtype, itemFields);
}
private String minimalBodyWithHotelAndSourceMetadata(
String sourceMessageId,
String sourceProvider,
String sourceChannel,
String taskType,
String resultType,
String taskSubtype,
String itemFields) {
return """
{
"hotel_id": "HOTEL-TEST",
"source_provider": "%s",
"source_channel": "%s",
"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(sourceProvider, sourceChannel, sourceMessageId, resultType, taskType, taskSubtype, itemFields);
}
private String twoTaskBody(String sourceMessageId, String groupCode) {
return """
{
@@ -2820,6 +3065,79 @@ class SuperAgentTaskResultControllerTest {
""".formatted(externalSourceMessageId, confirmationNumber, arrivalDate, departureDate);
}
private String v3BusinessRootBody(String externalSourceMessageId, String confirmationNumber) {
return """
{
"source_message": {
"source_message_id": "%s",
"subject": "Outlook new booking",
"from": null,
"cc": [],
"received_at": null,
"source_channel": "Email"
},
"message_events": [
{
"event_type": "New Booking",
"event_role": "travel_agent_request",
"source_event_index": "E1",
"current_or_history": "current",
"case_keys": {
"group_code": null,
"confirmation_number": "%s",
"reservation_number": null,
"block_code": null
},
"relevant_message_excerpt": "Please create a new FIT reservation.",
"attachments": [],
"file_references": [],
"context_used": {},
"extracted_fields": {
"booking_object_type": "FIT Reservation",
"arrival_date": "2026-09-01"
},
"manual_review": null
}
],
"case_candidates": [],
"extraction_warnings": [],
"unhandled_current_intents": []
}
""".formatted(externalSourceMessageId, confirmationNumber);
}
private String structuredS10Body(String externalSourceMessageId) {
return """
{
"source_message": {
"source_message_id": "%s",
"subject": null,
"from": null,
"cc": [],
"received_at": null,
"source_channel": "Email"
},
"route_code": "S10",
"handler_type": "main_agent_outcome",
"result_type": "source_message_review_notification",
"current_or_history": "current",
"agent_assessment": {
"status": "no_booking_action_detected",
"reason_code": "no_booking_action_detected",
"automation_action": "none"
},
"notification": {
"required": true,
"notification_type": "source_message_review",
"show_source_message": true,
"requires_user_decision": true,
"visible_message": "未匹配到当前 Agent 支持的业务事件类型,请查看原邮件并决定是否需要回复或进行其他处理。"
},
"manual_review": null
}
""".formatted(externalSourceMessageId);
}
private String[] createReadyTaskWithTwoOperaOperations(
String externalMessageId,
String nonce,

View File

@@ -304,6 +304,109 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
assertThat(transitionCount).isEqualTo(3L);
}
@Test
void shouldNormalizeSingleParentBlockCodeAndCreateParentSplitTask() throws Exception {
ObjectNode root = fixture("parent_split_two_children.json").deepCopy();
useParentSplitBusinessKeys(root, "PARENT-2608-BLOCK", "CHILD-2608-BLOCK-A", "CHILD-2608-BLOCK-B");
convertParentSplitRootToP01(root);
ObjectNode parentEvent = (ObjectNode) root.path("message_events").get(2);
ObjectNode parentCaseKeys = (ObjectNode) parentEvent.path("case_keys");
parentCaseKeys.putNull("group_code");
parentCaseKeys.put("block_code", "PARENT-2608-BLOCK");
String externalId = "p0-parent-split-single-block-code-001";
SourceMessageCaptureResult source = captureSourceMessage(externalId);
mockMvc.perform(signedPost(withSourceMessageId(root, externalId), "nonce-p0-parent-single-block-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accepted_count").value(3))
.andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
.andExpect(jsonPath("$.items[1].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
.andExpect(jsonPath("$.items[2].route_code").value("R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_NORMAL"));
String parentCaseKeysJson = jdbcTemplate.queryForObject("""
SELECT case_keys_json
FROM workflow_reservation_ai_transition
WHERE source_message_id = ?
AND route_code = 'R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_NORMAL'
""", String.class, source.inboxId());
JsonNode normalizedCaseKeys = objectMapper.readTree(parentCaseKeysJson);
assertThat(normalizedCaseKeys.path("group_code").asText()).isEqualTo("PARENT-2608-BLOCK");
assertThat(normalizedCaseKeys.path("block_code").asText()).isEqualTo("PARENT-2608-BLOCK");
String parentPayloadJson = jdbcTemplate.queryForObject("""
SELECT ai_payload_json
FROM workflow_reservation_ai_transition
WHERE source_message_id = ?
AND route_code = 'R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_NORMAL'
""", String.class, source.inboxId());
JsonNode parentPayload = objectMapper.readTree(parentPayloadJson);
assertThat(parentPayload.path("case_keys").path("group_code").asText()).isEqualTo("PARENT-2608-BLOCK");
assertThat(parentPayload.path("v3_message_event").path("case_keys").path("group_code").isNull()).isTrue();
assertThat(parentPayload.path("v3_message_event").path("case_keys").path("block_code").asText())
.isEqualTo("PARENT-2608-BLOCK");
}
@Test
void shouldCreateParentSplitManualReviewWhenParentKeysConflict() throws Exception {
ObjectNode root = fixture("parent_split_two_children.json").deepCopy();
useParentSplitBusinessKeys(root, "PARENT-2608-CONFLICT", "CHILD-2608-CONFLICT-A", "CHILD-2608-CONFLICT-B");
convertParentSplitRootToP01(root);
ObjectNode parentEvent = (ObjectNode) root.path("message_events").get(2);
ObjectNode parentCaseKeys = (ObjectNode) parentEvent.path("case_keys");
parentCaseKeys.putNull("group_code");
parentCaseKeys.putNull("block_code");
ObjectNode contextUsed = (ObjectNode) parentEvent.path("context_used");
ArrayNode candidates = contextUsed.putArray("parent_identity_candidates");
candidates.addObject()
.put("field", "group_code")
.put("value", "PARENT-2608-CONFLICT-A")
.put("evidence_source", "subject");
candidates.addObject()
.put("field", "block_code")
.put("value", "PARENT-2608-CONFLICT-B")
.put("evidence_source", "attachment");
ObjectNode manualReview = parentEvent.putObject("manual_review");
manualReview.put("reason_code", "target_object_unclear");
manualReview.put("visible_reason", "Parent Group 的 group_code 与 block_code 原始候选冲突,请人工确认目标。");
manualReview.put("review_record_type", "business_event_review");
manualReview.putArray("missing_fields")
.add("/case_keys/group_code")
.add("/case_keys/block_code");
manualReview.putArray("blocking_points")
.add("Parent Group identity cannot be safely normalized.");
manualReview.putArray("conflicting_points")
.add("PARENT-2608-CONFLICT-A")
.add("PARENT-2608-CONFLICT-B");
manualReview.putArray("suggested_human_actions")
.add("confirm_parent_group_identity");
manualReview.putArray("evidence_to_check")
.add("parent_identity_candidates");
manualReview.putObject("known_fields");
String externalId = "p0-parent-split-key-conflict-001";
SourceMessageCaptureResult source = captureSourceMessage(externalId);
mockMvc.perform(signedPost(withSourceMessageId(root, externalId), "nonce-p0-parent-key-conflict-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accepted_count").value(3))
.andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
.andExpect(jsonPath("$.items[1].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
.andExpect(jsonPath("$.items[2].route_code").value("R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_REVIEW"))
.andExpect(jsonPath("$.items[2].adapter_error_code").doesNotExist());
Long parentReviewTaskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_task_card card
JOIN workflow_reservation_task task
ON task.id = card.task_id
JOIN workflow_reservation_ai_transition transition
ON transition.id = task.ai_transition_id
WHERE task.source_message_id = ?
AND transition.route_code = 'R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_REVIEW'
AND task.result_type = 'manual_review'
""", Long.class, source.inboxId());
assertThat(parentReviewTaskCount).isEqualTo(1L);
}
@Test
void shouldFailClosedWhenCurrentProducerUsesLegacyParentCancelBooking() throws Exception {
ObjectNode root = fixture("parent_split_two_children.json").deepCopy();

View File

@@ -0,0 +1,132 @@
package cn.nianxx.thhotel.workflows.reservation.control;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
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.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.clock-skew-seconds=300",
"superagent.task-result.nonce-ttl-seconds=600",
"superagent.task-result.max-body-bytes=12000",
"superagent.task-result.allow-legacy-internal-source-message-id=false"
})
@AutoConfigureMockMvc
@ActiveProfiles("test")
class SuperAgentTaskResultStrictSourceMessageIdControllerTest {
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;
@Test
void shouldRejectInternalSourceMessageIdWhenLegacyCompatibilityDisabled() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-strict-external-id-only-001");
String body = """
{
"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_fit_reservation",
"current_or_history": "current",
"visible_reason": "AI extracted a task result.",
"relevant_message_excerpt": "Please handle booking message.",
"attachments": [],
"file_references": [],
"context_used": {},
"case_keys": {},
"extracted_fields": {},
"additional_operations": [],
"idempotency_key": null
}
],
"extraction_warnings": []
}
""".formatted(source.inboxId());
String timestamp = Instant.now().toString();
String nonce = "nonce-strict-external-id-only-001";
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)
.header("X-TH-Hotel-SuperAgent-Signature", signature(body, nonce, timestamp)))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.error_code").value("SOURCE_MESSAGE_NOT_FOUND"));
}
private SourceMessageCaptureResult captureSourceMessage(String externalMessageId) {
return captureService.capture(new CaptureSourceMessageCommand(
"HOTEL-TEST",
"AGENTBUS",
"OUTLOOK",
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 String signature(String body, String nonce, String timestamp) throws Exception {
String canonical = "POST\n"
+ ENDPOINT
+ "\n"
+ timestamp
+ "\n"
+ nonce
+ "\n"
+ CLIENT_ID
+ "\n"
+ sha256(body);
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)));
}
}