|
@@ -0,0 +1,223 @@
|
|
|
|
|
+package com.zksy.web.controller.cockpit;
|
|
|
|
|
+
|
|
|
|
|
+import com.zksy.cockpit.equipment.CockpitEquipmentArchiveSnapshot;
|
|
|
|
|
+import com.zksy.cockpit.equipment.CockpitEquipmentArchiveAdapter;
|
|
|
|
|
+import com.zksy.cockpit.equipment.CockpitEquipmentImage;
|
|
|
|
|
+import com.zksy.cockpit.equipment.CockpitEquipmentArchiveService;
|
|
|
|
|
+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.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.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 CockpitEquipmentArchiveControllerTest {
|
|
|
|
|
+ private CockpitEquipmentArchiveAdapter adapter;
|
|
|
|
|
+ private MockMvc mockMvc;
|
|
|
|
|
+
|
|
|
|
|
+ @BeforeEach
|
|
|
|
|
+ void setUp() {
|
|
|
|
|
+ adapter = mock(CockpitEquipmentArchiveAdapter.class);
|
|
|
|
|
+ CockpitEquipmentArchiveService service = new CockpitEquipmentArchiveService(
|
|
|
|
|
+ adapter,
|
|
|
|
|
+ Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
|
|
|
|
|
+ mockMvc = MockMvcBuilders
|
|
|
|
|
+ .standaloneSetup(new CockpitEquipmentArchiveController(service))
|
|
|
|
|
+ .build();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void archiveReturnsCompleteReadOnlyContractWithLatestImage() throws Exception {
|
|
|
|
|
+ when(adapter.load("TC-PS-YS-202609020012")).thenReturn(archive(
|
|
|
|
|
+ "TC-PS-YS-202609020012",
|
|
|
|
|
+ Collections.singletonList(new CockpitEquipmentImage(
|
|
|
|
|
+ "https://cockpit.example/equipment-latest.jpg",
|
|
|
|
|
+ "2026-08-30T10:20:00+08:00"))));
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/equipments/TC-PS-YS-202609020012/archive")
|
|
|
|
|
+ .header("X-Request-Id", "equipment-archive-request-001"))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(200))
|
|
|
|
|
+ .andExpect(jsonPath("$.msg").value("操作成功"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.requestId").value("equipment-archive-request-001"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.apiVersion").value("v1"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.readOnly").value(true))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.deviceCode").value("TC-PS-YS-202609020012"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.deviceName").value("污水管网环境监测设备"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.deviceModel").value("WS-PRO-2000"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.deviceSpec").value("悬浮物/COD/氨氮/电导率/PH"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.manufacturer").value("沅陵专用仪表厂"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.productionDate").value("2025-12-01"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.deviceType").value("ws_sewage_environment"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.deviceTypeName").value("污水环境监测设备"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.assetValue").value(128000.0))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.expectedServiceYears").value(10))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.longitude").value(110.141))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.latitude").value(28.452))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.coordinateSystem").value("WGS84"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.equipmentLocation").value("沅陵县太常片区污水干管检查井"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.installTime").value("2026-01-10 09:30:00"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.maintainer").value("张维护"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.maintainerPhone").value("13800000000"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.ownershipUnit").value("沅陵县排水管理所"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.remark").value("监测点首选查看设备"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.latestImage.url").value("https://cockpit.example/equipment-latest.jpg"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.latestImage.capturedAt").value("2026-08-30T10:20:00+08:00"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void archiveSucceedsWithoutImageWhenArchiveExists() throws Exception {
|
|
|
|
|
+ when(adapter.load("TC-PS-YS-202609020013")).thenReturn(archive(
|
|
|
|
|
+ "TC-PS-YS-202609020013", Collections.emptyList()));
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/equipments/TC-PS-YS-202609020013/archive"))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(200))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.deviceCode").value("TC-PS-YS-202609020013"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.latestImage").doesNotExist());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void archiveSelectsOnlyTheLatestImageByCapturedAt() throws Exception {
|
|
|
|
|
+ when(adapter.load("TC-PS-YS-202609020014")).thenReturn(archive(
|
|
|
|
|
+ "TC-PS-YS-202609020014",
|
|
|
|
|
+ Arrays.asList(
|
|
|
|
|
+ new CockpitEquipmentImage(
|
|
|
|
|
+ "https://cockpit.example/older.jpg",
|
|
|
|
|
+ "2026-08-29T10:20:00+08:00"),
|
|
|
|
|
+ new CockpitEquipmentImage(
|
|
|
|
|
+ "https://cockpit.example/latest.jpg",
|
|
|
|
|
+ "2026-08-30T10:20:00+08:00"))));
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/equipments/TC-PS-YS-202609020014/archive"))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.data.latestImage.url").value("https://cockpit.example/latest.jpg"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.latestImage.capturedAt").value("2026-08-30T10:20:00+08:00"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void missingDeviceReturnsTypedNotFound() throws Exception {
|
|
|
|
|
+ when(adapter.load("TC-PS-YS-404")).thenReturn(null);
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/equipments/TC-PS-YS-404/archive")
|
|
|
|
|
+ .header("X-Request-Id", "equipment-archive-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("equipment-archive-request-404"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void blankCodeReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
|
|
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/equipments/%20/archive"))
|
|
|
|
|
+ .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/equipments/%ZZ/archive"))
|
|
|
|
|
+ .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("TC-PS-YS-500")).thenThrow(new CockpitReadException("upstream unavailable"));
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/equipments/TC-PS-YS-500/archive"))
|
|
|
|
|
+ .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/equipments/TC-PS-YS-202609020012/archive"))
|
|
|
|
|
+ .andExpect(status().isMethodNotAllowed());
|
|
|
|
|
+
|
|
|
|
|
+ verifyNoInteractions(adapter);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void controllerOnlyExposesAuthenticatedReadOnlyOperations() {
|
|
|
|
|
+ assertTrue(java.util.Arrays.stream(CockpitEquipmentArchiveController.class.getDeclaredMethods())
|
|
|
|
|
+ .filter(this::hasRequestMappingAnnotation)
|
|
|
|
|
+ .allMatch(CockpitEquipmentArchiveControllerTest::isReadOnlyMappedMethod));
|
|
|
|
|
+
|
|
|
|
|
+ assertTrue(java.util.Arrays.stream(CockpitEquipmentArchiveController.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 CockpitEquipmentArchiveSnapshot archive(
|
|
|
|
|
+ String deviceCode,
|
|
|
|
|
+ java.util.List<CockpitEquipmentImage> images) {
|
|
|
|
|
+ return new CockpitEquipmentArchiveSnapshot(
|
|
|
|
|
+ deviceCode,
|
|
|
|
|
+ "污水管网环境监测设备",
|
|
|
|
|
+ "WS-PRO-2000",
|
|
|
|
|
+ "悬浮物/COD/氨氮/电导率/PH",
|
|
|
|
|
+ "沅陵专用仪表厂",
|
|
|
|
|
+ "2025-12-01",
|
|
|
|
|
+ "ws_sewage_environment",
|
|
|
|
|
+ "污水环境监测设备",
|
|
|
|
|
+ new BigDecimal("128000"),
|
|
|
|
|
+ 10,
|
|
|
|
|
+ new BigDecimal("110.141"),
|
|
|
|
|
+ new BigDecimal("28.452"),
|
|
|
|
|
+ "沅陵县太常片区污水干管检查井",
|
|
|
|
|
+ "2026-01-10 09:30:00",
|
|
|
|
|
+ "张维护",
|
|
|
|
|
+ "13800000000",
|
|
|
|
|
+ "沅陵县排水管理所",
|
|
|
|
|
+ "监测点首选查看设备",
|
|
|
|
|
+ images);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|