增加 SuperAgent 邮件会话查询接口
This commit is contained in:
@@ -11,11 +11,14 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
|
||||
import cn.nianxx.thhotel.ThHotelApplication;
|
||||
import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand;
|
||||
import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageMedia;
|
||||
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.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import javax.crypto.Mac;
|
||||
@@ -45,6 +48,8 @@ class ReservationAiQueryControllerTest {
|
||||
private static final String HOTEL_ID = "HOTEL-TEST";
|
||||
private static final String CASE_CONTEXT_ENDPOINT = "/api/ai-query/v1/case-context";
|
||||
private static final String OBJECT_DETAIL_ENDPOINT = "/api/ai-query/v1/object-detail";
|
||||
private static final String CONVERSATION_TASKS_ENDPOINT = "/api/ai-query/v1/message-conversation/tasks";
|
||||
private static final String CONVERSATION_MESSAGES_ENDPOINT = "/api/ai-query/v1/message-conversation/messages";
|
||||
private static final String CLIENT_ID = "superagent-test-client";
|
||||
private static final String SECRET = "test-superagent-secret";
|
||||
private static final String UTC_INSTANT_PATTERN = "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$";
|
||||
@@ -313,24 +318,273 @@ class ReservationAiQueryControllerTest {
|
||||
.andExpect(content().string(not(containsString(SECRET))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldQueryConversationMessagesByExternalConversationIdWithoutRawHtmlOrMediaUrls() throws Exception {
|
||||
String conversationId = "thread-ai-query-conversation-messages-001";
|
||||
captureSourceMessage(
|
||||
"mail-ai-query-conversation-messages-late-001",
|
||||
conversationId,
|
||||
Instant.parse("2026-07-07T09:10:00Z"),
|
||||
"Late message controlled text.",
|
||||
"<html><body><p>Late HTML body</p></body></html>",
|
||||
List.of());
|
||||
captureSourceMessage(
|
||||
"mail-ai-query-conversation-messages-early-001",
|
||||
conversationId,
|
||||
Instant.parse("2026-07-07T09:00:00Z"),
|
||||
"Early message controlled text.",
|
||||
"<html><body onclick=\"alert(1)\"><p>Early HTML body</p>"
|
||||
+ "<img src=\"https://media.example.test/inline.png?token=secret\" />"
|
||||
+ "<a href=\"https://media.example.test/private.pdf?token=secret\">private file</a>"
|
||||
+ "<a href=\"javascript:alert(2)\">unsafe link</a>"
|
||||
+ "<script>alert(3)</script></body></html>",
|
||||
List.of(new CaptureSourceMessageMedia(
|
||||
"ATTACHMENT",
|
||||
"private.pdf",
|
||||
"application/pdf",
|
||||
1000L,
|
||||
"https://media.example.test/private.pdf?token=secret",
|
||||
"attachment-ai-query-001")));
|
||||
|
||||
String body = """
|
||||
{
|
||||
"hotel_id": "HOTEL-TEST",
|
||||
"source_provider": "AGENTBUS",
|
||||
"source_channel": "EMAIL",
|
||||
"external_conversation_id": "%s"
|
||||
}
|
||||
""".formatted(conversationId);
|
||||
|
||||
mockMvc.perform(signedPost(CONVERSATION_MESSAGES_ENDPOINT, body,
|
||||
"nonce-ai-query-conversation-messages-001",
|
||||
"req-ai-query-conversation-messages-001"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.request_id").value("req-ai-query-conversation-messages-001"))
|
||||
.andExpect(jsonPath("$.data.hotel_id").value(HOTEL_ID))
|
||||
.andExpect(jsonPath("$.data.external_conversation_id").value(conversationId))
|
||||
.andExpect(jsonPath("$.data.message_count").value(2))
|
||||
.andExpect(jsonPath("$.data.messages[0].external_source_message_id")
|
||||
.value("mail-ai-query-conversation-messages-early-001"))
|
||||
.andExpect(jsonPath("$.data.messages[0].received_at").value(matchesPattern(UTC_INSTANT_PATTERN)))
|
||||
.andExpect(jsonPath("$.data.messages[0].text_body").value(containsString("Early message")))
|
||||
.andExpect(jsonPath("$.data.messages[0].html_body_sanitized").value(containsString("Early HTML body")))
|
||||
.andExpect(jsonPath("$.data.messages[0].html_render_mode").value("SANITIZED_HTML"))
|
||||
.andExpect(jsonPath("$.data.messages[1].external_source_message_id")
|
||||
.value("mail-ai-query-conversation-messages-late-001"))
|
||||
.andExpect(content().string(not(containsString("\"html_body\":"))))
|
||||
.andExpect(content().string(not(containsString("\"attachments\""))))
|
||||
.andExpect(content().string(not(containsString("media.example.test"))))
|
||||
.andExpect(content().string(not(containsString("token=secret"))))
|
||||
.andExpect(content().string(not(containsString("<script"))))
|
||||
.andExpect(content().string(not(containsString("onclick"))))
|
||||
.andExpect(content().string(not(containsString("javascript:"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFilterConversationMessagesByProviderAndChannelWhenConversationIdOverlaps() throws Exception {
|
||||
String conversationId = "thread-ai-query-provider-overlap-001";
|
||||
captureSourceMessage(
|
||||
"AGENTBUS",
|
||||
"EMAIL",
|
||||
"mail-ai-query-provider-target-001",
|
||||
conversationId,
|
||||
Instant.parse("2026-07-07T09:00:00Z"),
|
||||
"Target provider text.",
|
||||
"<html><body>Target provider HTML.</body></html>",
|
||||
List.of());
|
||||
captureSourceMessage(
|
||||
"OTHER_PROVIDER",
|
||||
"EMAIL",
|
||||
"mail-ai-query-provider-other-001",
|
||||
conversationId,
|
||||
Instant.parse("2026-07-07T09:01:00Z"),
|
||||
"Other provider text must not leak.",
|
||||
"<html><body>Other provider HTML must not leak.</body></html>",
|
||||
List.of());
|
||||
|
||||
String body = """
|
||||
{
|
||||
"hotel_id": "HOTEL-TEST",
|
||||
"source_provider": "AGENTBUS",
|
||||
"source_channel": "EMAIL",
|
||||
"external_conversation_id": "%s"
|
||||
}
|
||||
""".formatted(conversationId);
|
||||
|
||||
mockMvc.perform(signedPost(CONVERSATION_MESSAGES_ENDPOINT, body,
|
||||
"nonce-ai-query-provider-overlap-001",
|
||||
"req-ai-query-provider-overlap-001"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.message_count").value(1))
|
||||
.andExpect(jsonPath("$.data.messages[0].external_source_message_id")
|
||||
.value("mail-ai-query-provider-target-001"))
|
||||
.andExpect(content().string(containsString("Target provider text")))
|
||||
.andExpect(content().string(not(containsString("Other provider text must not leak"))))
|
||||
.andExpect(content().string(not(containsString("mail-ai-query-provider-other-001"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldQueryConversationTasksByExternalSourceMessageIdAnchorInRequiredOrder() throws Exception {
|
||||
String conversationId = "thread-ai-query-conversation-tasks-001";
|
||||
SourceMessageCaptureResult lateSource = captureSourceMessage(
|
||||
"mail-ai-query-conversation-tasks-late-001",
|
||||
conversationId,
|
||||
Instant.parse("2026-07-07T09:10:00Z"),
|
||||
"Late task source.",
|
||||
"<html><body>Late task source.</body></html>",
|
||||
List.of());
|
||||
SourceMessageCaptureResult earlySource = captureSourceMessage(
|
||||
"mail-ai-query-conversation-tasks-early-001",
|
||||
conversationId,
|
||||
Instant.parse("2026-07-07T09:00:00Z"),
|
||||
"Early task source.",
|
||||
"<html><body>Early task source.</body></html>",
|
||||
List.of());
|
||||
insertActiveGroupOrder(920000000000001101L, earlySource.inboxId(), "GRP-AIQUERY-CONV-EARLY-001");
|
||||
insertActiveGroupOrder(920000000000001102L, lateSource.inboxId(), "GRP-AIQUERY-CONV-LATE-001");
|
||||
insertTransition(920000000000001201L, earlySource.inboxId(), 1, "GRP-AIQUERY-CONV-EARLY-001");
|
||||
insertTransition(920000000000001202L, earlySource.inboxId(), 2, "GRP-AIQUERY-CONV-EARLY-001");
|
||||
insertTransition(920000000000001203L, lateSource.inboxId(), 1, "GRP-AIQUERY-CONV-LATE-001");
|
||||
insertTask(
|
||||
920000000000001301L,
|
||||
920000000000001101L,
|
||||
earlySource.inboxId(),
|
||||
920000000000001201L,
|
||||
"READY",
|
||||
1,
|
||||
LocalDateTime.parse("2026-07-07T09:06:00"));
|
||||
insertTask(
|
||||
920000000000001302L,
|
||||
920000000000001101L,
|
||||
earlySource.inboxId(),
|
||||
920000000000001202L,
|
||||
"PENDING_CONFIRM",
|
||||
2,
|
||||
LocalDateTime.parse("2026-07-07T09:05:00"));
|
||||
insertTask(
|
||||
920000000000001303L,
|
||||
920000000000001102L,
|
||||
lateSource.inboxId(),
|
||||
920000000000001203L,
|
||||
"READY",
|
||||
1,
|
||||
LocalDateTime.parse("2026-07-07T08:00:00"));
|
||||
|
||||
String body = """
|
||||
{
|
||||
"hotel_id": "HOTEL-TEST",
|
||||
"source_provider": "AGENTBUS",
|
||||
"source_channel": "EMAIL",
|
||||
"source_message_id": "mail-ai-query-conversation-tasks-early-001"
|
||||
}
|
||||
""";
|
||||
|
||||
mockMvc.perform(signedPost(CONVERSATION_TASKS_ENDPOINT, body,
|
||||
"nonce-ai-query-conversation-tasks-001",
|
||||
"req-ai-query-conversation-tasks-001"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.hotel_id").value(HOTEL_ID))
|
||||
.andExpect(jsonPath("$.data.external_conversation_id").value(conversationId))
|
||||
.andExpect(jsonPath("$.data.task_count").value(3))
|
||||
.andExpect(jsonPath("$.data.tasks[0].task_id").value("920000000000001302"))
|
||||
.andExpect(jsonPath("$.data.tasks[0].external_source_message_id")
|
||||
.value("mail-ai-query-conversation-tasks-early-001"))
|
||||
.andExpect(jsonPath("$.data.tasks[0].task_created_at").value(matchesPattern(UTC_INSTANT_PATTERN)))
|
||||
.andExpect(jsonPath("$.data.tasks[1].task_id").value("920000000000001301"))
|
||||
.andExpect(jsonPath("$.data.tasks[1].external_source_message_id")
|
||||
.value("mail-ai-query-conversation-tasks-early-001"))
|
||||
.andExpect(jsonPath("$.data.tasks[2].task_id").value("920000000000001303"))
|
||||
.andExpect(jsonPath("$.data.tasks[2].external_source_message_id")
|
||||
.value("mail-ai-query-conversation-tasks-late-001"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectConversationMessagesWhenHmacSignatureInvalid() throws Exception {
|
||||
String body = """
|
||||
{
|
||||
"hotel_id": "HOTEL-TEST",
|
||||
"external_conversation_id": "thread-ai-query-hmac-invalid"
|
||||
}
|
||||
""";
|
||||
|
||||
mockMvc.perform(post(CONVERSATION_MESSAGES_ENDPOINT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.header("X-TH-Hotel-Request-Id", "req-ai-query-conversation-hmac-invalid")
|
||||
.header("X-TH-Hotel-SuperAgent-Client-Id", CLIENT_ID)
|
||||
.header("X-TH-Hotel-SuperAgent-Timestamp", Instant.now().toString())
|
||||
.header("X-TH-Hotel-SuperAgent-Nonce", "nonce-ai-query-conversation-hmac-invalid")
|
||||
.header("X-TH-Hotel-SuperAgent-Signature", "sha256=invalid")
|
||||
.content(body))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.request_id").value("req-ai-query-conversation-hmac-invalid"))
|
||||
.andExpect(jsonPath("$.error.code").value("AUTH_SIGNATURE_INVALID"))
|
||||
.andExpect(content().string(not(containsString(SECRET))));
|
||||
}
|
||||
|
||||
private SourceMessageCaptureResult captureSourceMessage(String externalMessageId) {
|
||||
return captureService.capture(new CaptureSourceMessageCommand(
|
||||
HOTEL_ID,
|
||||
return captureSourceMessage(
|
||||
externalMessageId,
|
||||
"thread-" + externalMessageId,
|
||||
Instant.parse("2026-07-07T08:00:00Z"),
|
||||
"Please handle booking message.",
|
||||
"<html><body>Please handle booking message.</body></html>",
|
||||
List.of());
|
||||
}
|
||||
|
||||
private SourceMessageCaptureResult captureSourceMessage(
|
||||
String externalMessageId,
|
||||
String externalConversationId,
|
||||
Instant receivedAt,
|
||||
String textBody,
|
||||
String htmlBody,
|
||||
List<CaptureSourceMessageMedia> mediaItems) {
|
||||
return captureSourceMessage(
|
||||
"AGENTBUS",
|
||||
"EMAIL",
|
||||
externalMessageId,
|
||||
"thread-" + externalMessageId,
|
||||
externalConversationId,
|
||||
receivedAt,
|
||||
textBody,
|
||||
htmlBody,
|
||||
mediaItems);
|
||||
}
|
||||
|
||||
private SourceMessageCaptureResult captureSourceMessage(
|
||||
String provider,
|
||||
String channel,
|
||||
String externalMessageId,
|
||||
String externalConversationId,
|
||||
Instant receivedAt,
|
||||
String textBody,
|
||||
String htmlBody,
|
||||
List<CaptureSourceMessageMedia> mediaItems) {
|
||||
SourceMessageCaptureResult result = captureService.capture(new CaptureSourceMessageCommand(
|
||||
HOTEL_ID,
|
||||
provider,
|
||||
channel,
|
||||
externalMessageId,
|
||||
externalConversationId,
|
||||
"frame-" + externalMessageId,
|
||||
"session-ai-query",
|
||||
Instant.parse("2026-07-07T08:00:00Z"),
|
||||
receivedAt,
|
||||
"guest@example.test",
|
||||
"M002 AI Query",
|
||||
"Please handle booking message.",
|
||||
"<html><body>Please handle booking message.</body></html>",
|
||||
textBody,
|
||||
htmlBody,
|
||||
"{\"source\":{\"external_message_id\":\"" + externalMessageId + "\"}}",
|
||||
"agentbus-outlook-v1",
|
||||
List.of()
|
||||
mediaItems
|
||||
));
|
||||
jdbcTemplate.update("""
|
||||
UPDATE platform_source_message_inbox
|
||||
SET received_at = ?
|
||||
WHERE id = ?
|
||||
""", LocalDateTime.ofInstant(receivedAt, ZoneOffset.UTC), result.inboxId());
|
||||
return result;
|
||||
}
|
||||
|
||||
private void insertActiveGroupOrder(Long orderId, Long sourceMessageId, String groupCode) {
|
||||
@@ -359,10 +613,22 @@ class ReservationAiQueryControllerTest {
|
||||
'update_stay_dates', 'current', ?, ?, ?, 0, '{}', '{}', '{}',
|
||||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
""", transitionId, HOTEL_ID, transitionId - 1, sourceMessageId, sourceEventIndex, groupCode,
|
||||
"0".repeat(64), "1".repeat(64));
|
||||
fixedHash(transitionId), fixedHash(transitionId + 1));
|
||||
}
|
||||
|
||||
private void insertTask(Long taskId, Long orderId, Long sourceMessageId, Long transitionId, String taskStatus) {
|
||||
insertTask(taskId, orderId, sourceMessageId, transitionId, taskStatus, 1, null);
|
||||
}
|
||||
|
||||
private void insertTask(
|
||||
Long taskId,
|
||||
Long orderId,
|
||||
Long sourceMessageId,
|
||||
Long transitionId,
|
||||
String taskStatus,
|
||||
int executionOrder,
|
||||
LocalDateTime createdAt) {
|
||||
LocalDateTime createdAtValue = createdAt == null ? LocalDateTime.now(ZoneOffset.UTC) : createdAt;
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO workflow_reservation_task (
|
||||
id, hotel_id, order_id, source_message_id, ai_transition_id,
|
||||
@@ -371,9 +637,14 @@ class ReservationAiQueryControllerTest {
|
||||
version, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, 'normal_task', 'Update Booking', 'UPDATE_BOOKING',
|
||||
'UPDATE_BOOKING', 'update_stay_dates', ?, 1, 1, 0, 0,
|
||||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
""", taskId, HOTEL_ID, orderId, sourceMessageId, transitionId, taskStatus);
|
||||
'UPDATE_BOOKING', 'update_stay_dates', ?, 1, ?, 0, 0,
|
||||
?, ?)
|
||||
""", taskId, HOTEL_ID, orderId, sourceMessageId, transitionId, taskStatus,
|
||||
executionOrder, createdAtValue, createdAtValue);
|
||||
}
|
||||
|
||||
private String fixedHash(Long value) {
|
||||
return String.format("%064d", value);
|
||||
}
|
||||
|
||||
private MockHttpServletRequestBuilder signedPost(String endpoint, String body, String nonce, String requestId) throws Exception {
|
||||
|
||||
Reference in New Issue
Block a user