|
|
@@ -0,0 +1,261 @@
|
|
|
+package com.zksy.web.controller.cockpit;
|
|
|
+
|
|
|
+import com.zksy.cockpit.foundation.CockpitReadException;
|
|
|
+import com.zksy.cockpit.point.CockpitMonitoringPointDetail;
|
|
|
+import com.zksy.cockpit.point.CockpitMonitoringPointDetailAdapter;
|
|
|
+import com.zksy.cockpit.point.CockpitMonitoringPointDetailService;
|
|
|
+import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
|
|
|
+import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.springframework.test.web.servlet.MockMvc;
|
|
|
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
|
|
+
|
|
|
+import java.lang.reflect.Method;
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.time.Clock;
|
|
|
+import java.time.Instant;
|
|
|
+import java.time.ZoneOffset;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+import static org.hamcrest.Matchers.hasSize;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+import static org.mockito.Mockito.mock;
|
|
|
+import static org.mockito.Mockito.verifyNoInteractions;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
|
|
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
|
|
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
|
|
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
|
|
+
|
|
|
+class CockpitMonitoringPointDetailControllerTest {
|
|
|
+
|
|
|
+ private CockpitMonitoringPointDetailAdapter adapter;
|
|
|
+ private MockMvc mockMvc;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ adapter = mock(CockpitMonitoringPointDetailAdapter.class);
|
|
|
+ CockpitMonitoringPointDetailService service = new CockpitMonitoringPointDetailService(
|
|
|
+ adapter,
|
|
|
+ Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
|
|
|
+ CockpitMonitoringPointDetailController controller = new CockpitMonitoringPointDetailController(service);
|
|
|
+ mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void detailReturnsReadOnlyContractWithResolvedStatusReasonsStatisticsAndCards() throws Exception {
|
|
|
+ when(adapter.load("MP-001")).thenReturn(detail("MP-001", Arrays.asList(
|
|
|
+ device("EQ-003", true, false, false, true, Collections.singletonList("浓度超限")),
|
|
|
+ device("EQ-001", true, false, false, true, Collections.singletonList("压力超高")),
|
|
|
+ device("EQ-005", false, true, false, true, Collections.singletonList("流量波动")),
|
|
|
+ device("EQ-002", false, false, false, true, Collections.emptyList()),
|
|
|
+ device("EQ-004", false, false, false, false, Collections.emptyList()))));
|
|
|
+
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/monitoring-points/MP-001")
|
|
|
+ .header("X-Request-Id", "point-detail-request-001"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(200))
|
|
|
+ .andExpect(jsonPath("$.msg").value("操作成功"))
|
|
|
+ .andExpect(jsonPath("$.data.requestId").value("point-detail-request-001"))
|
|
|
+ .andExpect(jsonPath("$.data.apiVersion").value("v1"))
|
|
|
+ .andExpect(jsonPath("$.data.readOnly").value(true))
|
|
|
+ .andExpect(jsonPath("$.data.monitoringPointCode").value("MP-001"))
|
|
|
+ .andExpect(jsonPath("$.data.monitoringPointName").value("供水干管监测点"))
|
|
|
+ .andExpect(jsonPath("$.data.pointStatus").value("ALARM"))
|
|
|
+ .andExpect(jsonPath("$.data.statusLabel").value("报警"))
|
|
|
+ .andExpect(jsonPath("$.data.statusReasons[0].status").value("ALARM"))
|
|
|
+ .andExpect(jsonPath("$.data.statusReasons[0].deviceCode").value("EQ-003"))
|
|
|
+ .andExpect(jsonPath("$.data.statusReasons[0].reason").value("浓度超限"))
|
|
|
+ .andExpect(jsonPath("$.data.statusReasons[1].deviceCode").value("EQ-001"))
|
|
|
+ .andExpect(jsonPath("$.data.statusReasons[1].reason").value("压力超高"))
|
|
|
+ .andExpect(jsonPath("$.data.deviceStatistics.totalDeviceCount").value(5))
|
|
|
+ .andExpect(jsonPath("$.data.deviceStatistics.alarmDeviceCount").value(2))
|
|
|
+ .andExpect(jsonPath("$.data.deviceStatistics.warningDeviceCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.deviceStatistics.workOrderDeviceCount").value(0))
|
|
|
+ .andExpect(jsonPath("$.data.deviceStatistics.normalDeviceCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.deviceStatistics.offlineDeviceCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.devices", hasSize(5)))
|
|
|
+ .andExpect(jsonPath("$.data.devices[0].deviceCode").value("EQ-001"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[0].deviceStatus").value("ALARM"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[0].statusLabel").value("报警"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[1].deviceCode").value("EQ-003"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[2].deviceCode").value("EQ-005"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[3].deviceCode").value("EQ-002"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[4].deviceCode").value("EQ-004"))
|
|
|
+ .andExpect(jsonPath("$.data.coordinateValidation.state").value("VALID"))
|
|
|
+ .andExpect(jsonPath("$.data.coordinateValidation.renderable").value(true))
|
|
|
+ .andExpect(jsonPath("$.data.statusDictionary", hasSize(6)))
|
|
|
+ .andExpect(jsonPath("$.data.statusDictionary[0].status").value("ALARM"))
|
|
|
+ .andExpect(jsonPath("$.data.statusDictionary[0].label").value("报警"))
|
|
|
+ .andExpect(jsonPath("$.data.statusDictionary[0].priority").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.statusDictionary[2].status").value("WORK_ORDER"))
|
|
|
+ .andExpect(jsonPath("$.data.statusDictionary[2].label").value("工单处置"))
|
|
|
+ .andExpect(jsonPath("$.data.statusDictionary[2].priority").value(3))
|
|
|
+ .andExpect(jsonPath("$.data.statusDictionary[5].status").value("EMPTY"))
|
|
|
+ .andExpect(jsonPath("$.data.statusDictionary[5].label").value("无设备"))
|
|
|
+ .andExpect(jsonPath("$.data.statusDictionary[5].priority").value(6));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void pointStatusUsesFixedPrecedenceAcrossAllSixStates() throws Exception {
|
|
|
+ for (CockpitMonitoringPointStatus status : CockpitMonitoringPointStatus.values()) {
|
|
|
+ String pointCode = "MP-" + status.name();
|
|
|
+ when(adapter.load(pointCode)).thenReturn(detail(pointCode, statusFixture(status)));
|
|
|
+
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/monitoring-points/" + pointCode))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.data.pointStatus").value(status.name()));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void cardsUseStatusPriorityThenDeviceCodeForStableOrdering() throws Exception {
|
|
|
+ when(adapter.load("MP-ORDER")).thenReturn(detail("MP-ORDER", Arrays.asList(
|
|
|
+ device("EQ-003", true, false, false, true, Collections.emptyList()),
|
|
|
+ device("EQ-001", true, false, false, true, Collections.emptyList()),
|
|
|
+ device("EQ-005", false, true, false, true, Collections.emptyList()),
|
|
|
+ device("EQ-006", false, false, true, true, Collections.emptyList()),
|
|
|
+ device("EQ-002", false, false, false, true, Collections.emptyList()),
|
|
|
+ device("EQ-004", false, false, false, false, Collections.emptyList()))));
|
|
|
+
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/monitoring-points/MP-ORDER"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.data.devices[0].deviceCode").value("EQ-001"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[1].deviceCode").value("EQ-003"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[2].deviceCode").value("EQ-005"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[3].deviceCode").value("EQ-006"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[4].deviceCode").value("EQ-002"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[5].deviceCode").value("EQ-004"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void missingPointReturnsTypedNotFound() throws Exception {
|
|
|
+ when(adapter.load("MP-404")).thenReturn(null);
|
|
|
+
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/monitoring-points/MP-404")
|
|
|
+ .header("X-Request-Id", "point-detail-request-404"))
|
|
|
+ .andExpect(status().isNotFound())
|
|
|
+ .andExpect(jsonPath("$.code").value(404))
|
|
|
+ .andExpect(jsonPath("$.data.category").value("NOT_FOUND"))
|
|
|
+ .andExpect(jsonPath("$.data.message").value("监测点不存在"))
|
|
|
+ .andExpect(jsonPath("$.data.requestId").value("point-detail-request-404"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void blankCodeReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/monitoring-points/%20"))
|
|
|
+ .andExpect(status().isBadRequest())
|
|
|
+ .andExpect(jsonPath("$.code").value(400))
|
|
|
+ .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
|
|
|
+ .andExpect(jsonPath("$.data.message").value("监测点编码不能为空"));
|
|
|
+
|
|
|
+ verifyNoInteractions(adapter);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void malformedPathEncodingReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/monitoring-points/%ZZ"))
|
|
|
+ .andExpect(status().isBadRequest())
|
|
|
+ .andExpect(jsonPath("$.code").value(400))
|
|
|
+ .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
|
|
|
+ .andExpect(jsonPath("$.data.message").value("监测点编码不合法"));
|
|
|
+
|
|
|
+ verifyNoInteractions(adapter);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void serverFailureReturnsTypedServerError() throws Exception {
|
|
|
+ when(adapter.load("MP-500")).thenThrow(new CockpitReadException("upstream unavailable"));
|
|
|
+
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/monitoring-points/MP-500"))
|
|
|
+ .andExpect(status().isInternalServerError())
|
|
|
+ .andExpect(jsonPath("$.code").value(500))
|
|
|
+ .andExpect(jsonPath("$.data.category").value("SERVER_ERROR"))
|
|
|
+ .andExpect(jsonPath("$.data.message").value("驾驶舱监测点详情数据暂不可用"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void mutatingRequestIsRejectedBeforeReadAdapterIsCalled() throws Exception {
|
|
|
+ mockMvc.perform(post("/api/cockpit/v1/monitoring-points/MP-001"))
|
|
|
+ .andExpect(status().isMethodNotAllowed());
|
|
|
+
|
|
|
+ verifyNoInteractions(adapter);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void controllerOnlyExposesAuthenticatedReadOnlyOperations() {
|
|
|
+ assertTrue(Arrays.stream(CockpitMonitoringPointDetailController.class.getDeclaredMethods())
|
|
|
+ .filter(this::hasRequestMappingAnnotation)
|
|
|
+ .allMatch(CockpitMonitoringPointDetailControllerTest::isReadOnlyMappedMethod));
|
|
|
+
|
|
|
+ assertTrue(Arrays.stream(CockpitMonitoringPointDetailController.class.getDeclaredMethods())
|
|
|
+ .anyMatch(method -> method.isAnnotationPresent(org.springframework.security.access.prepost.PreAuthorize.class)
|
|
|
+ && method.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class)
|
|
|
+ .value()
|
|
|
+ .equals("isAuthenticated()")));
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean hasRequestMappingAnnotation(Method method) {
|
|
|
+ return method.isAnnotationPresent(org.springframework.web.bind.annotation.RequestMapping.class)
|
|
|
+ || method.isAnnotationPresent(org.springframework.web.bind.annotation.GetMapping.class)
|
|
|
+ || method.isAnnotationPresent(org.springframework.web.bind.annotation.PostMapping.class)
|
|
|
+ || method.isAnnotationPresent(org.springframework.web.bind.annotation.PutMapping.class)
|
|
|
+ || method.isAnnotationPresent(org.springframework.web.bind.annotation.PatchMapping.class)
|
|
|
+ || method.isAnnotationPresent(org.springframework.web.bind.annotation.DeleteMapping.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static boolean isReadOnlyMappedMethod(Method method) {
|
|
|
+ return method.isAnnotationPresent(org.springframework.web.bind.annotation.GetMapping.class)
|
|
|
+ && !method.isAnnotationPresent(org.springframework.web.bind.annotation.PostMapping.class)
|
|
|
+ && !method.isAnnotationPresent(org.springframework.web.bind.annotation.PutMapping.class)
|
|
|
+ && !method.isAnnotationPresent(org.springframework.web.bind.annotation.PatchMapping.class)
|
|
|
+ && !method.isAnnotationPresent(org.springframework.web.bind.annotation.DeleteMapping.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static List<CockpitMonitoringPointDeviceSnapshot> statusFixture(CockpitMonitoringPointStatus status) {
|
|
|
+ switch (status) {
|
|
|
+ case ALARM:
|
|
|
+ return Collections.singletonList(device("EQ-ALARM", true, false, false, true, Collections.emptyList()));
|
|
|
+ case WARNING:
|
|
|
+ return Collections.singletonList(device("EQ-WARNING", false, true, false, true, Collections.emptyList()));
|
|
|
+ case WORK_ORDER:
|
|
|
+ return Collections.singletonList(device("EQ-WORK", false, false, true, true, Collections.emptyList()));
|
|
|
+ case NORMAL:
|
|
|
+ return Collections.singletonList(device("EQ-NORMAL", false, false, false, true, Collections.emptyList()));
|
|
|
+ case OFFLINE:
|
|
|
+ return Collections.singletonList(device("EQ-OFFLINE", false, false, false, false, Collections.emptyList()));
|
|
|
+ case EMPTY:
|
|
|
+ return Collections.emptyList();
|
|
|
+ default:
|
|
|
+ throw new AssertionError("unknown status: " + status);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static CockpitMonitoringPointDetail detail(String pointCode,
|
|
|
+ List<CockpitMonitoringPointDeviceSnapshot> devices) {
|
|
|
+ return new CockpitMonitoringPointDetail(
|
|
|
+ pointCode,
|
|
|
+ "供水干管监测点",
|
|
|
+ new BigDecimal("110.141"),
|
|
|
+ new BigDecimal("28.452"),
|
|
|
+ true,
|
|
|
+ devices);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static CockpitMonitoringPointDeviceSnapshot device(String deviceCode, boolean alarm, boolean warning,
|
|
|
+ boolean workOrderInProgress, boolean online, List<String> statusReasons) {
|
|
|
+ return new CockpitMonitoringPointDeviceSnapshot(
|
|
|
+ deviceCode,
|
|
|
+ deviceCode + "设备",
|
|
|
+ "压力计",
|
|
|
+ online,
|
|
|
+ alarm,
|
|
|
+ warning,
|
|
|
+ workOrderInProgress,
|
|
|
+ statusReasons,
|
|
|
+ "2026-09-02T09:59:00+08:00");
|
|
|
+ }
|
|
|
+}
|