实现 SourceMessage Inbox 与 AgentBus 入站闭环

This commit is contained in:
andy
2026-07-06 21:23:57 +08:00
parent 5a498635d8
commit 7e276469ef
75 changed files with 4608 additions and 27 deletions

View File

@@ -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;
}
}

View File

@@ -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");
}
}

View File

@@ -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"))
);
}
}

View File

@@ -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));
}
}

View File

@@ -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"))));
}
}

View File

@@ -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"));
}
}