|
|
@@ -0,0 +1,215 @@
|
|
|
+package com.zksy.web.controller.cockpit;
|
|
|
+
|
|
|
+import com.zksy.cockpit.device.CockpitDeviceBoundaryAdapter;
|
|
|
+import com.zksy.cockpit.device.CockpitDeviceBoundaryService;
|
|
|
+import com.zksy.cockpit.device.CockpitDeviceBoundarySnapshot;
|
|
|
+import com.zksy.cockpit.device.CockpitDeviceBoundarySnapshot.SourceDevice;
|
|
|
+import com.zksy.cockpit.device.CockpitDeviceBoundarySnapshot.SourceFacility;
|
|
|
+import com.zksy.cockpit.device.CockpitDeviceBoundarySnapshot.SourceType;
|
|
|
+import com.zksy.cockpit.foundation.CockpitReadException;
|
|
|
+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.MvcResult;
|
|
|
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
|
|
+
|
|
|
+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.assertFalse;
|
|
|
+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 CockpitDeviceBoundaryControllerTest {
|
|
|
+
|
|
|
+ private CockpitDeviceBoundaryAdapter adapter;
|
|
|
+ private MockMvc mockMvc;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ adapter = mock(CockpitDeviceBoundaryAdapter.class);
|
|
|
+ CockpitDeviceBoundaryService service = new CockpitDeviceBoundaryService(
|
|
|
+ adapter,
|
|
|
+ Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
|
|
|
+ CockpitDeviceBoundaryController controller = new CockpitDeviceBoundaryController(service);
|
|
|
+ mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void deviceBoundaryUsesCanonicalDeviceCodeAndSeparatesFacilitiesFromSmartDevices() throws Exception {
|
|
|
+ when(adapter.load()).thenReturn(fixture());
|
|
|
+
|
|
|
+ MvcResult result = mockMvc.perform(get("/api/cockpit/v1/device-boundary")
|
|
|
+ .header("X-Request-Id", "device-boundary-request-001"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(200))
|
|
|
+ .andExpect(jsonPath("$.msg").value("操作成功"))
|
|
|
+ .andExpect(jsonPath("$.data.requestId").value("device-boundary-request-001"))
|
|
|
+ .andExpect(jsonPath("$.data.apiVersion").value("v1"))
|
|
|
+ .andExpect(jsonPath("$.data.readOnly").value(true))
|
|
|
+ .andExpect(jsonPath("$.data.typeScope.formalIndustries", hasSize(3)))
|
|
|
+ .andExpect(jsonPath("$.data.typeScope.drainageScopes", hasSize(2)))
|
|
|
+ .andExpect(jsonPath("$.data.typeScope.objectKinds", hasSize(2)))
|
|
|
+ .andExpect(jsonPath("$.data.facilities", hasSize(3)))
|
|
|
+ .andExpect(jsonPath("$.data.devices", hasSize(6)))
|
|
|
+ .andExpect(jsonPath("$.data.facilities[0].facilityCode").value("F-WATER-001"))
|
|
|
+ .andExpect(jsonPath("$.data.facilities[0].objectKind").value("FACILITY"))
|
|
|
+ .andExpect(jsonPath("$.data.facilities[0].industry").value("WATER_SUPPLY"))
|
|
|
+ .andExpect(jsonPath("$.data.facilities[1].facilityCode").value("F-PUMP-001"))
|
|
|
+ .andExpect(jsonPath("$.data.facilities[1].objectKind").value("FACILITY"))
|
|
|
+ .andExpect(jsonPath("$.data.facilities[2].facilityCode").value("F-PIPE-001"))
|
|
|
+ .andExpect(jsonPath("$.data.facilities[2].objectKind").value("FACILITY"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[0].deviceCode").value("EQ-GAS-001"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[0].deviceType.code").value("GAS_SENSOR"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[0].industry").value("GAS"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[0].objectKind").value("SMART_DEVICE"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[0].formalScope").value(true))
|
|
|
+ .andExpect(jsonPath("$.data.devices[1].deviceCode").value("EQ-WS-001"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[1].deviceType.code").value("WATER_SUPPLY_SENSOR"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[1].industry").value("WATER_SUPPLY"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[1].objectKind").value("SMART_DEVICE"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[2].deviceCode").value("EQ-RAIN-001"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[2].deviceType.code").value("RAINWATER_SENSOR"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[2].industry").value("DRAINAGE"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[2].drainageScope").value("RAINWATER"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[2].objectKind").value("SMART_DEVICE"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[3].deviceCode").value("EQ-SEW-001"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[3].deviceType.code").value("SEWAGE_ENVIRONMENT_SENSOR"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[3].industry").value("DRAINAGE"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[3].drainageScope").value("SEWAGE"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[3].objectKind").value("SMART_DEVICE"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[4].deviceCode").value("EQ-MH-001"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[4].deviceType.code").value("SMART_MANHOLE"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[4].objectKind").value("SMART_DEVICE"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[5].deviceCode").value("EQ-UNKNOWN-001"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[5].typeResolution").value("UNKNOWN_TYPE"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[5].formalScope").value(false))
|
|
|
+ .andExpect(jsonPath("$.data.statistics.facilityCount").value(3))
|
|
|
+ .andExpect(jsonPath("$.data.statistics.smartDeviceCount").value(6))
|
|
|
+ .andExpect(jsonPath("$.data.statistics.formalSmartDeviceCount").value(4))
|
|
|
+ .andExpect(jsonPath("$.data.statistics.unknownTypeCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.statistics.typeKindMismatchCount").value(0))
|
|
|
+ .andExpect(jsonPath("$.data.statistics.industries.gas.smartDeviceCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.statistics.industries.waterSupply.facilityCount").value(3))
|
|
|
+ .andExpect(jsonPath("$.data.statistics.industries.waterSupply.smartDeviceCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.statistics.industries.drainage.smartDeviceCount").value(2))
|
|
|
+ .andExpect(jsonPath("$.data.statistics.industries.drainage.rainwaterSmartDeviceCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.statistics.industries.drainage.sewageSmartDeviceCount").value(1))
|
|
|
+ .andReturn();
|
|
|
+
|
|
|
+ String body = result.getResponse().getContentAsString();
|
|
|
+ assertFalse(body.contains("equipment_code"));
|
|
|
+ assertFalse(body.contains("equipmentId"));
|
|
|
+ assertFalse(body.contains("equipmentTypeId"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void serverFailureReturnsTypedErrorInsteadOfPretendingToBeEmpty() throws Exception {
|
|
|
+ when(adapter.load()).thenThrow(new CockpitReadException("upstream unavailable"));
|
|
|
+
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/device-boundary")
|
|
|
+ .header("X-Request-Id", "device-boundary-request-002"))
|
|
|
+ .andExpect(status().isInternalServerError())
|
|
|
+ .andExpect(jsonPath("$.code").value(500))
|
|
|
+ .andExpect(jsonPath("$.msg").value("驾驶舱设备边界数据暂不可用"))
|
|
|
+ .andExpect(jsonPath("$.data.category").value("SERVER_ERROR"))
|
|
|
+ .andExpect(jsonPath("$.data.message").value("驾驶舱设备边界数据暂不可用"))
|
|
|
+ .andExpect(jsonPath("$.data.requestId").value("device-boundary-request-002"))
|
|
|
+ .andExpect(jsonPath("$.data.serverTime").value("2026-09-02T10:00:00+08:00"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void facilityFormalScopeRequiresFormalIndustryAndFacilityObjectKind() throws Exception {
|
|
|
+ when(adapter.load()).thenReturn(new CockpitDeviceBoundarySnapshot(
|
|
|
+ Arrays.asList(
|
|
|
+ new SourceType("WATER_PLANT", "水厂", "WATER_SUPPLY", null, "FACILITY"),
|
|
|
+ new SourceType("WATER_PLANT_DEVICE", "水厂设备", "WATER_SUPPLY", null, "SMART_DEVICE")),
|
|
|
+ Arrays.asList(
|
|
|
+ new SourceFacility("F-WATER-001", "第一水厂", "WATER_PLANT"),
|
|
|
+ new SourceFacility("F-UNKNOWN-001", "未知类型设施", "LEGACY_UNKNOWN_FACILITY_TYPE"),
|
|
|
+ new SourceFacility("F-MISMATCH-001", "误挂设备类型的设施", "WATER_PLANT_DEVICE")),
|
|
|
+ Collections.emptyList()));
|
|
|
+
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/device-boundary"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.data.facilities[0].formalScope").value(true))
|
|
|
+ .andExpect(jsonPath("$.data.facilities[1].formalScope").value(false))
|
|
|
+ .andExpect(jsonPath("$.data.facilities[2].formalScope").value(false))
|
|
|
+ .andExpect(jsonPath("$.data.statistics.facilityCount").value(3))
|
|
|
+ .andExpect(jsonPath("$.data.statistics.industries.waterSupply.facilityCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.statistics.unknownTypeCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.statistics.typeKindMismatchCount").value(1));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void mutatingRequestIsRejectedBeforeReadAdapterIsCalled() throws Exception {
|
|
|
+ mockMvc.perform(post("/api/cockpit/v1/device-boundary"))
|
|
|
+ .andExpect(status().isMethodNotAllowed());
|
|
|
+
|
|
|
+ verifyNoInteractions(adapter);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void controllerOnlyExposesAuthenticatedReadOnlyOperations() {
|
|
|
+ assertTrue(Arrays.stream(CockpitDeviceBoundaryController.class.getDeclaredMethods())
|
|
|
+ .filter(method -> 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))
|
|
|
+ .allMatch(CockpitDeviceBoundaryControllerTest::isReadOnlyMappedMethod));
|
|
|
+
|
|
|
+ assertTrue(Arrays.stream(CockpitDeviceBoundaryController.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 static boolean isReadOnlyMappedMethod(java.lang.reflect.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 CockpitDeviceBoundarySnapshot fixture() {
|
|
|
+ List<SourceType> types = Arrays.asList(
|
|
|
+ new SourceType("WATER_PLANT", "水厂", "WATER_SUPPLY", null, "FACILITY"),
|
|
|
+ new SourceType("PUMP_STATION", "泵站", "WATER_SUPPLY", null, "FACILITY"),
|
|
|
+ new SourceType("PIPELINE", "供水管线", "WATER_SUPPLY", null, "FACILITY"),
|
|
|
+ new SourceType("GAS_SENSOR", "燃气浓度计", "GAS", null, "SMART_DEVICE"),
|
|
|
+ new SourceType("WATER_SUPPLY_SENSOR", "供水压力计", "WATER_SUPPLY", null, "SMART_DEVICE"),
|
|
|
+ new SourceType("RAINWATER_SENSOR", "雨水流量计", "DRAINAGE", "RAINWATER", "SMART_DEVICE"),
|
|
|
+ new SourceType("SEWAGE_ENVIRONMENT_SENSOR", "污水环境监测仪", "DRAINAGE", "SEWAGE", "SMART_DEVICE"),
|
|
|
+ new SourceType("SMART_MANHOLE", "智能井盖", null, null, "SMART_DEVICE"));
|
|
|
+
|
|
|
+ List<SourceFacility> facilities = Arrays.asList(
|
|
|
+ new SourceFacility("F-WATER-001", "第一水厂", "WATER_PLANT"),
|
|
|
+ new SourceFacility("F-PUMP-001", "城北泵站", "PUMP_STATION"),
|
|
|
+ new SourceFacility("F-PIPE-001", "供水主线", "PIPELINE"));
|
|
|
+
|
|
|
+ List<SourceDevice> devices = Arrays.asList(
|
|
|
+ new SourceDevice("EQ-GAS-001", "燃气浓度计-001", "GAS_SENSOR"),
|
|
|
+ new SourceDevice("EQ-WS-001", "供水压力计-001", "WATER_SUPPLY_SENSOR"),
|
|
|
+ new SourceDevice("EQ-RAIN-001", "雨水流量计-001", "RAINWATER_SENSOR"),
|
|
|
+ new SourceDevice("EQ-SEW-001", "污水环境监测仪-001", "SEWAGE_ENVIRONMENT_SENSOR"),
|
|
|
+ new SourceDevice("EQ-MH-001", "智能井盖-001", "SMART_MANHOLE"),
|
|
|
+ new SourceDevice("EQ-UNKNOWN-001", "未知类型设备-001", "LEGACY_UNKNOWN_TYPE"));
|
|
|
+
|
|
|
+ return new CockpitDeviceBoundarySnapshot(types, facilities, devices);
|
|
|
+ }
|
|
|
+}
|