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.ArgumentMatchers.isNull;
|
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.ArgumentCaptor;
|
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("缺少厂商库连接口令:config/vendor-credentials.properties 中没有键 X"))
|
.when(registry).synchronize(any());
|
|
service.synchronizeEnabledAdapters();
|
|
verify(registry).synchronize(any());
|
ArgumentCaptor<String> message = ArgumentCaptor.forClass(String.class);
|
verify(adapterMapper).updateDiscoveryFailure(eq(9L), any(), message.capture());
|
assertTrue(message.getValue().contains("缺少厂商库连接口令"));
|
}
|
|
@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), isNull(), 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), isNull(), 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), isNull(), 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<DeviceAdapter, Object>) invocation.getArgument(1)).apply(deviceAdapter));
|
}
|
}
|