实现 SourceMessage Inbox 与 AgentBus 入站闭环
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
package cn.nianxx.thhotel.integrations.messaging.agentbus.adapter;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
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.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
class AgentBusFrameProcessorTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void shouldIgnoreSessionReadyAndMarkStatusWithoutCapturing() {
|
||||
SourceMessageCaptureService captureService = mock(SourceMessageCaptureService.class);
|
||||
AgentBusConnectionStatus status = new AgentBusConnectionStatus();
|
||||
AgentBusFrameProcessor processor = processor(captureService, status, properties(true, 1024));
|
||||
|
||||
AgentBusFrameProcessResult result = processor.process("""
|
||||
{"type":"session.ready","session_id":"session-agentbus-001"}
|
||||
""");
|
||||
|
||||
assertThat(result.outcome()).isEqualTo("IGNORED");
|
||||
assertThat(status.snapshot().sessionReady()).isTrue();
|
||||
assertThat(status.snapshot().ignoredFrameCount()).isEqualTo(1L);
|
||||
verifyNoInteractions(captureService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCaptureBusinessFrameIntoSourceMessageInbox() {
|
||||
SourceMessageCaptureService captureService = mock(SourceMessageCaptureService.class);
|
||||
when(captureService.capture(any(CaptureSourceMessageCommand.class)))
|
||||
.thenReturn(new SourceMessageCaptureResult(88001L, true, false, "RECEIVED"));
|
||||
AgentBusConnectionStatus status = new AgentBusConnectionStatus();
|
||||
AgentBusFrameProcessor processor = processor(captureService, status, properties(true, 4096));
|
||||
|
||||
AgentBusFrameProcessResult result = processor.process("""
|
||||
{
|
||||
"id": "frame-agentbus-capture-001",
|
||||
"session_id": "session-agentbus-capture",
|
||||
"payload": {
|
||||
"body": {"text": "Please update my reservation.", "html": "<html>Reservation</html>"},
|
||||
"source": {
|
||||
"channel": "email",
|
||||
"external_message_id": "mail-agentbus-capture-001",
|
||||
"external_conversation_id": "conversation-agentbus-capture",
|
||||
"sender": "guest@example.test",
|
||||
"subject": "Reservation update"
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
assertThat(result.outcome()).isEqualTo("CAPTURED");
|
||||
assertThat(result.inboxId()).isEqualTo(88001L);
|
||||
assertThat(status.snapshot().capturedFrameCount()).isEqualTo(1L);
|
||||
ArgumentCaptor<CaptureSourceMessageCommand> captor = ArgumentCaptor.forClass(CaptureSourceMessageCommand.class);
|
||||
verify(captureService).capture(captor.capture());
|
||||
assertThat(captor.getValue().hotelId()).isEqualTo("HOTEL-TEST");
|
||||
assertThat(captor.getValue().externalMessageId()).isEqualTo("mail-agentbus-capture-001");
|
||||
assertThat(captor.getValue().providerFrameId()).isEqualTo("frame-agentbus-capture-001");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldIgnoreBusinessFrameWhenCaptureDisabled() {
|
||||
SourceMessageCaptureService captureService = mock(SourceMessageCaptureService.class);
|
||||
AgentBusConnectionStatus status = new AgentBusConnectionStatus();
|
||||
AgentBusFrameProcessor processor = processor(captureService, status, properties(false, 4096));
|
||||
|
||||
AgentBusFrameProcessResult result = processor.process("""
|
||||
{
|
||||
"payload": {
|
||||
"body": {"text": "Ignored body"},
|
||||
"source": {"external_message_id": "mail-agentbus-disabled-001"}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
assertThat(result.outcome()).isEqualTo("IGNORED");
|
||||
assertThat(status.snapshot().ignoredFrameCount()).isEqualTo(1L);
|
||||
verifyNoInteractions(captureService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectOversizedFrameWithoutCapturing() {
|
||||
SourceMessageCaptureService captureService = mock(SourceMessageCaptureService.class);
|
||||
AgentBusConnectionStatus status = new AgentBusConnectionStatus();
|
||||
AgentBusFrameProcessor processor = processor(captureService, status, properties(true, 10));
|
||||
|
||||
AgentBusFrameProcessResult result = processor.process("{\"type\":\"business\",\"payload\":{\"text\":\"too-large\"}}");
|
||||
|
||||
assertThat(result.outcome()).isEqualTo("REJECTED");
|
||||
assertThat(status.snapshot().rejectedFrameCount()).isEqualTo(1L);
|
||||
assertThat(status.snapshot().lastErrorCode()).isEqualTo("FRAME_TOO_LARGE");
|
||||
verifyNoInteractions(captureService);
|
||||
}
|
||||
|
||||
private AgentBusFrameProcessor processor(
|
||||
SourceMessageCaptureService captureService,
|
||||
AgentBusConnectionStatus status,
|
||||
AgentBusProperties properties) {
|
||||
return new AgentBusFrameProcessor(
|
||||
objectMapper,
|
||||
new AgentBusSourceMessageAdapter(objectMapper),
|
||||
captureService,
|
||||
status,
|
||||
properties);
|
||||
}
|
||||
|
||||
private AgentBusProperties properties(boolean captureEnabled, int maxFrameBytes) {
|
||||
AgentBusProperties properties = new AgentBusProperties();
|
||||
properties.getCapture().setEnabled(captureEnabled);
|
||||
properties.getCapture().setDefaultHotelId("HOTEL-TEST");
|
||||
properties.setMaxFrameBytes(maxFrameBytes);
|
||||
return properties;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package cn.nianxx.thhotel.integrations.messaging.agentbus.adapter;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.time.Instant;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AgentBusSourceMessageAdapterTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final AgentBusSourceMessageAdapter adapter = new AgentBusSourceMessageAdapter(objectMapper);
|
||||
|
||||
@Test
|
||||
void shouldMapOutlookFrameToStableCaptureCommand() throws Exception {
|
||||
JsonNode frame = objectMapper.readTree("""
|
||||
{
|
||||
"id": "frame-agentbus-001",
|
||||
"session_id": "session-agentbus-001",
|
||||
"payload": {
|
||||
"text": "Fallback body text",
|
||||
"body": {
|
||||
"content_type": "html",
|
||||
"html": "<html><body>Please change booking</body></html>",
|
||||
"text": "Please change booking"
|
||||
},
|
||||
"inline_images": [
|
||||
{
|
||||
"id": "inline-001",
|
||||
"file_name": "inline.png",
|
||||
"content_type": "image/png",
|
||||
"size_bytes": 1200,
|
||||
"url": "https://media.example.test/inline.png"
|
||||
}
|
||||
],
|
||||
"attachments": [
|
||||
{
|
||||
"id": "attachment-001",
|
||||
"name": "booking.pdf",
|
||||
"content_type": "application/pdf",
|
||||
"size": 3400,
|
||||
"external_url": "https://media.example.test/booking.pdf"
|
||||
}
|
||||
],
|
||||
"source": {
|
||||
"channel": "email",
|
||||
"external_message_id": "outlook-message-001",
|
||||
"external_conversation_id": "outlook-conversation-001",
|
||||
"sender": "guest@example.test",
|
||||
"subject": "Booking change",
|
||||
"sent_at": "2026-07-06T08:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
CaptureSourceMessageCommand command = adapter.toCaptureCommand("HOTEL-TEST", frame);
|
||||
|
||||
assertThat(command.hotelId()).isEqualTo("HOTEL-TEST");
|
||||
assertThat(command.provider()).isEqualTo("AGENTBUS");
|
||||
assertThat(command.channel()).isEqualTo("EMAIL");
|
||||
assertThat(command.externalMessageId()).isEqualTo("outlook-message-001");
|
||||
assertThat(command.externalConversationId()).isEqualTo("outlook-conversation-001");
|
||||
assertThat(command.providerFrameId()).isEqualTo("frame-agentbus-001");
|
||||
assertThat(command.providerSessionId()).isEqualTo("session-agentbus-001");
|
||||
assertThat(command.sourceSentAt()).isEqualTo(Instant.parse("2026-07-06T08:00:00Z"));
|
||||
assertThat(command.senderIdentifier()).isEqualTo("guest@example.test");
|
||||
assertThat(command.subject()).isEqualTo("Booking change");
|
||||
assertThat(command.textBody()).isEqualTo("Please change booking");
|
||||
assertThat(command.htmlBody()).contains("Please change booking");
|
||||
assertThat(command.payloadJson()).contains("outlook-message-001");
|
||||
assertThat(command.schemaVersion()).isEqualTo("agentbus-outlook-v1");
|
||||
assertThat(command.mediaItems()).hasSize(2);
|
||||
assertThat(command.mediaItems().get(0).mediaType()).isEqualTo("INLINE_IMAGE");
|
||||
assertThat(command.mediaItems().get(0).externalUrl()).isEqualTo("https://media.example.test/inline.png");
|
||||
assertThat(command.mediaItems().get(1).mediaType()).isEqualTo("ATTACHMENT");
|
||||
assertThat(command.mediaItems().get(1).fileName()).isEqualTo("booking.pdf");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package cn.nianxx.thhotel.platform.message.control;
|
||||
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.hamcrest.Matchers.not;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
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.request.CaptureSourceMessageMedia;
|
||||
import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService;
|
||||
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
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.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@SpringBootTest(
|
||||
classes = ThHotelApplication.class,
|
||||
properties = "source-message.original-read.access-key=test-original-read-key")
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
class SourceMessageControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private SourceMessageCaptureService captureService;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Test
|
||||
void shouldListAndReadSummaryWithoutOriginalContentOrMediaUrls() throws Exception {
|
||||
SourceMessageCaptureResult result = captureService.capture(command(
|
||||
"mail-api-001",
|
||||
"conversation-api-001",
|
||||
"Please update guest name. Private phone 13800138000.",
|
||||
"<html><body>Private HTML</body></html>",
|
||||
"https://media.example.test/private.pdf?token=secret"
|
||||
));
|
||||
|
||||
mockMvc.perform(get("/api/source-messages")
|
||||
.param("hotelId", "HOTEL-TEST")
|
||||
.param("externalConversationId", "conversation-api-001")
|
||||
.param("pageNum", "1")
|
||||
.param("pageSize", "20"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.items[0].id").value(result.inboxId().toString()))
|
||||
.andExpect(jsonPath("$.items[0].externalMessageId").value("mail-api-001"))
|
||||
.andExpect(jsonPath("$.items[0].externalConversationId").value("conversation-api-001"))
|
||||
.andExpect(jsonPath("$.items[0].captureStatus").value("RECEIVED"))
|
||||
.andExpect(jsonPath("$.items[0].safeSnippet").value(containsString("Please update guest name")))
|
||||
.andExpect(content().string(not(containsString("13800138000"))))
|
||||
.andExpect(content().string(not(containsString("Private HTML"))))
|
||||
.andExpect(content().string(not(containsString("media.example.test"))))
|
||||
.andExpect(content().string(not(containsString("token=secret"))))
|
||||
.andExpect(content().string(not(containsString("payloadJson"))));
|
||||
|
||||
mockMvc.perform(get("/api/source-messages/{id}", result.inboxId()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.id").value(result.inboxId().toString()))
|
||||
.andExpect(jsonPath("$.externalMessageId").value("mail-api-001"))
|
||||
.andExpect(jsonPath("$.safeSnippet").value(containsString("Please update guest name")))
|
||||
.andExpect(content().string(not(containsString("Private HTML"))))
|
||||
.andExpect(content().string(not(containsString("media.example.test"))))
|
||||
.andExpect(content().string(not(containsString("payloadJson"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectOriginalReadWithoutAccessKey() throws Exception {
|
||||
SourceMessageCaptureResult result = captureService.capture(command(
|
||||
"mail-original-denied-001",
|
||||
"conversation-original-denied-001",
|
||||
"Private original text",
|
||||
"<html><body>Private original HTML</body></html>",
|
||||
"https://media.example.test/denied.pdf?token=secret"
|
||||
));
|
||||
|
||||
mockMvc.perform(get("/api/source-messages/{id}/original", result.inboxId())
|
||||
.header("X-TH-Hotel-Actor", "operator-001")
|
||||
.header("X-TH-Hotel-Access-Scene", "reservation-detail"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReadOriginalContentWithAccessKeyAndRecordAudit() throws Exception {
|
||||
SourceMessageCaptureResult result = captureService.capture(command(
|
||||
"mail-original-001",
|
||||
"conversation-original-001",
|
||||
"Original text body with private context.",
|
||||
"<html><body>Original HTML body</body></html>",
|
||||
"https://media.example.test/original.pdf"
|
||||
));
|
||||
|
||||
mockMvc.perform(get("/api/source-messages/{id}/original", result.inboxId())
|
||||
.header("X-TH-Hotel-Source-Original-Read-Key", "test-original-read-key")
|
||||
.header("X-TH-Hotel-Actor", "operator-001")
|
||||
.header("X-TH-Hotel-Access-Scene", "reservation-detail"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.id").value(result.inboxId().toString()))
|
||||
.andExpect(jsonPath("$.textBody").value(containsString("Original text body")))
|
||||
.andExpect(jsonPath("$.htmlBody").value(containsString("Original HTML body")))
|
||||
.andExpect(jsonPath("$.htmlSanitizeRequired").value(true))
|
||||
.andExpect(jsonPath("$.attachments[0].fileName").value("private.pdf"))
|
||||
.andExpect(jsonPath("$.attachments[0].externalUrl")
|
||||
.value("https://media.example.test/original.pdf"));
|
||||
|
||||
Long auditCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM platform_source_message_original_access_audit
|
||||
WHERE inbox_id = ?
|
||||
AND actor_id = 'operator-001'
|
||||
AND access_scene = 'reservation-detail'
|
||||
""", Long.class, result.inboxId());
|
||||
assert auditCount != null;
|
||||
org.assertj.core.api.Assertions.assertThat(auditCount).isEqualTo(1L);
|
||||
}
|
||||
|
||||
private CaptureSourceMessageCommand command(
|
||||
String externalMessageId,
|
||||
String externalConversationId,
|
||||
String textBody,
|
||||
String htmlBody,
|
||||
String mediaUrl) {
|
||||
return new CaptureSourceMessageCommand(
|
||||
"HOTEL-TEST",
|
||||
"AGENTBUS",
|
||||
"EMAIL",
|
||||
externalMessageId,
|
||||
externalConversationId,
|
||||
"frame-" + externalMessageId,
|
||||
"session-api",
|
||||
Instant.parse("2026-07-06T10:00:00Z"),
|
||||
"guest@example.test",
|
||||
"Reservation update",
|
||||
textBody,
|
||||
htmlBody,
|
||||
"{\"source\":{\"external_message_id\":\"" + externalMessageId + "\"}}",
|
||||
"agentbus-outlook-v1",
|
||||
List.of(new CaptureSourceMessageMedia(
|
||||
"ATTACHMENT",
|
||||
"private.pdf",
|
||||
"application/pdf",
|
||||
1000L,
|
||||
mediaUrl,
|
||||
"attachment-api-001"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package cn.nianxx.thhotel.platform.message.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import cn.nianxx.thhotel.ThHotelApplication;
|
||||
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxDraft;
|
||||
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxSnapshot;
|
||||
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryResponse;
|
||||
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.request.SourceMessageQueryRequest;
|
||||
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult;
|
||||
import cn.nianxx.thhotel.platform.message.common.result.SourceMessagePageResult;
|
||||
import cn.nianxx.thhotel.platform.message.domain.SourceMessageInboxEntity;
|
||||
import cn.nianxx.thhotel.platform.message.domain.SourceMessagePayloadEntity;
|
||||
import cn.nianxx.thhotel.platform.message.mapper.SourceMessageInboxMapper;
|
||||
import cn.nianxx.thhotel.platform.message.mapper.SourceMessagePayloadMapper;
|
||||
import cn.nianxx.thhotel.platform.message.repository.SourceMessageInboxRepository;
|
||||
import cn.nianxx.thhotel.platform.message.service.impl.SourceMessageCaptureServiceImpl;
|
||||
import cn.nianxx.thhotel.platform.message.service.impl.SourceMessageSafetySanitizer;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
@SpringBootTest(classes = ThHotelApplication.class)
|
||||
@ActiveProfiles("test")
|
||||
class SourceMessageCaptureServiceImplTest {
|
||||
|
||||
@Autowired
|
||||
private SourceMessageCaptureService captureService;
|
||||
|
||||
@Autowired
|
||||
private SourceMessageQueryService queryService;
|
||||
|
||||
@Autowired
|
||||
private SourceMessageInboxMapper inboxMapper;
|
||||
|
||||
@Autowired
|
||||
private SourceMessagePayloadMapper payloadMapper;
|
||||
|
||||
@Test
|
||||
void shouldCaptureReceivedEmailAndExposeOnlySafeSummaryForQueries() {
|
||||
CaptureSourceMessageCommand command = new CaptureSourceMessageCommand(
|
||||
"HOTEL-TEST",
|
||||
"AGENTBUS",
|
||||
"EMAIL",
|
||||
"mail-m001-001",
|
||||
"conversation-m001",
|
||||
"frame-m001-001",
|
||||
"session-m001",
|
||||
Instant.parse("2026-07-06T09:00:00Z"),
|
||||
"guest@example.test",
|
||||
"Booking change request",
|
||||
"Please change the arrival date. Private phone 13800138000.",
|
||||
"<html><body>Please change <img src=\"https://media.example.test/inline.png?token=secret\"></body></html>",
|
||||
"{\"source\":{\"external_message_id\":\"mail-m001-001\"}}",
|
||||
"agentbus-outlook-v1",
|
||||
List.of(
|
||||
new CaptureSourceMessageMedia(
|
||||
"INLINE_IMAGE",
|
||||
"inline.png",
|
||||
"image/png",
|
||||
12345L,
|
||||
"https://media.example.test/inline.png?token=secret",
|
||||
"inline-media-001"),
|
||||
new CaptureSourceMessageMedia(
|
||||
"ATTACHMENT",
|
||||
"booking.pdf",
|
||||
"application/pdf",
|
||||
45678L,
|
||||
"https://media.example.test/booking.pdf?token=secret",
|
||||
"attachment-001")
|
||||
)
|
||||
);
|
||||
|
||||
SourceMessageCaptureResult result = captureService.capture(command);
|
||||
|
||||
assertThat(result.created()).isTrue();
|
||||
assertThat(result.captureStatus()).isEqualTo("RECEIVED");
|
||||
assertThat(result.inboxId()).isNotNull();
|
||||
|
||||
SourceMessagePageResult<SourceMessageSummaryResponse> page = queryService.query(
|
||||
new SourceMessageQueryRequest("HOTEL-TEST", null, "conversation-m001", null, 1, 20)
|
||||
);
|
||||
|
||||
assertThat(page.items()).hasSize(1);
|
||||
SourceMessageSummaryResponse summary = page.items().get(0);
|
||||
assertThat(summary.id()).isEqualTo(result.inboxId().toString());
|
||||
assertThat(summary.externalMessageId()).isEqualTo("mail-m001-001");
|
||||
assertThat(summary.externalConversationId()).isEqualTo("conversation-m001");
|
||||
assertThat(summary.captureStatus()).isEqualTo("RECEIVED");
|
||||
assertThat(summary.senderSummary()).isEqualTo("g***@example.test");
|
||||
assertThat(summary.subject()).isEqualTo("Booking change request");
|
||||
assertThat(summary.safeSnippet()).contains("Please change the arrival date");
|
||||
assertThat(summary.safeSnippet()).doesNotContain("13800138000");
|
||||
assertThat(summary.toString()).doesNotContain("html");
|
||||
assertThat(summary.toString()).doesNotContain("media.example.test");
|
||||
assertThat(summary.toString()).doesNotContain("payload");
|
||||
assertThat(summary.toString()).doesNotContain("token=secret");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldKeepIdempotentInboxAndOriginalPayloadWhenDuplicatePayloadChanges() {
|
||||
CaptureSourceMessageCommand firstCommand = new CaptureSourceMessageCommand(
|
||||
"HOTEL-TEST",
|
||||
"AGENTBUS",
|
||||
"EMAIL",
|
||||
"mail-duplicate-001",
|
||||
"conversation-duplicate",
|
||||
"frame-duplicate-001",
|
||||
"session-duplicate",
|
||||
Instant.parse("2026-07-06T09:10:00Z"),
|
||||
"guest@example.test",
|
||||
"Duplicate delivery",
|
||||
"Original body",
|
||||
"<html>Original body</html>",
|
||||
"{\"source\":{\"external_message_id\":\"mail-duplicate-001\"},\"version\":1}",
|
||||
"agentbus-outlook-v1",
|
||||
List.of()
|
||||
);
|
||||
CaptureSourceMessageCommand changedCommand = new CaptureSourceMessageCommand(
|
||||
"HOTEL-TEST",
|
||||
"AGENTBUS",
|
||||
"EMAIL",
|
||||
"mail-duplicate-001",
|
||||
"conversation-duplicate",
|
||||
"frame-duplicate-002",
|
||||
"session-duplicate",
|
||||
Instant.parse("2026-07-06T09:11:00Z"),
|
||||
"guest@example.test",
|
||||
"Duplicate delivery changed",
|
||||
"Changed body",
|
||||
"<html>Changed body</html>",
|
||||
"{\"source\":{\"external_message_id\":\"mail-duplicate-001\"},\"version\":2}",
|
||||
"agentbus-outlook-v1",
|
||||
List.of()
|
||||
);
|
||||
|
||||
SourceMessageCaptureResult firstResult = captureService.capture(firstCommand);
|
||||
SourceMessageCaptureResult duplicateResult = captureService.capture(changedCommand);
|
||||
|
||||
assertThat(duplicateResult.created()).isFalse();
|
||||
assertThat(duplicateResult.inboxId()).isEqualTo(firstResult.inboxId());
|
||||
assertThat(duplicateResult.duplicatePayloadChanged()).isTrue();
|
||||
|
||||
Long payloadCount = payloadMapper.selectCount(Wrappers.<SourceMessagePayloadEntity>lambdaQuery()
|
||||
.eq(SourceMessagePayloadEntity::getInboxId, firstResult.inboxId()));
|
||||
assertThat(payloadCount).isEqualTo(1L);
|
||||
|
||||
SourceMessagePayloadEntity payload = payloadMapper.selectOne(Wrappers.<SourceMessagePayloadEntity>lambdaQuery()
|
||||
.eq(SourceMessagePayloadEntity::getInboxId, firstResult.inboxId()));
|
||||
assertThat(payload.getPayloadJson()).contains("\"version\":1");
|
||||
assertThat(payload.getPayloadJson()).doesNotContain("\"version\":2");
|
||||
|
||||
SourceMessageInboxEntity inbox = inboxMapper.selectById(firstResult.inboxId());
|
||||
assertThat(inbox.getDuplicatePayloadChanged()).isTrue();
|
||||
assertThat(inbox.getSafeErrorSummary()).contains("重复投递 payload");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPersistFailedInboxWhenExternalMessageIdIsMissing() {
|
||||
CaptureSourceMessageCommand command = new CaptureSourceMessageCommand(
|
||||
"HOTEL-TEST",
|
||||
"AGENTBUS",
|
||||
"EMAIL",
|
||||
null,
|
||||
"conversation-invalid",
|
||||
"frame-invalid-001",
|
||||
"session-invalid",
|
||||
Instant.parse("2026-07-06T09:30:00Z"),
|
||||
"guest@example.test",
|
||||
"Invalid source payload",
|
||||
"This text contains private phone 13800138000 and must not appear in the error summary.",
|
||||
"<html><body>private html</body></html>",
|
||||
"{\"source\":{\"external_conversation_id\":\"conversation-invalid\"},\"token\":\"secret\"}",
|
||||
"agentbus-outlook-v1",
|
||||
List.of()
|
||||
);
|
||||
|
||||
SourceMessageCaptureResult result = captureService.capture(command);
|
||||
|
||||
assertThat(result.created()).isTrue();
|
||||
assertThat(result.captureStatus()).isEqualTo("FAILED");
|
||||
|
||||
SourceMessagePageResult<SourceMessageSummaryResponse> page = queryService.query(
|
||||
new SourceMessageQueryRequest("HOTEL-TEST", null, "conversation-invalid", "FAILED", 1, 20)
|
||||
);
|
||||
|
||||
assertThat(page.items()).hasSize(1);
|
||||
SourceMessageSummaryResponse summary = page.items().get(0);
|
||||
assertThat(summary.id()).isEqualTo(result.inboxId().toString());
|
||||
assertThat(summary.captureStatus()).isEqualTo("FAILED");
|
||||
assertThat(summary.externalMessageId()).isNull();
|
||||
assertThat(summary.safeSnippet()).contains("payload missing external message id");
|
||||
assertThat(summary.toString()).doesNotContain("13800138000");
|
||||
assertThat(summary.toString()).doesNotContain("private html");
|
||||
assertThat(summary.toString()).doesNotContain("token");
|
||||
assertThat(summary.toString()).doesNotContain("secret");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSanitizeSensitiveSubjectForSafeQueries() {
|
||||
CaptureSourceMessageCommand command = new CaptureSourceMessageCommand(
|
||||
"HOTEL-TEST",
|
||||
"AGENTBUS",
|
||||
"EMAIL",
|
||||
"mail-sensitive-subject-001",
|
||||
"conversation-sensitive-subject",
|
||||
"frame-sensitive-subject-001",
|
||||
"session-sensitive-subject",
|
||||
Instant.parse("2026-07-06T09:40:00Z"),
|
||||
"guest@example.test",
|
||||
"Urgent: guest@example.test phone 13800138000 https://media.example.test/file.pdf?token=secret",
|
||||
"Please check the booking.",
|
||||
"<html><body>Please check the booking.</body></html>",
|
||||
"{\"source\":{\"external_message_id\":\"mail-sensitive-subject-001\"}}",
|
||||
"agentbus-outlook-v1",
|
||||
List.of()
|
||||
);
|
||||
|
||||
SourceMessageCaptureResult result = captureService.capture(command);
|
||||
|
||||
SourceMessageSummaryResponse summary = queryService.getSummary(result.inboxId()).orElseThrow();
|
||||
assertThat(summary.subject()).doesNotContain("guest@example.test");
|
||||
assertThat(summary.subject()).doesNotContain("13800138000");
|
||||
assertThat(summary.subject()).doesNotContain("media.example.test");
|
||||
assertThat(summary.subject()).doesNotContain("token=secret");
|
||||
assertThat(summary.subject()).contains("[email]");
|
||||
assertThat(summary.subject()).contains("[number]");
|
||||
assertThat(summary.subject()).contains("[url]");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPersistFailedInboxWhenMediaUrlIsMissing() {
|
||||
CaptureSourceMessageCommand command = new CaptureSourceMessageCommand(
|
||||
"HOTEL-TEST",
|
||||
"AGENTBUS",
|
||||
"EMAIL",
|
||||
"mail-invalid-media-001",
|
||||
"conversation-invalid-media",
|
||||
"frame-invalid-media-001",
|
||||
"session-invalid-media",
|
||||
Instant.parse("2026-07-06T09:45:00Z"),
|
||||
"guest@example.test",
|
||||
"Invalid media source payload",
|
||||
"This text must not appear as an exception.",
|
||||
"<html><body>private html</body></html>",
|
||||
"{\"source\":{\"external_message_id\":\"mail-invalid-media-001\"},\"token\":\"secret\"}",
|
||||
"agentbus-outlook-v1",
|
||||
List.of(new CaptureSourceMessageMedia(
|
||||
"ATTACHMENT",
|
||||
"private.pdf",
|
||||
"application/pdf",
|
||||
123L,
|
||||
null,
|
||||
"attachment-invalid-media-001"))
|
||||
);
|
||||
|
||||
SourceMessageCaptureResult result = captureService.capture(command);
|
||||
|
||||
assertThat(result.created()).isTrue();
|
||||
assertThat(result.captureStatus()).isEqualTo("FAILED");
|
||||
|
||||
SourceMessageSummaryResponse summary = queryService.getSummary(result.inboxId()).orElseThrow();
|
||||
assertThat(summary.captureStatus()).isEqualTo("FAILED");
|
||||
assertThat(summary.externalMessageId()).isEqualTo("mail-invalid-media-001");
|
||||
assertThat(summary.safeSnippet()).contains("payload media item missing external url");
|
||||
assertThat(summary.toString()).doesNotContain("private html");
|
||||
assertThat(summary.toString()).doesNotContain("token");
|
||||
assertThat(summary.toString()).doesNotContain("secret");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnExistingInboxWhenConcurrentInsertHitsUniqueConstraint() {
|
||||
SourceMessageInboxRepository localRepository = mock(SourceMessageInboxRepository.class);
|
||||
SourceMessageCaptureService localService = new SourceMessageCaptureServiceImpl(
|
||||
localRepository,
|
||||
new SourceMessageSafetySanitizer()
|
||||
);
|
||||
SourceMessageInboxSnapshot existing = new SourceMessageInboxSnapshot(
|
||||
99001L,
|
||||
"HOTEL-TEST",
|
||||
"AGENTBUS",
|
||||
"EMAIL",
|
||||
"mail-race-001",
|
||||
"conversation-race",
|
||||
"different-payload-hash",
|
||||
"RECEIVED",
|
||||
false,
|
||||
LocalDateTime.parse("2026-07-06T09:50:00"),
|
||||
LocalDateTime.parse("2026-07-06T09:50:00"),
|
||||
"g***@example.test",
|
||||
"Race delivery",
|
||||
"Race body"
|
||||
);
|
||||
when(localRepository.findByIdempotencyKey("HOTEL-TEST", "AGENTBUS", "EMAIL", "mail-race-001"))
|
||||
.thenReturn(Optional.empty())
|
||||
.thenReturn(Optional.of(existing));
|
||||
when(localRepository.insert(any(SourceMessageInboxDraft.class)))
|
||||
.thenThrow(new DuplicateKeyException("duplicate source message"));
|
||||
CaptureSourceMessageCommand command = new CaptureSourceMessageCommand(
|
||||
"HOTEL-TEST",
|
||||
"AGENTBUS",
|
||||
"EMAIL",
|
||||
"mail-race-001",
|
||||
"conversation-race",
|
||||
"frame-race-001",
|
||||
"session-race",
|
||||
Instant.parse("2026-07-06T09:50:00Z"),
|
||||
"guest@example.test",
|
||||
"Race delivery",
|
||||
"Race body",
|
||||
"<html>Race body</html>",
|
||||
"{\"source\":{\"external_message_id\":\"mail-race-001\"},\"version\":2}",
|
||||
"agentbus-outlook-v1",
|
||||
List.of()
|
||||
);
|
||||
|
||||
SourceMessageCaptureResult result = localService.capture(command);
|
||||
|
||||
assertThat(result.created()).isFalse();
|
||||
assertThat(result.inboxId()).isEqualTo(99001L);
|
||||
assertThat(result.duplicatePayloadChanged()).isTrue();
|
||||
verify(localRepository).markDuplicatePayloadChanged(eq(99001L), any(String.class), any(LocalDateTime.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package cn.nianxx.thhotel.platform.system.control;
|
||||
|
||||
import static org.hamcrest.Matchers.not;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
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.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@SpringBootTest(classes = ThHotelApplication.class)
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
class AgentBusProbeStatusControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Test
|
||||
void shouldExposeAgentBusStatusWithoutSecretsOrRawFrames() throws Exception {
|
||||
mockMvc.perform(get("/api/system/agentbus-probe"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.enabled").value(false))
|
||||
.andExpect(jsonPath("$.connected").value(false))
|
||||
.andExpect(jsonPath("$.sessionReady").value(false))
|
||||
.andExpect(jsonPath("$.receivedFrameCount").value(0))
|
||||
.andExpect(content().string(not(org.hamcrest.Matchers.containsString("AGENTBUS_WS_TOKEN"))))
|
||||
.andExpect(content().string(not(org.hamcrest.Matchers.containsString("Authorization"))))
|
||||
.andExpect(content().string(not(org.hamcrest.Matchers.containsString("payload"))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package cn.nianxx.thhotel.platform.system.control;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
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.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@SpringBootTest(classes = ThHotelApplication.class)
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
class HealthControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Test
|
||||
void shouldReturnBackendHealthStatus() throws Exception {
|
||||
mockMvc.perform(get("/api/health"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("UP"))
|
||||
.andExpect(jsonPath("$.service").value("th-hotel-server"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user