|
|
@@ -0,0 +1,293 @@
|
|
|
+package com.zksy.web.controller.cockpit;
|
|
|
+
|
|
|
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
|
|
|
+import com.zksy.cockpit.foundation.CockpitReadException;
|
|
|
+import com.zksy.cockpit.foundation.CockpitSnapshotExpiredException;
|
|
|
+import com.zksy.cockpit.map.CockpitMapSnapshot;
|
|
|
+import com.zksy.cockpit.map.CockpitMapSnapshotAdapter;
|
|
|
+import com.zksy.cockpit.map.CockpitMapSnapshotService;
|
|
|
+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 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 CockpitMapSnapshotControllerTest {
|
|
|
+
|
|
|
+ private static final String SNAPSHOT_AT = "2026-09-02T10:00:00+08:00";
|
|
|
+
|
|
|
+ private CockpitMapSnapshotAdapter adapter;
|
|
|
+ private MockMvc mockMvc;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ adapter = mock(CockpitMapSnapshotAdapter.class);
|
|
|
+ CockpitMapSnapshotService service = new CockpitMapSnapshotService(
|
|
|
+ adapter,
|
|
|
+ Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
|
|
|
+ CockpitMapSnapshotController controller = new CockpitMapSnapshotController(service);
|
|
|
+ mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void firstPageUsesDefaultPaginationAndReturnsReadOnlyCamelCaseSnapshotContract() throws Exception {
|
|
|
+ when(adapter.load(null)).thenReturn(fixture());
|
|
|
+
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/map-snapshot")
|
|
|
+ .header("X-Request-Id", "map-snapshot-request-001"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(200))
|
|
|
+ .andExpect(jsonPath("$.msg").value("操作成功"))
|
|
|
+ .andExpect(jsonPath("$.data.requestId").value("map-snapshot-request-001"))
|
|
|
+ .andExpect(jsonPath("$.data.apiVersion").value("v1"))
|
|
|
+ .andExpect(jsonPath("$.data.readOnly").value(true))
|
|
|
+ .andExpect(jsonPath("$.data.page").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.pageSize").value(500))
|
|
|
+ .andExpect(jsonPath("$.data.snapshotAt").value(SNAPSHOT_AT))
|
|
|
+ .andExpect(jsonPath("$.data.batchUpdatedAt").value(SNAPSHOT_AT))
|
|
|
+ .andExpect(jsonPath("$.data.total").value(7))
|
|
|
+ .andExpect(jsonPath("$.data.complete").value(true))
|
|
|
+ .andExpect(jsonPath("$.data.monitoringPoints", hasSize(3)))
|
|
|
+ .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(4)))
|
|
|
+ .andExpect(jsonPath("$.data.monitoringPoints[0].pointCode").value("MP-001"))
|
|
|
+ .andExpect(jsonPath("$.data.monitoringPoints[0].pointStatus").value("NORMAL"))
|
|
|
+ .andExpect(jsonPath("$.data.monitoringPoints[0].coordinateValidation.renderable").value(true))
|
|
|
+ .andExpect(jsonPath("$.data.monitoringPoints[0].coordinateValidation.state").value("VALID"))
|
|
|
+ .andExpect(jsonPath("$.data.unassociatedEquipment[0].deviceCode").value("EQ-FREE-001"))
|
|
|
+ .andExpect(jsonPath("$.data.unassociatedEquipment[0].coordinateValidation.renderable").value(false))
|
|
|
+ .andExpect(jsonPath("$.data.unassociatedEquipment[0].coordinateValidation.state").value("INVALID"))
|
|
|
+ .andExpect(jsonPath("$.data.unassociatedEquipment[0].coordinateValidation.reason").value("LONGITUDE_OUT_OF_RANGE"))
|
|
|
+ .andExpect(jsonPath("$.data.coordinateValidation.crs").value("WGS84"))
|
|
|
+ .andExpect(jsonPath("$.data.coordinateValidation.totalObjectCount").value(7))
|
|
|
+ .andExpect(jsonPath("$.data.coordinateValidation.renderableCount").value(3))
|
|
|
+ .andExpect(jsonPath("$.data.coordinateValidation.invalidCount").value(2))
|
|
|
+ .andExpect(jsonPath("$.data.coordinateValidation.unverifiedCount").value(2));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void pagesShareOneSnapshotAndDoNotDuplicateOrOmitObjects() throws Exception {
|
|
|
+ when(adapter.load(SNAPSHOT_AT)).thenReturn(fixture());
|
|
|
+
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/map-snapshot")
|
|
|
+ .param("page", "1")
|
|
|
+ .param("pageSize", "2")
|
|
|
+ .param("snapshotAt", SNAPSHOT_AT))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.data.snapshotAt").value(SNAPSHOT_AT))
|
|
|
+ .andExpect(jsonPath("$.data.total").value(7))
|
|
|
+ .andExpect(jsonPath("$.data.complete").value(false))
|
|
|
+ .andExpect(jsonPath("$.data.monitoringPoints", hasSize(2)))
|
|
|
+ .andExpect(jsonPath("$.data.monitoringPoints[0].pointCode").value("MP-001"))
|
|
|
+ .andExpect(jsonPath("$.data.monitoringPoints[1].pointCode").value("MP-002"))
|
|
|
+ .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(0)));
|
|
|
+
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/map-snapshot")
|
|
|
+ .param("page", "2")
|
|
|
+ .param("pageSize", "2")
|
|
|
+ .param("snapshotAt", SNAPSHOT_AT))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.data.snapshotAt").value(SNAPSHOT_AT))
|
|
|
+ .andExpect(jsonPath("$.data.total").value(7))
|
|
|
+ .andExpect(jsonPath("$.data.complete").value(false))
|
|
|
+ .andExpect(jsonPath("$.data.monitoringPoints", hasSize(1)))
|
|
|
+ .andExpect(jsonPath("$.data.monitoringPoints[0].pointCode").value("MP-003"))
|
|
|
+ .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(1)))
|
|
|
+ .andExpect(jsonPath("$.data.unassociatedEquipment[0].deviceCode").value("EQ-FREE-001"));
|
|
|
+
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/map-snapshot")
|
|
|
+ .param("page", "3")
|
|
|
+ .param("pageSize", "2")
|
|
|
+ .param("snapshotAt", SNAPSHOT_AT))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.data.snapshotAt").value(SNAPSHOT_AT))
|
|
|
+ .andExpect(jsonPath("$.data.total").value(7))
|
|
|
+ .andExpect(jsonPath("$.data.complete").value(false))
|
|
|
+ .andExpect(jsonPath("$.data.monitoringPoints", hasSize(0)))
|
|
|
+ .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(2)))
|
|
|
+ .andExpect(jsonPath("$.data.unassociatedEquipment[0].deviceCode").value("EQ-FREE-002"))
|
|
|
+ .andExpect(jsonPath("$.data.unassociatedEquipment[1].deviceCode").value("EQ-FREE-003"));
|
|
|
+
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/map-snapshot")
|
|
|
+ .param("page", "4")
|
|
|
+ .param("pageSize", "2")
|
|
|
+ .param("snapshotAt", SNAPSHOT_AT))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.data.snapshotAt").value(SNAPSHOT_AT))
|
|
|
+ .andExpect(jsonPath("$.data.total").value(7))
|
|
|
+ .andExpect(jsonPath("$.data.complete").value(true))
|
|
|
+ .andExpect(jsonPath("$.data.monitoringPoints", hasSize(0)))
|
|
|
+ .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(1)))
|
|
|
+ .andExpect(jsonPath("$.data.unassociatedEquipment[0].deviceCode").value("EQ-FREE-004"))
|
|
|
+ .andExpect(jsonPath("$.data.unassociatedEquipment[0].coordinateValidation.reason").value("ZERO_COORDINATE"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void invalidPaginationReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/map-snapshot")
|
|
|
+ .param("page", "0")
|
|
|
+ .param("pageSize", "1001"))
|
|
|
+ .andExpect(status().isBadRequest())
|
|
|
+ .andExpect(jsonPath("$.code").value(400))
|
|
|
+ .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
|
|
|
+ .andExpect(jsonPath("$.data.message").value("地图快照分页参数不合法"));
|
|
|
+
|
|
|
+ verifyNoInteractions(adapter);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void invalidSnapshotTimestampReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/map-snapshot")
|
|
|
+ .param("snapshotAt", "not-a-timestamp"))
|
|
|
+ .andExpect(status().isBadRequest())
|
|
|
+ .andExpect(jsonPath("$.code").value(400))
|
|
|
+ .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
|
|
|
+ .andExpect(jsonPath("$.data.message").value("地图快照分页参数不合法"));
|
|
|
+
|
|
|
+ verifyNoInteractions(adapter);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void expiredSnapshotReturnsTypedBadRequestAndDoesNotPretendToBeEmpty() throws Exception {
|
|
|
+ when(adapter.load("2026-09-02T09:00:00+08:00"))
|
|
|
+ .thenThrow(new CockpitSnapshotExpiredException("snapshot expired"));
|
|
|
+
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/map-snapshot")
|
|
|
+ .param("page", "2")
|
|
|
+ .param("pageSize", "500")
|
|
|
+ .param("snapshotAt", "2026-09-02T09:00:00+08:00"))
|
|
|
+ .andExpect(status().isBadRequest())
|
|
|
+ .andExpect(jsonPath("$.code").value(400))
|
|
|
+ .andExpect(jsonPath("$.data.category").value("SNAPSHOT_EXPIRED"))
|
|
|
+ .andExpect(jsonPath("$.data.message").value("地图快照已失效,请重新加载"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void serverFailureReturnsTypedServerError() throws Exception {
|
|
|
+ when(adapter.load(null)).thenThrow(new CockpitReadException("upstream unavailable"));
|
|
|
+
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/map-snapshot"))
|
|
|
+ .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/map-snapshot"))
|
|
|
+ .andExpect(status().isMethodNotAllowed());
|
|
|
+
|
|
|
+ verifyNoInteractions(adapter);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void controllerOnlyExposesAuthenticatedReadOnlyOperations() {
|
|
|
+ assertTrue(Arrays.stream(CockpitMapSnapshotController.class.getDeclaredMethods())
|
|
|
+ .filter(this::hasRequestMappingAnnotation)
|
|
|
+ .allMatch(CockpitMapSnapshotControllerTest::isReadOnlyMappedMethod));
|
|
|
+
|
|
|
+ assertTrue(Arrays.stream(CockpitMapSnapshotController.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 CockpitMapSnapshot fixture() {
|
|
|
+ return new CockpitMapSnapshot(
|
|
|
+ SNAPSHOT_AT,
|
|
|
+ Arrays.asList(
|
|
|
+ new CockpitMapSnapshot.SourceMonitoringPoint(
|
|
|
+ "MP-002",
|
|
|
+ "燃气调压站监测点",
|
|
|
+ "ALARM",
|
|
|
+ Collections.singletonList("EQ-ASSOC-001 报警"),
|
|
|
+ new BigDecimal("110.142"),
|
|
|
+ new BigDecimal("28.453"),
|
|
|
+ true,
|
|
|
+ Collections.singletonList("EQ-ASSOC-001")),
|
|
|
+ new CockpitMapSnapshot.SourceMonitoringPoint(
|
|
|
+ "MP-001",
|
|
|
+ "供水干管监测点",
|
|
|
+ "NORMAL",
|
|
|
+ Collections.emptyList(),
|
|
|
+ new BigDecimal("110.141"),
|
|
|
+ new BigDecimal("28.452"),
|
|
|
+ true,
|
|
|
+ Collections.emptyList()),
|
|
|
+ new CockpitMapSnapshot.SourceMonitoringPoint(
|
|
|
+ "MP-003",
|
|
|
+ "污水排口监测点",
|
|
|
+ "WARNING",
|
|
|
+ Collections.singletonList("EQ-FREE-003 预警"),
|
|
|
+ new BigDecimal("110.143"),
|
|
|
+ new BigDecimal("28.454"),
|
|
|
+ false,
|
|
|
+ Collections.emptyList())),
|
|
|
+ Arrays.asList(
|
|
|
+ new CockpitMapSnapshot.SourceSmartDevice(
|
|
|
+ "EQ-ASSOC-001",
|
|
|
+ "燃气浓度计-001",
|
|
|
+ new BigDecimal("110.142"),
|
|
|
+ new BigDecimal("28.453"),
|
|
|
+ true),
|
|
|
+ new CockpitMapSnapshot.SourceSmartDevice(
|
|
|
+ "EQ-FREE-002",
|
|
|
+ "智能井盖-002",
|
|
|
+ new BigDecimal("110.145"),
|
|
|
+ new BigDecimal("28.456"),
|
|
|
+ true),
|
|
|
+ new CockpitMapSnapshot.SourceSmartDevice(
|
|
|
+ "EQ-FREE-001",
|
|
|
+ "供水压力计-001",
|
|
|
+ new BigDecimal("181"),
|
|
|
+ new BigDecimal("28.455"),
|
|
|
+ true),
|
|
|
+ new CockpitMapSnapshot.SourceSmartDevice(
|
|
|
+ "EQ-FREE-003",
|
|
|
+ "污水环境监测仪-003",
|
|
|
+ new BigDecimal("110.146"),
|
|
|
+ new BigDecimal("28.457"),
|
|
|
+ false),
|
|
|
+ new CockpitMapSnapshot.SourceSmartDevice(
|
|
|
+ "EQ-FREE-004",
|
|
|
+ "零坐标设备-004",
|
|
|
+ BigDecimal.ZERO,
|
|
|
+ BigDecimal.ZERO,
|
|
|
+ true)));
|
|
|
+ }
|
|
|
+}
|