package com.battery.business.service.impl; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.times; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.InputStream; import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Set; import java.util.function.Function; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.annotation.AnnotationConfigApplicationContext; import com.ruoyi.common.exception.ServiceException; import com.battery.device.api.DeviceAdapter; import com.battery.device.api.DeviceAdapterException; import com.battery.device.api.discovery.InMemoryDeviceDiscoveryCandidateStore; import com.battery.device.api.discovery.DeviceDiscoveryCandidateStore; import com.battery.device.api.model.ChannelIdentity; import com.battery.device.api.model.DetectedChannel; import com.battery.device.api.model.DetectedDeviceMetadata; import com.battery.device.api.model.DeviceWorkStatus; import com.battery.device.api.model.RealtimeSnapshot; import com.battery.device.api.registry.DeviceAdapterRegistry; import com.battery.business.domain.TDetectionCabinet; import com.battery.business.domain.TDetectionChannel; import com.battery.business.domain.TDeviceAdapter; import com.battery.business.mapper.TDeviceAdapterMapper; import com.battery.business.mapper.TDetectionCabinetMapper; import com.battery.business.service.DeviceAdapterErrorCodes; @ExtendWith(MockitoExtension.class) class DeviceAdapterServiceImplTest { @Mock private TDeviceAdapterMapper adapterMapper; @Mock private DeviceAdapterRegistry registry; @Mock private DeviceAdapter deviceAdapter; @Mock private TDetectionCabinetMapper detectionCabinetMapper; private DeviceAdapterServiceImpl service; private TDeviceAdapter adapter; private InMemoryDeviceDiscoveryCandidateStore candidateStore; @BeforeEach void setUp() { candidateStore = new InMemoryDeviceDiscoveryCandidateStore(); service = new DeviceAdapterServiceImpl(adapterMapper, registry, new DeviceDiscoveryPersistenceService(candidateStore, detectionCabinetMapper), candidateStore); adapter = new TDeviceAdapter(); adapter.setAdapterId(7L); adapter.setAdapterCode("lab-bts"); adapter.setAdapterName("一号 BTS"); adapter.setAdapterType("BTS"); adapter.setHost("127.0.0.1"); adapter.setPort(502); adapter.setPollIntervalMs(5000); adapter.setTimezoneId("Asia/Hong_Kong"); adapter.setReadOnly(true); adapter.setCapabilities("{\"discovery\":true,\"realtimeObservation\":true,\"historyDownload\":true,\"control\":false}"); adapter.setStatus("0"); } @Test void enablingAdapterSynchronizesRuntimeWithoutConstructingVendorClient() { adapter.setStatus("1"); when(adapterMapper.selectDeviceAdapterById(7L)).thenReturn(adapter); when(adapterMapper.updateDeviceAdapterStatus(7L, "0", "admin")).thenReturn(1); int rows = service.changeStatus(7L, true, "admin"); assertEquals(1, rows); verify(registry).synchronize(any()); } /** 装配失败(例如缺少口令环境变量)时不能把状态改成"已启用",免得留下库里启用、运行时没实例的假状态。 */ @Test void failedEnableKeepsAdapterDisabledAndSurfacesTheReason() { adapter.setStatus("1"); when(adapterMapper.selectDeviceAdapterById(7L)).thenReturn(adapter); org.mockito.Mockito.doThrow(new DeviceAdapterException("缺少厂商库连接口令:请设置环境变量 X")) .when(registry).synchronize(any()); DeviceAdapterException exception = assertThrows(DeviceAdapterException.class, () -> service.changeStatus(7L, true, "admin")); assertTrue(exception.getMessage().contains("缺少厂商库连接口令")); verify(adapterMapper, never()).updateDeviceAdapterStatus(any(), any(), any()); } /** 单行配置有问题不得拦住后端启动:记下原因,由发现轮询按周期重试并持久化失败。 */ @Test void startupRestoreSurvivesOneBrokenAdapterRow() { TDeviceAdapter vendor = new TDeviceAdapter(); vendor.setAdapterId(9L); vendor.setAdapterCode("neware76"); vendor.setAdapterName("新威 7.6 那台"); vendor.setAdapterType("NEWARE_MYSQL"); vendor.setHost("26.70.208.249"); vendor.setPort(3306); vendor.setPollIntervalMs(60000); vendor.setTimezoneId("Asia/Hong_Kong"); vendor.setStatus("0"); vendor.setCapabilities("{\"discovery\":true,\"realtimeObservation\":true,\"historyDownload\":true,\"control\":false}"); when(adapterMapper.selectEnabledDeviceAdapters()).thenReturn(List.of(vendor)); org.mockito.Mockito.doThrow(new DeviceAdapterException("缺少厂商库连接口令:请设置环境变量 X")) .when(registry).synchronize(any()); service.synchronizeEnabledAdapters(); verify(registry).synchronize(any()); } @Test void springSelectsProductionConstructor() { try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { context.registerBean(TDeviceAdapterMapper.class, () -> org.mockito.Mockito.mock(TDeviceAdapterMapper.class)); context.registerBean(DeviceAdapterRegistry.class, () -> org.mockito.Mockito.mock(DeviceAdapterRegistry.class)); context.registerBean(DeviceDiscoveryPersistenceService.class, () -> org.mockito.Mockito.mock(DeviceDiscoveryPersistenceService.class)); context.registerBean(DeviceDiscoveryCandidateStore.class, () -> org.mockito.Mockito.mock(DeviceDiscoveryCandidateStore.class)); context.register(DeviceAdapterServiceImpl.class); context.refresh(); assertNotNull(context.getBean(DeviceAdapterServiceImpl.class)); } } @Test void repeatedDiscoveryUpsertsStableChannelIdentityWithoutDeletingMissingChannels() { when(adapterMapper.selectDeviceAdapterById(7L)).thenReturn(adapter); when(deviceAdapter.discoverChannels()).thenReturn(List.of( new DetectedChannel(new ChannelIdentity("lab-bts", 22, 1, 2, 3), "192.168.1.35", true), new DetectedChannel(new ChannelIdentity("lab-bts", 22, 1, 2, 4), "192.168.1.35", false))); stubRegistryExecution(); assertEquals(2, service.discover(7L).channelCount()); assertEquals(2, service.discover(7L).channelCount()); var snapshot = candidateStore.snapshot(Instant.now()); assertEquals(1, snapshot.cabinets().size()); assertEquals(2, snapshot.cabinets().get(0).channels().size()); verify(adapterMapper, times(2)).updateDiscoverySuccess(eq(7L), any()); } @Test void discoveryPersistsOptionalDeviceMetadataFromAdapterBoundary() { ChannelIdentity identity = new ChannelIdentity("lab-bts", 22, 8, 1, 1); when(adapterMapper.selectDeviceAdapterById(7L)).thenReturn(adapter); when(deviceAdapter.discoverChannels()).thenReturn(List.of(new DetectedChannel(identity, "127.0.0.8", true, new DetectedDeviceMetadata("开发设备 8", "开发模拟器", "BTS Virtual Cabinet", "02:44:00:16:00:08")))); stubRegistryExecution(); service.discover(7L); var cabinet = candidateStore.snapshot(Instant.now()).cabinets().get(0); assertEquals("开发设备 8", cabinet.name()); assertEquals("开发模拟器", cabinet.manufacturer()); assertEquals("BTS Virtual Cabinet", cabinet.model()); assertEquals("02:44:00:16:00:08", cabinet.mac()); } @Test void discoveryPersistsRealtimeWorkStatusForAvailableChannels() { ChannelIdentity identity = new ChannelIdentity("lab-bts", 22, 1, 2, 3); when(adapterMapper.selectDeviceAdapterById(7L)).thenReturn(adapter); when(deviceAdapter.discoverChannels()).thenReturn(List.of( new DetectedChannel(identity, "192.168.1.35", true))); when(deviceAdapter.inquire(Set.of(identity))).thenReturn(Map.of(identity, new RealtimeSnapshot(identity, DeviceWorkStatus.WORKING, 1, 2, "cc", null, BigDecimal.ONE, BigDecimal.TEN, null, null, 3L, 2L, null, Instant.parse("2026-08-26T01:02:03Z"), Map.of()))); stubRegistryExecution(); service.discover(7L); var channel = candidateStore.snapshot(Instant.now()).cabinets().get(0).channels().get(0); assertEquals(DeviceWorkStatus.WORKING, channel.workStatus()); verify(detectionCabinetMapper).updateRegisteredChannelObservation(eq("lab-bts"), eq(22), eq(1), eq(2), eq(3), eq("ONLINE"), eq("WORKING"), any(), eq(true)); verify(detectionCabinetMapper).updateRegisteredCabinetObservation(eq("lab-bts"), eq(22), eq(1), eq("ONLINE"), any(), eq(true)); } @Test void unavailableDevicePersistsExplicitOfflineFactsWithoutRealtimeInquiry() { when(adapterMapper.selectDeviceAdapterById(7L)).thenReturn(adapter); when(deviceAdapter.discoverChannels()).thenReturn(List.of( new DetectedChannel(new ChannelIdentity("lab-bts", 22, 1, 1, 1), "127.0.0.1", false), new DetectedChannel(new ChannelIdentity("lab-bts", 22, 1, 1, 2), "127.0.0.1", false))); stubRegistryExecution(); service.discover(7L); var cabinet = candidateStore.snapshot(Instant.now()).cabinets().get(0); verify(deviceAdapter, never()).inquire(any()); assertEquals(false, cabinet.online()); assertTrue(cabinet.channels().stream().noneMatch(channel -> channel.online())); verify(detectionCabinetMapper).updateRegisteredChannelObservation(eq("lab-bts"), eq(22), eq(1), eq(1), eq(1), eq("OFFLINE"), eq("UNKNOWN"), any(), eq(false)); verify(detectionCabinetMapper).updateRegisteredCabinetObservation(eq("lab-bts"), eq(22), eq(1), eq("OFFLINE"), any(), eq(false)); } @Test void availableChannelWithoutSuccessfulSnapshotCannotAdvanceLastSeenBoundary() { ChannelIdentity identity = new ChannelIdentity("lab-bts", 22, 1, 1, 1); when(adapterMapper.selectDeviceAdapterById(7L)).thenReturn(adapter); when(deviceAdapter.discoverChannels()).thenReturn(List.of(new DetectedChannel(identity, "127.0.0.1", true))); when(deviceAdapter.inquire(Set.of(identity))).thenReturn(Map.of()); stubRegistryExecution(); service.discover(7L); verify(detectionCabinetMapper).updateRegisteredChannelObservation(eq("lab-bts"), eq(22), eq(1), eq(1), eq(1), eq("ONLINE"), eq("UNKNOWN"), any(), eq(false)); verify(detectionCabinetMapper).updateRegisteredCabinetObservation(eq("lab-bts"), eq(22), eq(1), eq("ONLINE"), any(), eq(false)); } @Test void connectionFailureHasStableCodeAndLaterDiscoveryClearsError() { when(adapterMapper.selectDeviceAdapterById(7L)).thenReturn(adapter); when(deviceAdapter.discoverChannels()) .thenThrow(new DeviceAdapterException("CONNECTION_FAILED", "connection refused", null)) .thenReturn(List.of()); stubRegistryExecution(); ServiceException failure = assertThrows(ServiceException.class, () -> service.discover(7L)); assertEquals(DeviceAdapterErrorCodes.DISCOVERY_FAILED, failure.getCode()); verify(adapterMapper).updateDiscoveryFailure(7L, "CONNECTION_FAILED", "connection refused"); assertEquals(0, service.discover(7L).channelCount()); verify(adapterMapper).updateDiscoverySuccess(eq(7L), any()); } @Test void ensuresManagedAdapterFromApplicationProvidedConfiguration() { when(adapterMapper.selectDeviceAdapterByCode("lab-bts")).thenReturn(adapter); TDeviceAdapter registered = service.ensureManagedAdapter(adapter); verify(adapterMapper).upsertManagedDeviceAdapter(adapter); assertEquals(adapter, registered); } @Test void rejectsReservedDevelopmentSimulatorCodeFromDatabaseConfiguration() { adapter.setAdapterCode("dev-sim"); when(registry.contains("dev-sim")).thenReturn(true); assertThrows(ServiceException.class, () -> service.insert(adapter)); assertThrows(ServiceException.class, () -> service.ensureManagedAdapter(adapter)); verify(adapterMapper, never()).insertDeviceAdapter(any()); verify(adapterMapper, never()).upsertManagedDeviceAdapter(any()); } @Test void developmentSimulatorProvisioningIsIdempotentAndReadOnly() { String mapperXml; try (InputStream input = getClass().getResourceAsStream("/mapper/battery/TDeviceAdapterMapper.xml")) { if (input == null) throw new AssertionError("缺少设备适配器 Mapper"); mapperXml = new String(input.readAllBytes(), StandardCharsets.UTF_8); } catch (Exception exception) { throw new AssertionError("无法读取设备适配器 Mapper", exception); } assertTrue(mapperXml.contains("upsertManagedDeviceAdapter")); assertTrue(mapperXml.contains("#{adapterCode}")); assertTrue(mapperXml.contains("#{capabilities}")); assertTrue(mapperXml.contains("on duplicate key update")); assertTrue(mapperXml.contains("del_flag=0")); } @SuppressWarnings("unchecked") private void stubRegistryExecution() { when(registry.execute(eq("lab-bts"), any())).thenAnswer(invocation -> ((Function) invocation.getArgument(1)).apply(deviceAdapter)); } }