完成 M002 V3 CP6 P0 fixtures 回归基线
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
package cn.nianxx.thhotel.integrations.ai.superagent.common.result;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* SuperAgent V3 基础设施输入错误响应。用于 source_message.source_message_id 缺失等调用层错误,
|
||||
* 不使用通用错误包装,保持 0711 P0 契约的扁平结构。
|
||||
*/
|
||||
public record SuperAgentTaskResultInfrastructureInputErrorResponse(
|
||||
@JsonProperty("result_type")
|
||||
String resultType,
|
||||
@JsonProperty("error_code")
|
||||
String errorCode,
|
||||
Boolean retryable,
|
||||
@JsonProperty("missing_fields")
|
||||
List<String> missingFields
|
||||
) {
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package cn.nianxx.thhotel.integrations.ai.superagent.control;
|
||||
|
||||
import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentTaskResultErrorResponse;
|
||||
import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentTaskResultInfrastructureInputErrorResponse;
|
||||
import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentTaskResultException;
|
||||
import cn.nianxx.thhotel.platform.hotel.service.HotelContextException;
|
||||
import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationAiTaskIntakeException;
|
||||
@@ -29,8 +30,16 @@ public class SuperAgentTaskResultControllerAdvice {
|
||||
* 处理 Reservation 接收阶段的 SourceMessage、幂等和 AI item 技术校验异常。
|
||||
*/
|
||||
@ExceptionHandler(ReservationAiTaskIntakeException.class)
|
||||
public ResponseEntity<SuperAgentTaskResultErrorResponse> handleIntakeException(
|
||||
public ResponseEntity<?> handleIntakeException(
|
||||
ReservationAiTaskIntakeException exception) {
|
||||
if ("MISSING_SOURCE_MESSAGE_ID".equals(exception.getErrorCode())) {
|
||||
return ResponseEntity.status(exception.getStatus())
|
||||
.body(new SuperAgentTaskResultInfrastructureInputErrorResponse(
|
||||
"infrastructure_input_error",
|
||||
"missing_source_message_id",
|
||||
true,
|
||||
List.of("source_message.source_message_id")));
|
||||
}
|
||||
return ResponseEntity.status(exception.getStatus())
|
||||
.body(error(exception.getErrorCode(), exception.getMessage()));
|
||||
}
|
||||
|
||||
@@ -108,6 +108,12 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
requestBody);
|
||||
}
|
||||
JsonNode root = parseJson(rawBody);
|
||||
if (isV3SourceMessageIdentityMissing(root)) {
|
||||
throw infrastructureInputError();
|
||||
}
|
||||
if (isMissingSourceMessageInfrastructureInputError(root)) {
|
||||
throw infrastructureInputError();
|
||||
}
|
||||
if (isInfrastructureInputError(root)) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "INFRASTRUCTURE_INPUT_ERROR", "SuperAgent 返回基础设施输入错误。");
|
||||
}
|
||||
@@ -334,18 +340,62 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
* 校验结构化 S10/S99 的最小形态,避免把联合值或空复核误收为正常通知。
|
||||
*/
|
||||
private void validateV3SourceMessageNotificationShape(JsonNode root, ReservationAiRouteDefinition route) {
|
||||
if (!"main_agent_outcome".equals(textAt(root, "handler_type"))
|
||||
|| !AiResultType.SOURCE_MESSAGE_REVIEW_NOTIFICATION.code().equals(textAt(root, "result_type"))
|
||||
|| !"current".equals(textAt(root, "current_or_history"))) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "S10/S99 根结构字段无效。");
|
||||
}
|
||||
JsonNode assessment = root.path("agent_assessment");
|
||||
JsonNode notification = root.path("notification");
|
||||
if (!"none".equals(textAt(assessment, "automation_action"))
|
||||
|| !isBooleanTrue(notification.path("required"))
|
||||
|| !"source_message_review".equals(textAt(notification, "notification_type"))
|
||||
|| !isBooleanTrue(notification.path("show_source_message"))
|
||||
|| !isBooleanTrue(notification.path("requires_user_decision"))
|
||||
|| trimToNull(textAt(notification, "visible_message")) == null) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "S10/S99 通知结构无效。");
|
||||
}
|
||||
JsonNode manualReview = root.get("manual_review");
|
||||
if (route == ReservationAiRouteDefinition.SOURCE_MESSAGE_S10
|
||||
&& manualReview != null
|
||||
&& !manualReview.isNull()) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "S10 manual_review 必须为空。");
|
||||
if (route == ReservationAiRouteDefinition.SOURCE_MESSAGE_S10) {
|
||||
if (!"no_booking_action_detected".equals(textAt(assessment, "status"))
|
||||
|| !"no_booking_action_detected".equals(textAt(assessment, "reason_code"))
|
||||
|| (manualReview != null && !manualReview.isNull())) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "S10 结构不符合 P0 契约。");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (route == ReservationAiRouteDefinition.SOURCE_MESSAGE_S99
|
||||
&& (manualReview == null || manualReview.isNull() || !manualReview.isObject())) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "S99 manual_review 必须为入口复核对象。");
|
||||
if (!"material_package_unavailable".equals(textAt(assessment, "status"))
|
||||
|| !"material_package_unavailable".equals(textAt(assessment, "reason_code"))
|
||||
|| manualReview == null
|
||||
|| manualReview.isNull()
|
||||
|| !manualReview.isObject()
|
||||
|| inspectV3MainAgentReviewIssue(manualReview) != null
|
||||
|| !"material_package_unavailable".equals(textAt(manualReview, "reason_code"))) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "S99 结构不符合 P0 契约。");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 S99 main_agent_entry_review 的 P0 九字段结构。
|
||||
*/
|
||||
private V3EventContractIssue inspectV3MainAgentReviewIssue(JsonNode manualReview) {
|
||||
if (!manualReview.isObject()) {
|
||||
return new V3EventContractIssue("MAIN_AGENT_REVIEW_INVALID", "manual_review 必须是对象。");
|
||||
}
|
||||
if (!"main_agent_entry_review".equals(textAt(manualReview, "review_record_type"))
|
||||
|| trimToNull(textAt(manualReview, "reason_code")) == null
|
||||
|| trimToNull(textAt(manualReview, "visible_reason")) == null
|
||||
|| !manualReview.path("known_fields").isObject()
|
||||
|| !manualReview.path("missing_fields").isArray()
|
||||
|| !manualReview.path("blocking_points").isArray()
|
||||
|| !manualReview.path("conflicting_points").isArray()
|
||||
|| !manualReview.path("suggested_human_actions").isArray()
|
||||
|| !manualReview.path("evidence_to_check").isArray()) {
|
||||
return new V3EventContractIssue("MAIN_AGENT_REVIEW_INCOMPLETE", "manual_review 九字段不完整。");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 V3 业务 event 转为旧任务结果 item 形态,复用现有订单、任务和任务卡创建逻辑。
|
||||
*/
|
||||
@@ -1320,6 +1370,13 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
return node.get(fieldName).asBoolean(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 JSON 节点是否为布尔 true。S10/S99 P0 契约不接受字符串 true 这类宽松输入。
|
||||
*/
|
||||
private boolean isBooleanTrue(JsonNode node) {
|
||||
return node != null && node.isBoolean() && node.booleanValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JSON 节点序列化为字符串,缺失时返回 null。
|
||||
*/
|
||||
@@ -1400,6 +1457,42 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
return "infrastructure_input_error".equals(textAt(root, "result_type"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 P0 明确约定的缺失 source_message_id 基础设施错误;其他 infra 错误不得被改写成该错误。
|
||||
*/
|
||||
private boolean isMissingSourceMessageInfrastructureInputError(JsonNode root) {
|
||||
return isInfrastructureInputError(root)
|
||||
&& "missing_source_message_id".equals(textAt(root, "error_code"));
|
||||
}
|
||||
|
||||
/**
|
||||
* V3 或入口网关形态输入必须先有非空 source_message_id;纯 V2 兼容结构不受该预检查影响。
|
||||
*/
|
||||
private boolean isV3SourceMessageIdentityMissing(JsonNode root) {
|
||||
if (root == null || !root.has("source_message") || !isV3OrGatewayInputShape(root)) {
|
||||
return false;
|
||||
}
|
||||
return trimToNull(textAt(root.path("source_message"), "source_message_id")) == null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 0711 P0 基础设施输入错误,ControllerAdvice 会转换为扁平 typed 响应。
|
||||
*/
|
||||
private ReservationAiTaskIntakeException infrastructureInputError() {
|
||||
return error(HttpStatus.BAD_REQUEST, "MISSING_SOURCE_MESSAGE_ID", "source_message.source_message_id 缺失。");
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断输入是否属于 V3 回调或 0711 P0 入口网关形态,避免误伤旧 V2 兼容 JSON。
|
||||
*/
|
||||
private boolean isV3OrGatewayInputShape(JsonNode root) {
|
||||
return root.path("message_events").isArray()
|
||||
|| root.path("unhandled_current_intents").isArray()
|
||||
|| root.path("candidate_events").isArray()
|
||||
|| root.has("body_current")
|
||||
|| isV3SourceMessageNotification(root);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 V3 结构化 S10/S99 来源邮件通知根。
|
||||
*/
|
||||
@@ -1426,7 +1519,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
JsonNode sourceMessageNode = root.path("source_message");
|
||||
String externalSourceMessageId = trimToNull(textAt(sourceMessageNode, "source_message_id"));
|
||||
if (externalSourceMessageId == null) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "INFRASTRUCTURE_INPUT_ERROR", "source_message.source_message_id 缺失。");
|
||||
throw infrastructureInputError();
|
||||
}
|
||||
validateLength(externalSourceMessageId, "source_message.source_message_id", LENGTH_256);
|
||||
String hotelId = requireText(defaultHotelId, "default_hotel_id", LENGTH_64);
|
||||
|
||||
@@ -898,9 +898,23 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
if (!collapsed.equals(candidates.get(0))) {
|
||||
candidates.add(collapsed);
|
||||
}
|
||||
String legacyAlias = p0ReviewPointerLegacyAlias(collapsed);
|
||||
if (legacyAlias != null && !candidates.contains(legacyAlias)) {
|
||||
candidates.add(legacyAlias);
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容 0711 P0 fixtures 中已迁移为数组结构、但当前后端矩阵仍是扁平字段的复核 pointer。
|
||||
*/
|
||||
private String p0ReviewPointerLegacyAlias(String collapsedFieldPath) {
|
||||
if ("extracted_fields.room_items[].pms_room_type_code".equals(collapsedFieldPath)) {
|
||||
return "extracted_fields.pms_room_type_code";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 JSON Pointer token,拒绝非法的 ~ 转义。
|
||||
*/
|
||||
@@ -1717,7 +1731,33 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
if (editedValues.containsKey(fieldPath)) {
|
||||
return editedValues.get(fieldPath);
|
||||
}
|
||||
return valueAt(aiPayload, fieldPath);
|
||||
Object value = valueAt(aiPayload, fieldPath);
|
||||
if (value != null) {
|
||||
return value;
|
||||
}
|
||||
for (String legacyPath : p0LegacyValuePathCandidates(fieldPath)) {
|
||||
Object legacyValue = valueAt(aiPayload, legacyPath);
|
||||
if (legacyValue != null) {
|
||||
return legacyValue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 0711 P0 已将房型字段迁移到 room_items[];当前矩阵仍使用扁平字段,读取时做最小别名兼容。
|
||||
*/
|
||||
private List<String> p0LegacyValuePathCandidates(String fieldPath) {
|
||||
String safeFieldPath = fieldPath == null ? "" : fieldPath;
|
||||
return switch (safeFieldPath) {
|
||||
case "extracted_fields.room_type" -> List.of(
|
||||
"extracted_fields.room_items.0.room_type_normalized",
|
||||
"extracted_fields.room_items.0.room_type_raw");
|
||||
case "extracted_fields.room_quantity" -> List.of("extracted_fields.room_items.0.room_quantity");
|
||||
case "extracted_fields.pms_room_type_code" -> List.of(
|
||||
"extracted_fields.room_items.0.pms_room_type_code");
|
||||
default -> List.of();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1880,7 +1920,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
definition.operaWriteParticipation(),
|
||||
definition.operaParameterMapping(),
|
||||
definition.notes(),
|
||||
valueAt(aiPayload, definition.fieldPath()));
|
||||
valueForField(aiPayload, Map.of(), definition.fieldPath()));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1904,7 +1944,9 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
JsonNode current = root;
|
||||
for (String rawPart : fieldPath.split("\\.")) {
|
||||
String part = rawPart.replace("[]", "");
|
||||
current = current.path(part);
|
||||
current = current.isArray() && isNumericToken(part)
|
||||
? current.path(Integer.parseInt(part))
|
||||
: current.path(part);
|
||||
if (current.isMissingNode() || current.isNull()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -638,14 +638,15 @@ class SuperAgentTaskResultControllerTest {
|
||||
"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
|
||||
},
|
||||
"manual_review": null
|
||||
}
|
||||
"notification": {
|
||||
"required": true,
|
||||
"notification_type": "source_message_review",
|
||||
"show_source_message": true,
|
||||
"requires_user_decision": true,
|
||||
"visible_message": "未匹配到当前 Agent 支持的业务事件类型,请查看原邮件并决定是否需要回复或进行其他处理。"
|
||||
},
|
||||
"manual_review": null
|
||||
}
|
||||
""";
|
||||
|
||||
MvcResult result = mockMvc.perform(signedPost(body, "nonce-v3-s10-entry-result-001"))
|
||||
@@ -695,23 +696,31 @@ class SuperAgentTaskResultControllerTest {
|
||||
"route_code": "S99",
|
||||
"handler_type": "main_agent_outcome",
|
||||
"result_type": "source_message_review_notification",
|
||||
"current_or_history": "current",
|
||||
"agent_assessment": {
|
||||
"status": "insufficient_business_material",
|
||||
"reason_code": "cannot_form_business_material_package",
|
||||
"automation_action": "none"
|
||||
},
|
||||
"notification": {
|
||||
"required": true,
|
||||
"notification_type": "source_message_review",
|
||||
"show_source_message": true,
|
||||
"requires_user_decision": true
|
||||
},
|
||||
"manual_review": {
|
||||
"reason_code": "cannot_form_business_material_package",
|
||||
"review_notes": "需要人工查看原邮件。"
|
||||
}
|
||||
}
|
||||
"current_or_history": "current",
|
||||
"agent_assessment": {
|
||||
"status": "material_package_unavailable",
|
||||
"reason_code": "material_package_unavailable",
|
||||
"automation_action": "none"
|
||||
},
|
||||
"notification": {
|
||||
"required": true,
|
||||
"notification_type": "source_message_review",
|
||||
"show_source_message": true,
|
||||
"requires_user_decision": true,
|
||||
"visible_message": "当前输入不足,无法判断是否匹配当前 Agent 支持的业务事件类型,请查看原邮件并决定后续处理。"
|
||||
},
|
||||
"manual_review": {
|
||||
"reason_code": "material_package_unavailable",
|
||||
"visible_reason": "当前输入不足,无法完成支持业务事件范围分类。",
|
||||
"review_record_type": "main_agent_entry_review",
|
||||
"missing_fields": [],
|
||||
"blocking_points": [],
|
||||
"conflicting_points": [],
|
||||
"suggested_human_actions": ["review_source_message"],
|
||||
"evidence_to_check": ["source_message"],
|
||||
"known_fields": {}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
MvcResult result = mockMvc.perform(signedPost(body, "nonce-v3-s99-entry-result-001"))
|
||||
@@ -761,23 +770,31 @@ class SuperAgentTaskResultControllerTest {
|
||||
"route_code": "S99",
|
||||
"handler_type": "main_agent_outcome",
|
||||
"result_type": "source_message_review_notification",
|
||||
"current_or_history": "current",
|
||||
"agent_assessment": {
|
||||
"status": "insufficient_business_material",
|
||||
"reason_code": "cannot_form_business_material_package",
|
||||
"automation_action": "none"
|
||||
},
|
||||
"notification": {
|
||||
"required": true,
|
||||
"notification_type": "source_message_review",
|
||||
"show_source_message": true,
|
||||
"requires_user_decision": true
|
||||
},
|
||||
"manual_review": {
|
||||
"reason_code": "cannot_form_business_material_package",
|
||||
"review_notes": "需要人工查看原邮件。"
|
||||
}
|
||||
}
|
||||
"current_or_history": "current",
|
||||
"agent_assessment": {
|
||||
"status": "material_package_unavailable",
|
||||
"reason_code": "material_package_unavailable",
|
||||
"automation_action": "none"
|
||||
},
|
||||
"notification": {
|
||||
"required": true,
|
||||
"notification_type": "source_message_review",
|
||||
"show_source_message": true,
|
||||
"requires_user_decision": true,
|
||||
"visible_message": "当前输入不足,无法判断是否匹配当前 Agent 支持的业务事件类型,请查看原邮件并决定后续处理。"
|
||||
},
|
||||
"manual_review": {
|
||||
"reason_code": "material_package_unavailable",
|
||||
"visible_reason": "当前输入不足,无法完成支持业务事件范围分类。",
|
||||
"review_record_type": "main_agent_entry_review",
|
||||
"missing_fields": [],
|
||||
"blocking_points": [],
|
||||
"conflicting_points": [],
|
||||
"suggested_human_actions": ["review_source_message"],
|
||||
"evidence_to_check": ["source_message"],
|
||||
"known_fields": {}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
MvcResult result = mockMvc.perform(signedPost(body, "nonce-v3-s99-frontend-visible-001"))
|
||||
@@ -810,11 +827,11 @@ class SuperAgentTaskResultControllerTest {
|
||||
.value("source_message_review_notification"))
|
||||
.andExpect(jsonPath("$.source_message_only_result.route_code").value("S99"))
|
||||
.andExpect(jsonPath("$.source_message_only_result.agent_assessment.status")
|
||||
.value("insufficient_business_material"))
|
||||
.value("material_package_unavailable"))
|
||||
.andExpect(jsonPath("$.source_message_only_result.notification.notification_type")
|
||||
.value("source_message_review"))
|
||||
.andExpect(jsonPath("$.source_message_only_result.manual_review.reason_code")
|
||||
.value("cannot_form_business_material_package"));
|
||||
.value("material_package_unavailable"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.control;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
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 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 com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
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.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
||||
|
||||
@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=20000"
|
||||
})
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
class SuperAgentTaskResultP0FixtureRegressionTest {
|
||||
|
||||
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";
|
||||
private static final Path FIXTURES_DIR = Path.of(
|
||||
"..",
|
||||
"docs",
|
||||
"import",
|
||||
"20260711",
|
||||
"开发交付_P0冻结基线_2026-07-11",
|
||||
"03_P0_Acceptance",
|
||||
"fixtures");
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Autowired
|
||||
private SourceMessageCaptureService captureService;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Test
|
||||
void shouldAcceptLegalS10AndS99MainOutcomeFixtures() throws Exception {
|
||||
JsonNode cases = fixture("main_outcomes.json").path("cases");
|
||||
JsonNode s10 = caseValue(cases, "legal_s10_no_supported_match");
|
||||
JsonNode s99 = caseValue(cases, "legal_s99_material_unavailable");
|
||||
String s10ExternalId = "p0-main-outcome-s10-001";
|
||||
String s99ExternalId = "p0-main-outcome-s99-001";
|
||||
captureSourceMessage(s10ExternalId);
|
||||
captureSourceMessage(s99ExternalId);
|
||||
|
||||
MvcResult s10Result = mockMvc.perform(signedPost(withSourceMessageId(s10, s10ExternalId), "nonce-p0-s10-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.items[0].route_code").value("S10"))
|
||||
.andExpect(jsonPath("$.items[0].system_process_category").value("SOURCE_MESSAGE_NOTIFICATION"))
|
||||
.andExpect(jsonPath("$.items[0].system_task_type").value("SOURCE_MESSAGE_ONLY"))
|
||||
.andReturn();
|
||||
MvcResult s99Result = mockMvc.perform(signedPost(withSourceMessageId(s99, s99ExternalId), "nonce-p0-s99-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.items[0].route_code").value("S99"))
|
||||
.andExpect(jsonPath("$.items[0].system_process_category").value("SOURCE_MESSAGE_NOTIFICATION"))
|
||||
.andExpect(jsonPath("$.items[0].system_task_type").value("SOURCE_MESSAGE_ONLY"))
|
||||
.andReturn();
|
||||
|
||||
assertSourceMessageOnlyTaskCount(s10Result, "S10");
|
||||
assertSourceMessageOnlyTaskCount(s99Result, "S99");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectIllegalMainOutcomeFixturesWithoutCreatingTasks() throws Exception {
|
||||
JsonNode cases = fixture("main_outcomes.json").path("cases");
|
||||
for (String caseId : List.of(
|
||||
"illegal_s10_with_review_object",
|
||||
"illegal_s99_with_null_review",
|
||||
"illegal_union_template_value")) {
|
||||
JsonNode value = caseValue(cases, caseId);
|
||||
String externalId = "p0-" + caseId.replace('_', '-');
|
||||
SourceMessageCaptureResult source = captureSourceMessage(externalId);
|
||||
|
||||
mockMvc.perform(signedPost(withSourceMessageId(value, externalId), "nonce-" + externalId))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("ADAPTER_CONTRACT_ERROR"))
|
||||
.andExpect(content().string(notContainsSecret()));
|
||||
|
||||
Long taskCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_reservation_task
|
||||
WHERE source_message_id = ?
|
||||
""", Long.class, source.inboxId());
|
||||
assertThat(taskCount).isZero();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnTypedInfrastructureInputErrorForMissingSourceIdentityFixtures() throws Exception {
|
||||
JsonNode cases = fixture("source_identity_errors.json").path("cases");
|
||||
for (JsonNode fixtureCase : cases) {
|
||||
if (fixtureCase.path("expected_error").isNull()) {
|
||||
continue;
|
||||
}
|
||||
String caseId = fixtureCase.path("case_id").asText();
|
||||
String body = objectMapper.writeValueAsString(fixtureCase.path("input"));
|
||||
|
||||
mockMvc.perform(signedPost(body, "nonce-" + caseId))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.result_type").value("infrastructure_input_error"))
|
||||
.andExpect(jsonPath("$.error_code").value("missing_source_message_id"))
|
||||
.andExpect(jsonPath("$.retryable").value(true))
|
||||
.andExpect(jsonPath("$.missing_fields[0]").value("source_message.source_message_id"))
|
||||
.andExpect(jsonPath("$.route_code").doesNotExist())
|
||||
.andExpect(jsonPath("$.message_events").doesNotExist());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotRewriteOtherInfrastructureInputErrorsToMissingSourceMessageId() throws Exception {
|
||||
mockMvc.perform(signedPost("""
|
||||
{
|
||||
"result_type": "infrastructure_input_error",
|
||||
"error_code": "object_storage_unavailable",
|
||||
"retryable": true,
|
||||
"missing_fields": []
|
||||
}
|
||||
""", "nonce-p0-infra-other-001"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("INFRASTRUCTURE_INPUT_ERROR"))
|
||||
.andExpect(jsonPath("$.result_type").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldLetNonBlankSourceIdentityPassP0GuardWithoutTypedMissingError() throws Exception {
|
||||
JsonNode validInput = caseItem(
|
||||
fixture("source_identity_errors.json").path("cases"),
|
||||
"nonblank_source_message_id_passes_guard").path("input");
|
||||
|
||||
mockMvc.perform(signedPost(objectMapper.writeValueAsString(validInput), "nonce-p0-source-valid-guard-001"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("SOURCE_MESSAGE_REQUIRED"))
|
||||
.andExpect(jsonPath("$.result_type").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldKeepCandidateGateFixtureAsP0RegressionReference() throws Exception {
|
||||
JsonNode cases = fixture("candidate_gate.json").path("cases");
|
||||
|
||||
assertThat(caseIdsByValidity(cases, true)).containsExactly(
|
||||
"valid_coarse_attachment_signal",
|
||||
"valid_single_supported_direction",
|
||||
"valid_main_no_active_signal_routes_s10",
|
||||
"valid_skill_no_supported_event_routes_s10");
|
||||
assertThat(caseIdsByValidity(cases, false)).containsExactly(
|
||||
"invalid_candidate_missing_target_hints",
|
||||
"invalid_candidate_wrong_status",
|
||||
"invalid_candidate_empty_possible_types",
|
||||
"invalid_review_outcome_used_as_candidate",
|
||||
"invalid_evidence_refs_string_array",
|
||||
"invalid_target_hints_object");
|
||||
|
||||
JsonNode supportedSignal = caseItem(cases, "valid_coarse_attachment_signal")
|
||||
.path("candidate_events").get(0);
|
||||
assertThat(supportedSignal.path("classification_status").asText()).isEqualTo("coarse_supported_signal");
|
||||
assertThat(supportedSignal.path("possible_event_types")).hasSize(2);
|
||||
assertThat(supportedSignal.path("evidence_refs").get(0).isObject()).isTrue();
|
||||
assertThat(caseItem(cases, "invalid_review_outcome_used_as_candidate")
|
||||
.path("candidate_events").get(0).path("possible_event_types").get(0).asText())
|
||||
.isEqualTo("Need Manual Review");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateParentSplitTasksFromP0FixtureInEventOrder() throws Exception {
|
||||
ObjectNode root = fixture("parent_split_two_children.json").deepCopy();
|
||||
String externalId = "p0-parent-split-001";
|
||||
SourceMessageCaptureResult source = captureSourceMessage(externalId);
|
||||
|
||||
MvcResult result = mockMvc.perform(signedPost(withSourceMessageId(root, externalId), "nonce-p0-parent-split-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[0].execution_order").value(1))
|
||||
.andExpect(jsonPath("$.items[1].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
|
||||
.andExpect(jsonPath("$.items[1].execution_order").value(1))
|
||||
.andExpect(jsonPath("$.items[2].route_code").value("R07_LINKED_PARENT_RELEASE_AFTER_CHILD_SPLIT_NORMAL"))
|
||||
.andReturn();
|
||||
|
||||
String parentOrderId = com.jayway.jsonpath.JsonPath.read(
|
||||
result.getResponse().getContentAsString(),
|
||||
"$.items[2].order_id");
|
||||
Integer parentExecutionOrder = com.jayway.jsonpath.JsonPath.read(
|
||||
result.getResponse().getContentAsString(),
|
||||
"$.items[2].execution_order");
|
||||
assertThat(parentOrderId).isNotBlank();
|
||||
assertThat(parentExecutionOrder).isEqualTo(1);
|
||||
|
||||
Long transitionCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_reservation_ai_transition
|
||||
WHERE source_message_id = ?
|
||||
AND route_code IN (
|
||||
'R02_NEW_GROUP_BLOCK_NORMAL',
|
||||
'R07_LINKED_PARENT_RELEASE_AFTER_CHILD_SPLIT_NORMAL'
|
||||
)
|
||||
""", Long.class, source.inboxId());
|
||||
assertThat(transitionCount).isEqualTo(3L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateRowDerivedMainParentAndTraceTasksFromP0Fixture() throws Exception {
|
||||
ObjectNode root = fixture("row_multiple_derived.json").deepCopy();
|
||||
String externalId = "p0-row-derived-001";
|
||||
SourceMessageCaptureResult source = captureSourceMessage(externalId);
|
||||
|
||||
mockMvc.perform(signedPost(withSourceMessageId(root, externalId), "nonce-p0-row-derived-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("R07_LINKED_PARENT_RELEASE_AFTER_CHILD_SPLIT_NORMAL"))
|
||||
.andExpect(jsonPath("$.items[2].route_code").value("R13_EXTRA_BED_NORMAL"));
|
||||
|
||||
Long taskCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_reservation_task
|
||||
WHERE source_message_id = ?
|
||||
""", Long.class, source.inboxId());
|
||||
assertThat(taskCount).isEqualTo(3L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPreserveMixedAllotmentUnhandledIntentAndRejectPartialAllotmentAsBusinessTask() throws Exception {
|
||||
JsonNode fixture = fixture("allotment_scope.json");
|
||||
ObjectNode mixedOutput = fixture.path("mixed_partial_allotment").path("output").deepCopy();
|
||||
String externalId = "p0-allotment-mixed-001";
|
||||
SourceMessageCaptureResult source = captureSourceMessage(externalId);
|
||||
|
||||
mockMvc.perform(signedPost(withSourceMessageId(mixedOutput, externalId), "nonce-p0-allotment-mixed-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.accepted_count").value(2))
|
||||
.andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
|
||||
.andExpect(jsonPath("$.items[1].route_code").value("R42_UNHANDLED_CURRENT_INTENT"))
|
||||
.andExpect(jsonPath("$.items[1].task_id").doesNotExist());
|
||||
|
||||
Long unsupportedTaskCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_reservation_ai_transition
|
||||
WHERE source_message_id = ?
|
||||
AND ai_task_type IN (
|
||||
'Allotment Maintenance',
|
||||
'Update Booking / Amendment',
|
||||
'Cancel Allotment'
|
||||
)
|
||||
""", Long.class, source.inboxId());
|
||||
assertThat(unsupportedTaskCount).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateTypeKnownManualReviewAndResolveSameCardFromP0Fixture() throws Exception {
|
||||
JsonNode manualReview = fixture("manual_review_resolution.json");
|
||||
ObjectNode event = manualReview.path("known_subtype_manual_review").path("event").deepCopy();
|
||||
String externalId = "p0-manual-review-same-card-001";
|
||||
SourceMessageCaptureResult source = captureSourceMessage(externalId);
|
||||
String body = businessRoot(externalId, event);
|
||||
|
||||
MvcResult result = mockMvc.perform(signedPost(body, "nonce-p0-manual-review-create-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_REVIEW"))
|
||||
.andExpect(jsonPath("$.items[0].system_task_type").value("NEW_BOOKING"))
|
||||
.andReturn();
|
||||
String taskId = com.jayway.jsonpath.JsonPath.read(
|
||||
result.getResponse().getContentAsString(),
|
||||
"$.items[0].task_id");
|
||||
String orderId = com.jayway.jsonpath.JsonPath.read(
|
||||
result.getResponse().getContentAsString(),
|
||||
"$.items[0].order_id");
|
||||
|
||||
mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-resolutions", taskId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"confirmed_order_id": "%s",
|
||||
"field_overrides": [
|
||||
{
|
||||
"field_pointer": "/extracted_fields/room_items/0/pms_room_type_code",
|
||||
"value": "SU1"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".formatted(orderId)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.task_status").value("READY"))
|
||||
.andExpect(jsonPath("$.review_status").value("RESOLVED"))
|
||||
.andExpect(jsonPath("$.review_resolution.field_overrides[0].field_pointer")
|
||||
.value("/extracted_fields/room_items/0/pms_room_type_code"))
|
||||
.andExpect(jsonPath("$.review_resolution.field_overrides[0].field_path")
|
||||
.value("extracted_fields.pms_room_type_code"))
|
||||
.andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.room_type']")
|
||||
.value("SUITE"))
|
||||
.andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.room_quantity']")
|
||||
.value(2))
|
||||
.andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.pms_room_type_code']")
|
||||
.value("SU1"));
|
||||
|
||||
Long sourceTaskCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_reservation_task
|
||||
WHERE source_message_id = ?
|
||||
""", Long.class, source.inboxId());
|
||||
String aiPayloadJson = jdbcTemplate.queryForObject("""
|
||||
SELECT ai_payload_json
|
||||
FROM workflow_reservation_task_card
|
||||
WHERE task_id = ?
|
||||
""", String.class, Long.valueOf(taskId));
|
||||
JsonNode aiPayload = objectMapper.readTree(aiPayloadJson);
|
||||
assertThat(sourceTaskCount).isEqualTo(1L);
|
||||
assertThat(aiPayload.path("extracted_fields").path("room_items").get(0)
|
||||
.path("pms_room_type_code").isNull()).isTrue();
|
||||
assertThat(aiPayload.path("extracted_fields").has("room_type")).isFalse();
|
||||
assertThat(aiPayloadJson).doesNotContain("\"pms_room_type_code\":\"SU1\"");
|
||||
}
|
||||
|
||||
private JsonNode fixture(String fileName) throws Exception {
|
||||
return objectMapper.readTree(FIXTURES_DIR.resolve(fileName).toFile());
|
||||
}
|
||||
|
||||
private JsonNode caseValue(JsonNode cases, String caseId) {
|
||||
return caseItem(cases, caseId).path("value");
|
||||
}
|
||||
|
||||
private JsonNode caseItem(JsonNode cases, String caseId) {
|
||||
for (JsonNode item : cases) {
|
||||
if (caseId.equals(item.path("case_id").asText())) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("fixture case not found: " + caseId);
|
||||
}
|
||||
|
||||
private List<String> caseIdsByValidity(JsonNode cases, boolean expectValid) {
|
||||
List<String> ids = new java.util.ArrayList<>();
|
||||
for (JsonNode item : cases) {
|
||||
if (item.path("expect_valid").asBoolean(false) == expectValid) {
|
||||
ids.add(item.path("case_id").asText());
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private String withSourceMessageId(JsonNode root, String externalId) throws Exception {
|
||||
ObjectNode copy = root.deepCopy();
|
||||
((ObjectNode) copy.path("source_message")).put("source_message_id", externalId);
|
||||
return objectMapper.writeValueAsString(copy);
|
||||
}
|
||||
|
||||
private String businessRoot(String externalId, JsonNode event) throws Exception {
|
||||
ObjectNode root = objectMapper.createObjectNode();
|
||||
ObjectNode sourceMessage = root.putObject("source_message");
|
||||
sourceMessage.put("source_message_id", externalId);
|
||||
sourceMessage.put("subject", "P0 fixture");
|
||||
sourceMessage.put("from", "agent@example.test");
|
||||
sourceMessage.putArray("cc");
|
||||
sourceMessage.put("received_at", "2026-07-11T10:00:00+08:00");
|
||||
sourceMessage.put("source_channel", "Email");
|
||||
ArrayNode events = root.putArray("message_events");
|
||||
events.add(event);
|
||||
root.putArray("case_candidates");
|
||||
root.putArray("extraction_warnings");
|
||||
root.putArray("unhandled_current_intents");
|
||||
return objectMapper.writeValueAsString(root);
|
||||
}
|
||||
|
||||
private SourceMessageCaptureResult captureSourceMessage(String externalMessageId) {
|
||||
return captureService.capture(new CaptureSourceMessageCommand(
|
||||
"HOTEL-TEST",
|
||||
"AGENTBUS",
|
||||
"EMAIL",
|
||||
externalMessageId,
|
||||
"thread-" + externalMessageId,
|
||||
"frame-" + externalMessageId,
|
||||
"session-m002-p0",
|
||||
Instant.parse("2026-07-11T02:00:00Z"),
|
||||
"agent@example.test",
|
||||
"M002 P0 fixture",
|
||||
"P0 fixture source message.",
|
||||
"<html><body>P0 fixture source message.</body></html>",
|
||||
"{\"source\":{\"external_message_id\":\"" + externalMessageId + "\"}}",
|
||||
"agentbus-outlook-v1",
|
||||
List.of()
|
||||
));
|
||||
}
|
||||
|
||||
private void assertSourceMessageOnlyTaskCount(MvcResult result, String routeCode) throws Exception {
|
||||
String taskId = com.jayway.jsonpath.JsonPath.read(
|
||||
result.getResponse().getContentAsString(),
|
||||
"$.items[0].task_id");
|
||||
Long taskCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_reservation_task task
|
||||
JOIN workflow_reservation_ai_transition transition
|
||||
ON transition.id = task.ai_transition_id
|
||||
WHERE task.id = ?
|
||||
AND transition.route_code = ?
|
||||
AND task.queue_participation = 0
|
||||
""", Long.class, Long.valueOf(taskId), routeCode);
|
||||
assertThat(taskCount).isEqualTo(1L);
|
||||
}
|
||||
|
||||
private MockHttpServletRequestBuilder signedPost(String body, String nonce) throws Exception {
|
||||
String timestamp = Instant.now().toString();
|
||||
return 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));
|
||||
}
|
||||
|
||||
private String signature(String body, String nonce, String timestamp) throws Exception {
|
||||
String bodyHash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(body.getBytes(StandardCharsets.UTF_8)));
|
||||
String canonical = "POST\n" + ENDPOINT + "\n" + timestamp + "\n" + nonce + "\n" + CLIENT_ID + "\n" + bodyHash;
|
||||
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 org.hamcrest.Matcher<String> notContainsSecret() {
|
||||
return org.hamcrest.Matchers.not(containsString(SECRET));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user