实现酒店上下文单酒店收口
This commit is contained in:
@@ -124,10 +124,14 @@ class SuperAgentMcpControllerTest {
|
||||
.andExpect(jsonPath("$.id").value("mcp-tools-001"))
|
||||
.andExpect(jsonPath("$.result.tools.length()").value(5))
|
||||
.andExpect(jsonPath("$.result.tools[0].name").value("th_hotel_query_case_context"))
|
||||
.andExpect(jsonPath("$.result.tools[0].inputSchema.required.length()").value(0))
|
||||
.andExpect(jsonPath("$.result.tools[0].annotations.readOnlyHint").value(true))
|
||||
.andExpect(jsonPath("$.result.tools[3].name").value("th_hotel_list_message_conversation_messages"))
|
||||
.andExpect(jsonPath("$.result.tools[3].inputSchema.required.length()").value(0))
|
||||
.andExpect(jsonPath("$.result.tools[3].annotations.readOnlyHint").value(true))
|
||||
.andExpect(jsonPath("$.result.tools[4].name").value("th_hotel_submit_task_results"))
|
||||
.andExpect(jsonPath("$.result.tools[4].inputSchema.required[0]").value("source_message_id"))
|
||||
.andExpect(jsonPath("$.result.tools[4].inputSchema.required[1]").value("ai_task_results"))
|
||||
.andExpect(jsonPath("$.result.tools[4].annotations.readOnlyHint").value(false))
|
||||
.andExpect(jsonPath("$.result.tools[4].annotations.destructiveHint").value(true));
|
||||
}
|
||||
@@ -142,7 +146,6 @@ class SuperAgentMcpControllerTest {
|
||||
"params": {
|
||||
"name": "th_hotel_query_case_context",
|
||||
"arguments": {
|
||||
"hotel_id": "HOTEL-TEST",
|
||||
"group_code": "GRP-MCP-NOT-FOUND"
|
||||
}
|
||||
}
|
||||
@@ -173,7 +176,6 @@ class SuperAgentMcpControllerTest {
|
||||
"params": {
|
||||
"name": "th_hotel_submit_task_results",
|
||||
"arguments": {
|
||||
"hotel_id": "HOTEL-TEST",
|
||||
"source_message_id": "mail-mcp-disabled-001",
|
||||
"ai_task_results": []
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ class SuperAgentMcpSubmitEnabledControllerTest {
|
||||
"params": {
|
||||
"name": "th_hotel_submit_task_results",
|
||||
"arguments": {
|
||||
"hotel_id": "HOTEL-TEST",
|
||||
"source_message_id": "mail-mcp-enabled-missing-001",
|
||||
"ai_task_results": [
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 cn.nianxx.thhotel.platform.hotel.service.HotelContextService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
@@ -64,7 +65,7 @@ class AgentBusFrameProcessorTest {
|
||||
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().hotelId()).isEqualTo("HOTEL-SYSTEM");
|
||||
assertThat(captor.getValue().externalMessageId()).isEqualTo("mail-agentbus-capture-001");
|
||||
assertThat(captor.getValue().providerFrameId()).isEqualTo("frame-agentbus-capture-001");
|
||||
}
|
||||
@@ -107,18 +108,20 @@ class AgentBusFrameProcessorTest {
|
||||
SourceMessageCaptureService captureService,
|
||||
AgentBusConnectionStatus status,
|
||||
AgentBusProperties properties) {
|
||||
HotelContextService hotelContextService = mock(HotelContextService.class);
|
||||
when(hotelContextService.resolveSystemHotelId()).thenReturn("HOTEL-SYSTEM");
|
||||
return new AgentBusFrameProcessor(
|
||||
objectMapper,
|
||||
new AgentBusSourceMessageAdapter(objectMapper),
|
||||
captureService,
|
||||
status,
|
||||
properties);
|
||||
properties,
|
||||
hotelContextService);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -192,6 +192,29 @@ class DebugEmlSuperAgentControllerTest {
|
||||
org.assertj.core.api.Assertions.assertThat(sourceCount).isEqualTo(2L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUseSystemHotelWhenDebugUploadOmitsHotelId() throws Exception {
|
||||
mockStorageAndSuperAgentSuccess();
|
||||
|
||||
mockMvc.perform(multipart(ENDPOINT)
|
||||
.file(emlFile())
|
||||
.param("run_label", "system-hotel-debug-upload")
|
||||
.header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.source_provider").value("DEBUG_EML_UPLOAD"))
|
||||
.andExpect(jsonPath("$.external_message_id", containsString("debug-eml-run-")));
|
||||
|
||||
Long sourceCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM platform_source_message_inbox inbox
|
||||
JOIN platform_source_message_payload payload ON payload.inbox_id = inbox.id
|
||||
WHERE inbox.hotel_id = 'HOTEL-TEST'
|
||||
AND inbox.provider = 'DEBUG_EML_UPLOAD'
|
||||
AND payload.payload_json LIKE '%system-hotel-debug-upload%'
|
||||
""", Long.class);
|
||||
org.assertj.core.api.Assertions.assertThat(sourceCount).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSanitizeDebugHtmlAndReplaceUpperCaseCidReferences() throws Exception {
|
||||
mockStorageAndSuperAgentSuccess();
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package cn.nianxx.thhotel.platform.hotel.repository;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import cn.nianxx.thhotel.ThHotelApplication;
|
||||
import cn.nianxx.thhotel.platform.hotel.common.enums.PlatformHotelStatus;
|
||||
import cn.nianxx.thhotel.platform.hotel.domain.PlatformHotelEntity;
|
||||
import java.time.LocalDateTime;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
@SpringBootTest(
|
||||
classes = ThHotelApplication.class,
|
||||
properties = {
|
||||
"spring.datasource.url=jdbc:h2:mem:m005_single_active_hotel;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE",
|
||||
"auth.bootstrap.admin.username=",
|
||||
"auth.bootstrap.admin.password=",
|
||||
"auth.bootstrap.default-hotel-id="
|
||||
})
|
||||
@ActiveProfiles("test")
|
||||
class PlatformHotelSingleActiveConstraintTest {
|
||||
|
||||
@Autowired
|
||||
private PlatformHotelRepository hotelRepository;
|
||||
|
||||
@Test
|
||||
void shouldRejectSecondActiveHotelAtDatabaseLevel() {
|
||||
hotelRepository.insertHotel(hotel("M005-ACTIVE-A", PlatformHotelStatus.ACTIVE));
|
||||
|
||||
assertThatThrownBy(() -> hotelRepository.insertHotel(hotel("M005-ACTIVE-B", PlatformHotelStatus.ACTIVE)))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
}
|
||||
|
||||
private PlatformHotelEntity hotel(String hotelId, PlatformHotelStatus status) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
PlatformHotelEntity hotel = new PlatformHotelEntity();
|
||||
hotel.setHotelId(hotelId);
|
||||
hotel.setHotelName(hotelId);
|
||||
hotel.setHotelStatus(status.name());
|
||||
hotel.setTimeZone("Asia/Bangkok");
|
||||
hotel.setSortOrder(10);
|
||||
hotel.setCreatedAt(now);
|
||||
hotel.setUpdatedAt(now);
|
||||
return hotel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package cn.nianxx.thhotel.platform.hotel.service.impl;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import cn.nianxx.thhotel.platform.hotel.common.enums.PlatformHotelStatus;
|
||||
import cn.nianxx.thhotel.platform.hotel.domain.PlatformHotelEntity;
|
||||
import cn.nianxx.thhotel.platform.hotel.repository.PlatformHotelRepository;
|
||||
import cn.nianxx.thhotel.platform.hotel.service.HotelContextException;
|
||||
import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext;
|
||||
import cn.nianxx.thhotel.platform.security.service.CurrentUserContextService;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class HotelContextServiceImplTest {
|
||||
|
||||
@Mock
|
||||
private PlatformHotelRepository hotelRepository;
|
||||
@Mock
|
||||
private CurrentUserContextService currentUserContextService;
|
||||
|
||||
@Test
|
||||
void shouldResolveOnlyActiveHotelAsSystemHotel() {
|
||||
HotelContextServiceImpl service = service();
|
||||
when(hotelRepository.listActiveHotels()).thenReturn(List.of(activeHotel("HOTEL-ONLY")));
|
||||
|
||||
assertThat(service.resolveSystemHotelId()).isEqualTo("HOTEL-ONLY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectMissingActiveSystemHotel() {
|
||||
HotelContextServiceImpl service = service();
|
||||
when(hotelRepository.listActiveHotels()).thenReturn(List.of());
|
||||
|
||||
assertThatThrownBy(service::resolveSystemHotelId)
|
||||
.isInstanceOf(HotelContextException.class)
|
||||
.extracting("status", "errorCode")
|
||||
.containsExactly(HttpStatus.CONFLICT, "SYSTEM_HOTEL_NOT_CONFIGURED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectAmbiguousActiveSystemHotel() {
|
||||
HotelContextServiceImpl service = service();
|
||||
when(hotelRepository.listActiveHotels()).thenReturn(List.of(
|
||||
activeHotel("HOTEL-A"),
|
||||
activeHotel("HOTEL-B")));
|
||||
|
||||
assertThatThrownBy(service::resolveSystemHotelId)
|
||||
.isInstanceOf(HotelContextException.class)
|
||||
.extracting("status", "errorCode")
|
||||
.containsExactly(HttpStatus.CONFLICT, "SYSTEM_HOTEL_AMBIGUOUS");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUseCurrentUserDefaultHotelWhenRequestHotelMissing() {
|
||||
HotelContextServiceImpl service = service();
|
||||
when(currentUserContextService.currentUser()).thenReturn(Optional.of(userContext(
|
||||
"HOTEL-USER-DEFAULT",
|
||||
List.of("HOTEL-USER-DEFAULT", "HOTEL-OTHER"))));
|
||||
|
||||
assertThat(service.resolveCurrentHotelId(null)).isEqualTo("HOTEL-USER-DEFAULT");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectRequestedHotelOutsideCurrentUserAccessList() {
|
||||
HotelContextServiceImpl service = service();
|
||||
when(currentUserContextService.currentUser()).thenReturn(Optional.of(userContext(
|
||||
"HOTEL-USER-DEFAULT",
|
||||
List.of("HOTEL-USER-DEFAULT"))));
|
||||
|
||||
assertThatThrownBy(() -> service.resolveCurrentHotelId("HOTEL-NOT-ALLOWED"))
|
||||
.isInstanceOf(HotelContextException.class)
|
||||
.extracting("status", "errorCode")
|
||||
.containsExactly(HttpStatus.FORBIDDEN, "HOTEL_ACCESS_DENIED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFallbackToSystemHotelWhenNoCurrentUser() {
|
||||
HotelContextServiceImpl service = service();
|
||||
when(currentUserContextService.currentUser()).thenReturn(Optional.empty());
|
||||
when(hotelRepository.listActiveHotels()).thenReturn(List.of(activeHotel("HOTEL-SYSTEM")));
|
||||
|
||||
assertThat(service.resolveCurrentHotelId(null)).isEqualTo("HOTEL-SYSTEM");
|
||||
}
|
||||
|
||||
private HotelContextServiceImpl service() {
|
||||
return new HotelContextServiceImpl(hotelRepository, currentUserContextService);
|
||||
}
|
||||
|
||||
private PlatformHotelEntity activeHotel(String hotelId) {
|
||||
PlatformHotelEntity hotel = new PlatformHotelEntity();
|
||||
hotel.setHotelId(hotelId);
|
||||
hotel.setHotelName(hotelId);
|
||||
hotel.setHotelStatus(PlatformHotelStatus.ACTIVE.name());
|
||||
hotel.setTimeZone("Asia/Bangkok");
|
||||
hotel.setSortOrder(10);
|
||||
hotel.setCreatedAt(LocalDateTime.now());
|
||||
hotel.setUpdatedAt(LocalDateTime.now());
|
||||
return hotel;
|
||||
}
|
||||
|
||||
private AuthenticatedUserContext userContext(String defaultHotelId, List<String> accessibleHotelIds) {
|
||||
return new AuthenticatedUserContext(
|
||||
1L,
|
||||
"tester",
|
||||
"测试用户",
|
||||
false,
|
||||
defaultHotelId,
|
||||
accessibleHotelIds,
|
||||
List.of());
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,6 @@ class ReservationAiQueryControllerTest {
|
||||
|
||||
String body = """
|
||||
{
|
||||
"hotel_id": "HOTEL-TEST",
|
||||
"source_message_id": "%s",
|
||||
"source_event_index": 1,
|
||||
"group_code": "GRP-AIQUERY-NOT-FOUND"
|
||||
|
||||
@@ -172,16 +172,41 @@ class SuperAgentTaskResultControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectExternalSourceMessageIdWhenHotelIdMissing() throws Exception {
|
||||
String body = minimalBody("mail-external-without-hotel-001", "New Booking", "normal_task", "new_fit_reservation", """
|
||||
void shouldResolveExternalSourceMessageIdWithoutHotelIdFromSystemHotel() throws Exception {
|
||||
String externalMessageId = "mail-external-without-hotel-001";
|
||||
SourceMessageCaptureResult source = captureSourceMessage(externalMessageId);
|
||||
String body = minimalBody(externalMessageId, "New Booking", "normal_task", "new_fit_reservation", """
|
||||
"case_keys": {},
|
||||
"extracted_fields": {}
|
||||
""");
|
||||
|
||||
mockMvc.perform(signedPost(body, "nonce-external-source-without-hotel-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.source_message_id").value(externalMessageId))
|
||||
.andExpect(jsonPath("$.accepted_count").value(1))
|
||||
.andExpect(jsonPath("$.items[0].task_status").value("PENDING_CONFIRM"));
|
||||
|
||||
Long transitionCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_reservation_ai_transition
|
||||
WHERE source_message_id = ?
|
||||
""", Long.class, source.inboxId());
|
||||
assertThat(transitionCount).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectLegacyInternalSourceMessageIdWhenHotelDoesNotMatchSystemHotel() throws Exception {
|
||||
SourceMessageCaptureResult source = captureSourceMessage(
|
||||
"mail-legacy-source-cross-hotel-001",
|
||||
"HOTEL-OTHER");
|
||||
String body = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """
|
||||
"case_keys": {},
|
||||
"extracted_fields": {}
|
||||
""");
|
||||
|
||||
mockMvc.perform(signedPost(body, "nonce-legacy-source-cross-hotel-001"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("HOTEL_ID_REQUIRED"))
|
||||
.andExpect(jsonPath("$.message").value("使用外部 source_message_id 时 hotel_id 不能为空。"));
|
||||
.andExpect(jsonPath("$.error_code").value("HOTEL_ID_MISMATCH"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1259,8 +1284,12 @@ class SuperAgentTaskResultControllerTest {
|
||||
}
|
||||
|
||||
private SourceMessageCaptureResult captureSourceMessage(String externalMessageId) {
|
||||
return captureSourceMessage(externalMessageId, "HOTEL-TEST");
|
||||
}
|
||||
|
||||
private SourceMessageCaptureResult captureSourceMessage(String externalMessageId, String hotelId) {
|
||||
return captureService.capture(new CaptureSourceMessageCommand(
|
||||
"HOTEL-TEST",
|
||||
hotelId,
|
||||
"AGENTBUS",
|
||||
"EMAIL",
|
||||
externalMessageId,
|
||||
|
||||
@@ -14,6 +14,12 @@ springdoc:
|
||||
swagger-ui:
|
||||
enabled: false
|
||||
|
||||
auth:
|
||||
bootstrap:
|
||||
default-hotel-id: HOTEL-TEST
|
||||
default-hotel-name: 测试酒店
|
||||
default-hotel-time-zone: Asia/Bangkok
|
||||
|
||||
---
|
||||
spring:
|
||||
config:
|
||||
|
||||
Reference in New Issue
Block a user