|
|
@@ -0,0 +1,304 @@
|
|
|
+package com.zksy.web.controller.cockpit;
|
|
|
+
|
|
|
+import com.zksy.cockpit.foundation.CockpitReadException;
|
|
|
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureManagementInformationAdapter;
|
|
|
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureManagementInformationService;
|
|
|
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureManagementInformationSnapshot;
|
|
|
+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.ArrayList;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+import static org.hamcrest.Matchers.hasSize;
|
|
|
+import static org.hamcrest.Matchers.nullValue;
|
|
|
+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 CockpitInfrastructureManagementInformationControllerTest {
|
|
|
+
|
|
|
+ private static final String ENDPOINT = "/api/cockpit/v1/infrastructure/management-information";
|
|
|
+
|
|
|
+ private CockpitInfrastructureManagementInformationAdapter adapter;
|
|
|
+ private MockMvc mockMvc;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ adapter = mock(CockpitInfrastructureManagementInformationAdapter.class);
|
|
|
+ CockpitInfrastructureManagementInformationService service =
|
|
|
+ new CockpitInfrastructureManagementInformationService(
|
|
|
+ adapter,
|
|
|
+ Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
|
|
|
+ mockMvc = MockMvcBuilders
|
|
|
+ .standaloneSetup(new CockpitInfrastructureManagementInformationController(service))
|
|
|
+ .build();
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void managementInformationGroupsOrganizationsAndOwnershipDistribution() throws Exception {
|
|
|
+ when(adapter.load()).thenReturn(fixture());
|
|
|
+
|
|
|
+ mockMvc.perform(get(ENDPOINT).header("X-Request-Id", "management-information-001"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(200))
|
|
|
+ .andExpect(jsonPath("$.msg").value("操作成功"))
|
|
|
+ .andExpect(jsonPath("$.data.requestId").value("management-information-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.updatedAt").value("2026-09-02T09:59:50+08:00"))
|
|
|
+ .andExpect(jsonPath("$.data.freshness.status").value("CURRENT"))
|
|
|
+ .andExpect(jsonPath("$.data.freshness.reason").value("READY"))
|
|
|
+ .andExpect(jsonPath("$.data.selectedFilters.industry").value(nullValue()))
|
|
|
+ .andExpect(jsonPath("$.data.selectedFilters.organizationCode").value(nullValue()))
|
|
|
+ .andExpect(jsonPath("$.data.totals.organizationCount").value(4))
|
|
|
+ .andExpect(jsonPath("$.data.totals.facilityCount").value(5))
|
|
|
+ .andExpect(jsonPath("$.data.totals.equipmentCount").value(4))
|
|
|
+ .andExpect(jsonPath("$.data.totals.unmaintainedCount").value(2))
|
|
|
+ .andExpect(jsonPath("$.data.organizations", hasSize(4)))
|
|
|
+ .andExpect(jsonPath("$.data.organizations[0].organizationCode").value("ORG-WS-A"))
|
|
|
+ .andExpect(jsonPath("$.data.organizations[0].organizationName").value("供水一公司"))
|
|
|
+ .andExpect(jsonPath("$.data.organizations[0].industry").value("WATER_SUPPLY"))
|
|
|
+ .andExpect(jsonPath("$.data.organizations[0].facilityCount").value(2))
|
|
|
+ .andExpect(jsonPath("$.data.organizations[0].equipmentCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.organizations[0].unmaintained").value(false))
|
|
|
+ .andExpect(jsonPath("$.data.organizations[0].ownershipDistribution", hasSize(2)))
|
|
|
+ .andExpect(jsonPath("$.data.organizations[0].ownershipDistribution[0].ownershipType").value("OWNED"))
|
|
|
+ .andExpect(jsonPath("$.data.organizations[0].ownershipDistribution[0].facilityCount").value(2))
|
|
|
+ .andExpect(jsonPath("$.data.organizations[0].ownershipDistribution[0].equipmentCount").value(0))
|
|
|
+ .andExpect(jsonPath("$.data.organizations[0].ownershipDistribution[0].total").value(2))
|
|
|
+ .andExpect(jsonPath("$.data.organizations[0].ownershipDistribution[1].ownershipType").value("LEASED"))
|
|
|
+ .andExpect(jsonPath("$.data.organizations[1].organizationCode").value("ORG-GAS-A"))
|
|
|
+ .andExpect(jsonPath("$.data.organizations[2].organizationCode").value("ORG-WS-B"))
|
|
|
+ .andExpect(jsonPath("$.data.organizations[2].ownershipDistribution[0].ownershipType").value("ENTRUSTED"))
|
|
|
+ .andExpect(jsonPath("$.data.organizations[2].ownershipDistribution[1].ownershipType").value("UNKNOWN"))
|
|
|
+ .andExpect(jsonPath("$.data.ownershipDistribution", hasSize(4)))
|
|
|
+ .andExpect(jsonPath("$.data.ownershipDistribution[0].ownershipType").value("OWNED"))
|
|
|
+ .andExpect(jsonPath("$.data.ownershipDistribution[0].facilityCount").value(4))
|
|
|
+ .andExpect(jsonPath("$.data.ownershipDistribution[0].equipmentCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.ownershipDistribution[0].total").value(5))
|
|
|
+ .andExpect(jsonPath("$.data.ownershipDistribution[1].ownershipType").value("UNKNOWN"))
|
|
|
+ .andExpect(jsonPath("$.data.ownershipDistribution[1].total").value(2))
|
|
|
+ .andExpect(jsonPath("$.data.ownershipDistribution[2].ownershipType").value("ENTRUSTED"))
|
|
|
+ .andExpect(jsonPath("$.data.ownershipDistribution[3].ownershipType").value("LEASED"))
|
|
|
+ .andExpect(jsonPath("$.data.mapFilter.organizationCodes", hasSize(4)))
|
|
|
+ .andExpect(jsonPath("$.data.mapFilter.organizationCodes[0]").value("ORG-WS-A"))
|
|
|
+ .andExpect(jsonPath("$.data.mapFilter.organizationCodes[3]").value("UNMAINTAINED"))
|
|
|
+ .andExpect(jsonPath("$.data.excluded.duplicateRelationCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.excluded.unknownIndustryCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.excluded.nonFormalIndustryCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.excluded.unknownObjectKindCount").value(1));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void missingOrganizationFallsIntoExplicitUnmaintainedBucket() throws Exception {
|
|
|
+ when(adapter.load()).thenReturn(fixture());
|
|
|
+
|
|
|
+ mockMvc.perform(get(ENDPOINT))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.data.organizations[3].organizationCode").value("UNMAINTAINED"))
|
|
|
+ .andExpect(jsonPath("$.data.organizations[3].organizationName").value("未维护"))
|
|
|
+ .andExpect(jsonPath("$.data.organizations[3].unmaintained").value(true))
|
|
|
+ .andExpect(jsonPath("$.data.organizations[3].industry").value("DRAINAGE"))
|
|
|
+ .andExpect(jsonPath("$.data.organizations[3].facilityCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.organizations[3].equipmentCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.organizations[3].ownershipDistribution[0].ownershipType").value("OWNED"))
|
|
|
+ .andExpect(jsonPath("$.data.organizations[3].ownershipDistribution[1].ownershipType").value("UNKNOWN"))
|
|
|
+ .andExpect(jsonPath("$.data.totals.unmaintainedCount").value(2));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void duplicateRelationsAreCountedOnceAndReportedAsExcluded() throws Exception {
|
|
|
+ when(adapter.load()).thenReturn(fixture());
|
|
|
+
|
|
|
+ mockMvc.perform(get(ENDPOINT))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.data.totals.facilityCount").value(5))
|
|
|
+ .andExpect(jsonPath("$.data.organizations[0].facilityCount").value(2))
|
|
|
+ .andExpect(jsonPath("$.data.excluded.duplicateRelationCount").value(1));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void facilitiesAndEquipmentAreCountedSeparatelyAndNeverMerged() throws Exception {
|
|
|
+ when(adapter.load()).thenReturn(fixture());
|
|
|
+
|
|
|
+ mockMvc.perform(get(ENDPOINT))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.data.organizations[0].facilityCount").value(2))
|
|
|
+ .andExpect(jsonPath("$.data.organizations[0].equipmentCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.totals.facilityCount").value(5))
|
|
|
+ .andExpect(jsonPath("$.data.totals.equipmentCount").value(4));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void industryFilterRestrictsOrganizationsToTheFormalIndustry() throws Exception {
|
|
|
+ when(adapter.load()).thenReturn(fixture());
|
|
|
+
|
|
|
+ mockMvc.perform(get(ENDPOINT).queryParam("industry", "WATER_SUPPLY"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.data.selectedFilters.industry").value("WATER_SUPPLY"))
|
|
|
+ .andExpect(jsonPath("$.data.organizations", hasSize(2)))
|
|
|
+ .andExpect(jsonPath("$.data.organizations[0].organizationCode").value("ORG-WS-A"))
|
|
|
+ .andExpect(jsonPath("$.data.organizations[1].organizationCode").value("ORG-WS-B"))
|
|
|
+ .andExpect(jsonPath("$.data.totals.facilityCount").value(3))
|
|
|
+ .andExpect(jsonPath("$.data.totals.equipmentCount").value(2));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void organizationCodeFilterSelectsSingleUnit() throws Exception {
|
|
|
+ when(adapter.load()).thenReturn(fixture());
|
|
|
+
|
|
|
+ mockMvc.perform(get(ENDPOINT).queryParam("organizationCode", "ORG-GAS-A"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.data.selectedFilters.organizationCode").value("ORG-GAS-A"))
|
|
|
+ .andExpect(jsonPath("$.data.organizations", hasSize(1)))
|
|
|
+ .andExpect(jsonPath("$.data.organizations[0].organizationCode").value("ORG-GAS-A"))
|
|
|
+ .andExpect(jsonPath("$.data.organizations[0].organizationName").value("燃气公司"))
|
|
|
+ .andExpect(jsonPath("$.data.organizations[0].industry").value("GAS"))
|
|
|
+ .andExpect(jsonPath("$.data.totals.facilityCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.totals.equipmentCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.mapFilter.organizationCodes[0]").value("ORG-GAS-A"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void unmaintainedCodeFilterSelectsOnlyTheUnmaintainedBucket() throws Exception {
|
|
|
+ when(adapter.load()).thenReturn(fixture());
|
|
|
+
|
|
|
+ mockMvc.perform(get(ENDPOINT).queryParam("organizationCode", "UNMAINTAINED"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.data.organizations", hasSize(1)))
|
|
|
+ .andExpect(jsonPath("$.data.organizations[0].organizationCode").value("UNMAINTAINED"))
|
|
|
+ .andExpect(jsonPath("$.data.organizations[0].unmaintained").value(true))
|
|
|
+ .andExpect(jsonPath("$.data.totals.facilityCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.totals.equipmentCount").value(1));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void unknownOrganizationCodeReturnsExplicitEmptyResult() throws Exception {
|
|
|
+ when(adapter.load()).thenReturn(fixture());
|
|
|
+
|
|
|
+ mockMvc.perform(get(ENDPOINT).queryParam("organizationCode", "ORG-MISSING"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.data.organizations", hasSize(0)))
|
|
|
+ .andExpect(jsonPath("$.data.totals.organizationCount").value(0))
|
|
|
+ .andExpect(jsonPath("$.data.totals.facilityCount").value(0))
|
|
|
+ .andExpect(jsonPath("$.data.totals.equipmentCount").value(0))
|
|
|
+ .andExpect(jsonPath("$.data.mapFilter.organizationCodes", hasSize(0)))
|
|
|
+ .andExpect(jsonPath("$.data.freshness.status").value("EMPTY"))
|
|
|
+ .andExpect(jsonPath("$.data.freshness.reason").value("NO_ORGANIZATIONS"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void emptySnapshotReturnsExplicitEmptyState() throws Exception {
|
|
|
+ when(adapter.load()).thenReturn(new CockpitInfrastructureManagementInformationSnapshot(
|
|
|
+ "2026-09-02T09:59:50+08:00", Collections.emptyList()));
|
|
|
+
|
|
|
+ mockMvc.perform(get(ENDPOINT))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.data.organizations", hasSize(0)))
|
|
|
+ .andExpect(jsonPath("$.data.ownershipDistribution", hasSize(0)))
|
|
|
+ .andExpect(jsonPath("$.data.totals.organizationCount").value(0))
|
|
|
+ .andExpect(jsonPath("$.data.freshness.status").value("EMPTY"))
|
|
|
+ .andExpect(jsonPath("$.data.freshness.reason").value("NO_ORGANIZATIONS"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @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", "management-information-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("management-information-500"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void controllerExposesAuthenticatedReadOnlyMethodsOnly() {
|
|
|
+ for (java.lang.reflect.Method method
|
|
|
+ : CockpitInfrastructureManagementInformationController.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(
|
|
|
+ CockpitInfrastructureManagementInformationController.class.getDeclaredMethods())
|
|
|
+ .anyMatch(method -> method.isAnnotationPresent(PreAuthorize.class)
|
|
|
+ && "isAuthenticated()".equals(
|
|
|
+ method.getAnnotation(PreAuthorize.class).value())));
|
|
|
+ }
|
|
|
+
|
|
|
+ private static CockpitInfrastructureManagementInformationSnapshot fixture() {
|
|
|
+ List<CockpitInfrastructureManagementInformationSnapshot.SourceRelation> relations = new ArrayList<>();
|
|
|
+ relations.add(relation("F-WS-001", "FACILITY", "WATER_SUPPLY", "ORG-WS-A", "供水一公司", "OWNED"));
|
|
|
+ relations.add(relation("F-WS-002", "FACILITY", "WATER_SUPPLY", "ORG-WS-A", "供水一公司", "OWNED"));
|
|
|
+ relations.add(relation("E-WS-001", "SMART_DEVICE", "WATER_SUPPLY", "ORG-WS-A", "供水一公司", "LEASED"));
|
|
|
+ relations.add(relation("F-WS-003", "FACILITY", "WATER_SUPPLY", "ORG-WS-B", "供水二公司", "ENTRUSTED"));
|
|
|
+ relations.add(relation("E-WS-002", "SMART_DEVICE", "WATER_SUPPLY", "ORG-WS-B", "供水二公司", null));
|
|
|
+ relations.add(relation("F-GAS-001", "FACILITY", "GAS", "ORG-GAS-A", "燃气公司", "OWNED"));
|
|
|
+ relations.add(relation("E-GAS-001", "SMART_DEVICE", "GAS", "ORG-GAS-A", "燃气公司", "OWNED"));
|
|
|
+ relations.add(relation("F-DR-001", "FACILITY", "DRAINAGE", null, null, "OWNED"));
|
|
|
+ relations.add(relation("E-DR-001", "SMART_DEVICE", "DRAINAGE", " ", null, null));
|
|
|
+ relations.add(relation("F-WS-001", "FACILITY", "WATER_SUPPLY", "ORG-WS-A", "供水一公司", "OWNED"));
|
|
|
+ relations.add(relation("F-MH-001", "FACILITY", "MANHOLE", "ORG-MH-A", "井盖公司", "OWNED"));
|
|
|
+ relations.add(relation("E-XX-001", "SMART_DEVICE", null, "ORG-WS-A", "供水一公司", "OWNED"));
|
|
|
+ relations.add(relation("E-XX-002", "SENSOR", "WATER_SUPPLY", "ORG-WS-A", "供水一公司", "OWNED"));
|
|
|
+ return new CockpitInfrastructureManagementInformationSnapshot(
|
|
|
+ "2026-09-02T09:59:50+08:00", relations);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static CockpitInfrastructureManagementInformationSnapshot.SourceRelation relation(
|
|
|
+ String objectCode,
|
|
|
+ String objectKind,
|
|
|
+ String industry,
|
|
|
+ String organizationCode,
|
|
|
+ String organizationName,
|
|
|
+ String ownershipType) {
|
|
|
+ return new CockpitInfrastructureManagementInformationSnapshot.SourceRelation(
|
|
|
+ objectCode, objectKind, industry, organizationCode, organizationName, ownershipType);
|
|
|
+ }
|
|
|
+}
|