|
|
@@ -0,0 +1,296 @@
|
|
|
+package com.zksy.web.controller.cockpit;
|
|
|
+
|
|
|
+import com.zksy.cockpit.foundation.CockpitReadException;
|
|
|
+import com.zksy.cockpit.monitoring.CockpitMonitoringOverviewAdapter;
|
|
|
+import com.zksy.cockpit.monitoring.CockpitMonitoringOverviewDeviceStatusDelta;
|
|
|
+import com.zksy.cockpit.monitoring.CockpitMonitoringOverviewService;
|
|
|
+import com.zksy.cockpit.monitoring.CockpitMonitoringOverviewSnapshot;
|
|
|
+import com.zksy.cockpit.monitoring.CockpitMonitoringOverviewResponse;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.springframework.security.access.prepost.PreAuthorize;
|
|
|
+import org.springframework.test.web.servlet.MockMvc;
|
|
|
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
|
|
+import org.springframework.web.bind.annotation.DeleteMapping;
|
|
|
+import org.springframework.web.bind.annotation.GetMapping;
|
|
|
+import org.springframework.web.bind.annotation.PatchMapping;
|
|
|
+import org.springframework.web.bind.annotation.PostMapping;
|
|
|
+import org.springframework.web.bind.annotation.PutMapping;
|
|
|
+
|
|
|
+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 java.util.ArrayList;
|
|
|
+
|
|
|
+import static org.hamcrest.Matchers.hasSize;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+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 CockpitMonitoringOverviewControllerTest {
|
|
|
+
|
|
|
+ private static final String ENDPOINT = "/api/cockpit/v1/monitoring/overview";
|
|
|
+
|
|
|
+ private CockpitMonitoringOverviewAdapter adapter;
|
|
|
+ private MockMvc mockMvc;
|
|
|
+ private CockpitMonitoringOverviewService service;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ adapter = mock(CockpitMonitoringOverviewAdapter.class);
|
|
|
+ service = new CockpitMonitoringOverviewService(adapter,
|
|
|
+ Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
|
|
|
+ mockMvc = MockMvcBuilders
|
|
|
+ .standaloneSetup(new CockpitMonitoringOverviewController(service))
|
|
|
+ .build();
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void overviewExposesEnvelopeCountsStatusOnlineRateAndTypeDistribution() throws Exception {
|
|
|
+ when(adapter.load()).thenReturn(fixture());
|
|
|
+
|
|
|
+ mockMvc.perform(get(ENDPOINT).header("X-Request-Id", "monitoring-overview-001"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(200))
|
|
|
+ .andExpect(jsonPath("$.data.requestId").value("monitoring-overview-001"))
|
|
|
+ .andExpect(jsonPath("$.data.apiVersion").value("v1"))
|
|
|
+ .andExpect(jsonPath("$.data.readOnly").value(true))
|
|
|
+ .andExpect(jsonPath("$.data.displayName").value("监测概览"))
|
|
|
+ .andExpect(jsonPath("$.data.timezone").value("Asia/Shanghai"))
|
|
|
+ .andExpect(jsonPath("$.data.counts.monitoringPointCount").value(3))
|
|
|
+ .andExpect(jsonPath("$.data.counts.equipmentCount").value(5))
|
|
|
+ .andExpect(jsonPath("$.data.counts.facilityCount").value(0))
|
|
|
+ .andExpect(jsonPath("$.data.status.onlineCount").value(2))
|
|
|
+ .andExpect(jsonPath("$.data.status.offlineCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.status.staleCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.status.unknownOrMissingCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.status.denominator").value(4))
|
|
|
+ .andExpect(jsonPath("$.data.onlineRate.value").value("0.5000"))
|
|
|
+ .andExpect(jsonPath("$.data.onlineRate.numerator").value(2))
|
|
|
+ .andExpect(jsonPath("$.data.onlineRate.denominator").value(4))
|
|
|
+ .andExpect(jsonPath("$.data.onlineRate.state").value("AVAILABLE"))
|
|
|
+ .andExpect(jsonPath("$.data.typeDistribution", hasSize(3)))
|
|
|
+ .andExpect(jsonPath("$.data.typeDistribution[0].deviceCount").value(2))
|
|
|
+ .andExpect(jsonPath("$.data.typeDistribution[0].onlineCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(5)))
|
|
|
+ .andExpect(jsonPath("$.data.excluded.unknownTypeCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.excluded.typeKindMismatchCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.excluded.nonFormalIndustryCount").value(1));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void industryFilterRestrictsOverview() throws Exception {
|
|
|
+ when(adapter.load()).thenReturn(fixture());
|
|
|
+
|
|
|
+ mockMvc.perform(get(ENDPOINT).queryParam("industry", "GAS"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.data.counts.monitoringPointCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.counts.equipmentCount").value(2))
|
|
|
+ .andExpect(jsonPath("$.data.status.onlineCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.status.offlineCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.status.denominator").value(2))
|
|
|
+ .andExpect(jsonPath("$.data.onlineRate.value").value("0.5000"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void deviceTypeFilterReturnsOnlyThatSmartDeviceType() throws Exception {
|
|
|
+ when(adapter.load()).thenReturn(fixture());
|
|
|
+
|
|
|
+ mockMvc.perform(get(ENDPOINT).queryParam("deviceType", "WATER_SUPPLY_SENSOR"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.data.counts.equipmentCount").value(2))
|
|
|
+ .andExpect(jsonPath("$.data.counts.monitoringPointCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.typeDistribution", hasSize(1)))
|
|
|
+ .andExpect(jsonPath("$.data.typeDistribution[0].deviceType.code").value("WATER_SUPPLY_SENSOR"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void unknownDeviceTypeReturnsExplicitEmptyOverview() throws Exception {
|
|
|
+ when(adapter.load()).thenReturn(fixture());
|
|
|
+
|
|
|
+ mockMvc.perform(get(ENDPOINT).queryParam("deviceType", "NOT_A_TYPE"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.data.counts.equipmentCount").value(0))
|
|
|
+ .andExpect(jsonPath("$.data.status.denominator").value(0))
|
|
|
+ .andExpect(jsonPath("$.data.onlineRate.state").value("EMPTY"))
|
|
|
+ .andExpect(jsonPath("$.data.onlineRate.reason").value("NO_RESOLVED_DEVICES"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void emptySnapshotReturnsSuccessfulEmptyState() throws Exception {
|
|
|
+ when(adapter.load()).thenReturn(emptyFixture());
|
|
|
+
|
|
|
+ mockMvc.perform(get(ENDPOINT))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.data.counts.equipmentCount").value(0))
|
|
|
+ .andExpect(jsonPath("$.data.status.onlineCount").value(0))
|
|
|
+ .andExpect(jsonPath("$.data.status.denominator").value(0))
|
|
|
+ .andExpect(jsonPath("$.data.onlineRate.value").value("0.0000"))
|
|
|
+ .andExpect(jsonPath("$.data.onlineRate.state").value("EMPTY"))
|
|
|
+ .andExpect(jsonPath("$.data.onlineRate.reason").value("NO_RESOLVED_DEVICES"))
|
|
|
+ .andExpect(jsonPath("$.data.freshness.status").value("EMPTY"))
|
|
|
+ .andExpect(jsonPath("$.data.freshness.reason").value("NO_RESOLVED_DEVICES"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void allOfflineReturnsZeroOnlineRate() throws Exception {
|
|
|
+ when(adapter.load()).thenReturn(allOfflineFixture());
|
|
|
+
|
|
|
+ mockMvc.perform(get(ENDPOINT))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.data.status.onlineCount").value(0))
|
|
|
+ .andExpect(jsonPath("$.data.status.offlineCount").value(3))
|
|
|
+ .andExpect(jsonPath("$.data.onlineRate.value").value("0.0000"))
|
|
|
+ .andExpect(jsonPath("$.data.onlineRate.state").value("AVAILABLE"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void staleDevicesRemainInDenominatorAndAreNotOnline() throws Exception {
|
|
|
+ when(adapter.load()).thenReturn(fixture());
|
|
|
+
|
|
|
+ mockMvc.perform(get(ENDPOINT))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.data.status.staleCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.status.denominator").value(4))
|
|
|
+ .andExpect(jsonPath("$.data.onlineRate.numerator").value(2));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void invalidIndustryReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
|
|
|
+ mockMvc.perform(get(ENDPOINT).queryParam("industry", "MANHOLE"))
|
|
|
+ .andExpect(status().isBadRequest())
|
|
|
+ .andExpect(jsonPath("$.code").value(400))
|
|
|
+ .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
|
|
|
+ .andExpect(jsonPath("$.data.message").value("industry must be GAS, WATER_SUPPLY or DRAINAGE"));
|
|
|
+
|
|
|
+ verifyNoInteractions(adapter);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void mutatingRequestIsRejectedBeforeReadAdapterIsCalled() throws Exception {
|
|
|
+ mockMvc.perform(post(ENDPOINT))
|
|
|
+ .andExpect(status().isMethodNotAllowed());
|
|
|
+
|
|
|
+ verifyNoInteractions(adapter);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void serverFailureReturnsTypedServerError() throws Exception {
|
|
|
+ when(adapter.load()).thenThrow(new CockpitReadException("upstream unavailable"));
|
|
|
+
|
|
|
+ mockMvc.perform(get(ENDPOINT).header("X-Request-Id", "monitoring-overview-500"))
|
|
|
+ .andExpect(status().isInternalServerError())
|
|
|
+ .andExpect(jsonPath("$.code").value(500))
|
|
|
+ .andExpect(jsonPath("$.data.category").value("SERVER_ERROR"))
|
|
|
+ .andExpect(jsonPath("$.data.message").value("驾驶舱监测概览数据暂不可用"))
|
|
|
+ .andExpect(jsonPath("$.data.requestId").value("monitoring-overview-500"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void webSocketDeltaRecomputesAndIgnoresOldMessages() {
|
|
|
+ when(adapter.load()).thenReturn(fixture());
|
|
|
+
|
|
|
+ CockpitMonitoringOverviewResponse first = service.applyDeviceStatusDelta(
|
|
|
+ "delta-1", null, null,
|
|
|
+ new CockpitMonitoringOverviewDeviceStatusDelta(
|
|
|
+ "GAS-002", "ONLINE", "2026-09-02T09:59:00+08:00",
|
|
|
+ "2026-09-02T10:05:00+08:00"));
|
|
|
+
|
|
|
+ assertEquals(3, first.getStatus().getOnlineCount());
|
|
|
+ assertEquals(0, first.getStatus().getOfflineCount());
|
|
|
+ assertEquals(4, first.getStatus().getDenominator());
|
|
|
+ assertEquals("0.7500", first.getOnlineRate().getValue().toPlainString());
|
|
|
+
|
|
|
+ CockpitMonitoringOverviewResponse second = service.applyDeviceStatusDelta(
|
|
|
+ "delta-2", null, null,
|
|
|
+ new CockpitMonitoringOverviewDeviceStatusDelta(
|
|
|
+ "GAS-002", "OFFLINE", "2026-09-02T09:59:00+08:00",
|
|
|
+ "2026-09-02T10:04:00+08:00"));
|
|
|
+
|
|
|
+ assertEquals(3, second.getStatus().getOnlineCount());
|
|
|
+ assertEquals(0, second.getStatus().getOfflineCount());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void controllerExposesAuthenticatedReadOnlyMethodsOnly() {
|
|
|
+ for (java.lang.reflect.Method method
|
|
|
+ : CockpitMonitoringOverviewController.class.getDeclaredMethods()) {
|
|
|
+ if (method.isAnnotationPresent(GetMapping.class)) {
|
|
|
+ assertTrue(!method.isAnnotationPresent(PostMapping.class)
|
|
|
+ && !method.isAnnotationPresent(PutMapping.class)
|
|
|
+ && !method.isAnnotationPresent(PatchMapping.class)
|
|
|
+ && !method.isAnnotationPresent(DeleteMapping.class));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ assertTrue(Arrays.stream(CockpitMonitoringOverviewController.class.getDeclaredMethods())
|
|
|
+ .anyMatch(method -> method.isAnnotationPresent(PreAuthorize.class)
|
|
|
+ && "isAuthenticated()".equals(
|
|
|
+ method.getAnnotation(PreAuthorize.class).value())));
|
|
|
+ }
|
|
|
+
|
|
|
+ private static CockpitMonitoringOverviewSnapshot fixture() {
|
|
|
+ return new CockpitMonitoringOverviewSnapshot(
|
|
|
+ "2026-09-02T09:59:50+08:00",
|
|
|
+ Arrays.asList(
|
|
|
+ type("GAS_SENSOR", "燃气浓度计", "GAS"),
|
|
|
+ type("WATER_SUPPLY_SENSOR", "供水压力计", "WATER_SUPPLY"),
|
|
|
+ type("SEWAGE_ENV", "污水环境监测", "DRAINAGE"),
|
|
|
+ new CockpitMonitoringOverviewSnapshot.SourceType("WATER_PLANT", "水厂", "WATER_SUPPLY", "FACILITY"),
|
|
|
+ type("SMART_MANHOLE", "智能井盖", null)),
|
|
|
+ Arrays.asList(
|
|
|
+ device("GAS-001", "GAS_SENSOR", "ONLINE", "P-GAS-1"),
|
|
|
+ device("GAS-002", "GAS_SENSOR", "OFFLINE", "P-GAS-2"),
|
|
|
+ device("WS-001", "WATER_SUPPLY_SENSOR", "STALE", "P-WS-1"),
|
|
|
+ device("WS-002", "WATER_SUPPLY_SENSOR", "UNKNOWN", "P-WS-2"),
|
|
|
+ device("DRAIN-001", "SEWAGE_ENV", "ONLINE", "P-DR-1"),
|
|
|
+ device("MH-001", "SMART_MANHOLE", "ONLINE", "P-MH-1"),
|
|
|
+ device("MISMATCH-001", "WATER_PLANT", "ONLINE", "P-MATCH-1"),
|
|
|
+ device("UNKNOWN-001", "LEGACY", "ONLINE", "P-UNKNOWN-1")),
|
|
|
+ Arrays.asList(
|
|
|
+ point("P-GAS-1", "GAS_SENSOR"),
|
|
|
+ point("P-WS-1", "WATER_SUPPLY_SENSOR"),
|
|
|
+ point("P-DR-1", "SEWAGE_ENV")));
|
|
|
+ }
|
|
|
+
|
|
|
+ private static CockpitMonitoringOverviewSnapshot emptyFixture() {
|
|
|
+ return new CockpitMonitoringOverviewSnapshot(
|
|
|
+ "2026-09-02T09:59:50+08:00",
|
|
|
+ Collections.emptyList(), Collections.emptyList(), Collections.emptyList());
|
|
|
+ }
|
|
|
+
|
|
|
+ private static CockpitMonitoringOverviewSnapshot allOfflineFixture() {
|
|
|
+ return new CockpitMonitoringOverviewSnapshot(
|
|
|
+ "2026-09-02T09:59:50+08:00",
|
|
|
+ Collections.singletonList(type("GAS_SENSOR", "燃气浓度计", "GAS")),
|
|
|
+ Arrays.asList(
|
|
|
+ device("GAS-001", "GAS_SENSOR", "OFFLINE", "P-GAS-1"),
|
|
|
+ device("GAS-002", "GAS_SENSOR", "OFFLINE", "P-GAS-2"),
|
|
|
+ device("GAS-003", "GAS_SENSOR", "OFFLINE", "P-GAS-3")),
|
|
|
+ Collections.emptyList());
|
|
|
+ }
|
|
|
+
|
|
|
+ private static CockpitMonitoringOverviewSnapshot.SourceType type(String code, String name, String industry) {
|
|
|
+ return new CockpitMonitoringOverviewSnapshot.SourceType(
|
|
|
+ code, name, industry, "SMART_DEVICE");
|
|
|
+ }
|
|
|
+
|
|
|
+ private static CockpitMonitoringOverviewSnapshot.SourceDevice device(
|
|
|
+ String code, String typeCode, String state, String pointCode) {
|
|
|
+ return new CockpitMonitoringOverviewSnapshot.SourceDevice(
|
|
|
+ code, typeCode, state, "2026-09-02T09:59:00+08:00", pointCode);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint point(String code, String typeCode) {
|
|
|
+ return new CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint(code, typeCode);
|
|
|
+ }
|
|
|
+}
|