2026-06-30 24681c81c09022f584a57006f2534b5f74723414
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
package cn.iocoder.yudao.module.iot.service.device.property;
 
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDevicePropertyDO;
import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO;
import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.IotThingModelDO;
import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.model.ThingModelProperty;
import cn.iocoder.yudao.module.iot.dal.redis.device.DevicePropertyRedisDAO;
import cn.iocoder.yudao.module.iot.dal.tdengine.IotDevicePropertyMapper;
import cn.iocoder.yudao.module.iot.enums.thingmodel.IotDataSpecsDataTypeEnum;
import cn.iocoder.yudao.module.iot.enums.thingmodel.IotThingModelTypeEnum;
import cn.iocoder.yudao.module.iot.framework.tdengine.core.TDengineTableField;
import cn.iocoder.yudao.module.iot.service.product.IotProductService;
import cn.iocoder.yudao.module.iot.service.thingmodel.IotThingModelService;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
 
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
 
import static java.util.Collections.singletonList;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
 
/**
 * {@link IotDevicePropertyServiceImpl} 的单元测试
 *
 * @author 芋道源码
 */
public class IotDevicePropertyServiceImplTest extends BaseMockitoUnitTest {
 
    @InjectMocks
    private IotDevicePropertyServiceImpl service;
 
    @Mock
    private IotThingModelService thingModelService;
    @Mock
    private IotProductService productService;
    @Mock
    private IotDevicePropertyMapper devicePropertyMapper;
    @Mock
    private DevicePropertyRedisDAO deviceDataRedisDAO;
 
    @Test
    public void testSaveDeviceProperty_identifierCaseInsensitive() {
        // 准备参数:物模型 identifier 是 "LightStatus",设备上报的 key 是 "LIGHTSTATUS"(全大写)
        IotDeviceDO device = buildDevice();
        IotThingModelDO thingModel = buildThingModel("LightStatus", IotDataSpecsDataTypeEnum.INT.getDataType());
        Map<String, Object> params = new HashMap<>();
        params.put("LIGHTSTATUS", 100);
        IotDeviceMessage message = buildMessage(params);
 
        // mock 行为
        when(thingModelService.getThingModelListByProductIdFromCache(device.getProductId()))
                .thenReturn(singletonList(thingModel));
        when(thingModelService.convertThingModelPropertyValue(thingModel, 100)).thenReturn(100);
 
        // 调用
        service.saveDeviceProperty(device, message);
 
        // 断言:properties 落库 / 入缓存时 key 应为物模型 identifier "LightStatus",而不是上报的 "LIGHTSTATUS"
        Map<String, Object> dbProperties = captureMapperInsertProperties();
        assertTrue(dbProperties.containsKey("LightStatus"));
        assertFalse(dbProperties.containsKey("LIGHTSTATUS"));
        assertEquals(100, dbProperties.get("LightStatus"));
 
        Map<String, IotDevicePropertyDO> redisProperties = captureRedisPutAllProperties(device.getId());
        assertTrue(redisProperties.containsKey("LightStatus"));
        assertFalse(redisProperties.containsKey("LIGHTSTATUS"));
    }
 
    @Test
    public void testSaveDeviceProperty_identifierNotInThingModel() {
        // 准备参数:上报的 key 在物模型里完全不存在(连忽略大小写都匹配不到)
        IotDeviceDO device = buildDevice();
        IotThingModelDO thingModel = buildThingModel("LightStatus", IotDataSpecsDataTypeEnum.INT.getDataType());
        Map<String, Object> params = new HashMap<>();
        params.put("UnknownProperty", 1);
        IotDeviceMessage message = buildMessage(params);
 
        // mock 行为
        when(thingModelService.getThingModelListByProductIdFromCache(device.getProductId()))
                .thenReturn(singletonList(thingModel));
 
        // 调用
        service.saveDeviceProperty(device, message);
 
        // 断言:没有合法属性,不会写入 TDengine 与 Redis
        verify(devicePropertyMapper, never()).insert(any(), any(), anyLong(), anyLong());
        verify(deviceDataRedisDAO, never()).putAll(anyLong(), any());
    }
 
    @Test
    public void testSaveDeviceProperty_convertValueFailed() {
        // 准备参数:物模型存在,但是属性值无法按物模型转换
        IotDeviceDO device = buildDevice();
        IotThingModelDO temperature = buildThingModel("Temperature", IotDataSpecsDataTypeEnum.INT.getDataType());
        Map<String, Object> params = new HashMap<>();
        params.put("Temperature", "abc");
        IotDeviceMessage message = buildMessage(params);
 
        // mock 方法
        when(thingModelService.getThingModelListByProductIdFromCache(device.getProductId()))
                .thenReturn(singletonList(temperature));
        when(thingModelService.convertThingModelPropertyValue(temperature, "abc")).thenReturn(null);
 
        // 调用,并断言:不会抛出异常
        assertDoesNotThrow(() -> service.saveDeviceProperty(device, message));
 
        // 断言:没有合法属性,不会写入 TDengine 与 Redis
        verify(devicePropertyMapper, never()).insert(any(), any(), anyLong(), anyLong());
        verify(deviceDataRedisDAO, never()).putAll(anyLong(), any());
    }
 
    @Test
    public void testSaveDeviceProperty_skipNullValue() {
        // 准备参数:属性值为空,不能写入 TDengine 与 Redis
        IotDeviceDO device = buildDevice();
        IotThingModelDO thingModel = buildThingModel("Temperature", IotDataSpecsDataTypeEnum.INT.getDataType());
        Map<String, Object> params = new HashMap<>();
        params.put("Temperature", null);
        IotDeviceMessage message = buildMessage(params);
 
        // mock 方法
        when(thingModelService.getThingModelListByProductIdFromCache(device.getProductId()))
                .thenReturn(singletonList(thingModel));
 
        // 调用,并断言:不会抛出异常
        assertDoesNotThrow(() -> service.saveDeviceProperty(device, message));
 
        // 断言:跳过空值,不会转换属性值,也不会写入 TDengine 与 Redis
        verify(thingModelService, never()).convertThingModelPropertyValue(any(), any());
        verify(devicePropertyMapper, never()).insert(any(), any(), anyLong(), anyLong());
        verify(deviceDataRedisDAO, never()).putAll(anyLong(), any());
    }
 
    @Test
    public void testSaveDeviceProperty_skipInvalidKeyType() {
        // 准备参数:Map 中包含非字符串 key,不能因为强转失败影响其它合法属性
        IotDeviceDO device = buildDevice();
        IotThingModelDO thingModel = buildThingModel("PowerSwitch", IotDataSpecsDataTypeEnum.BOOL.getDataType());
        Map<Object, Object> params = new HashMap<>();
        params.put(123, 1);
        params.put("PowerSwitch", true);
        IotDeviceMessage message = buildMessage(params);
 
        // mock 方法
        when(thingModelService.getThingModelListByProductIdFromCache(device.getProductId()))
                .thenReturn(singletonList(thingModel));
        when(thingModelService.convertThingModelPropertyValue(thingModel, true)).thenReturn((byte) 1);
 
        // 调用,并断言:非字符串 key 不影响其它合法属性
        assertDoesNotThrow(() -> service.saveDeviceProperty(device, message));
 
        // 断言:只写入合法属性
        Map<String, Object> dbProperties = captureMapperInsertProperties();
        assertEquals(1, dbProperties.size());
        assertEquals((byte) 1, dbProperties.get("PowerSwitch"));
    }
 
    @Test
    public void testDefineDevicePropertyData_fieldNameToLowerCase() {
        // 准备参数:全大写缩写和驼峰缩写都需要转换为 TDengine 实际的小写字段名
        Long productId = 2L;
        IotProductDO product = IotProductDO.builder().id(productId).build();
        List<IotThingModelDO> thingModels = Arrays.asList(
                buildThingModel("Ua", IotDataSpecsDataTypeEnum.FLOAT.getDataType()),
                buildThingModel("PfT", IotDataSpecsDataTypeEnum.FLOAT.getDataType()),
                buildThingModel("PT", IotDataSpecsDataTypeEnum.FLOAT.getDataType()),
                buildThingModel("PA", IotDataSpecsDataTypeEnum.FLOAT.getDataType()));
        thingModels.forEach(thingModel -> thingModel.setType(IotThingModelTypeEnum.PROPERTY.getType()));
 
        // mock 方法
        when(productService.validateProductExists(productId)).thenReturn(product);
        when(thingModelService.getThingModelListByProductId(productId)).thenReturn(thingModels);
        when(devicePropertyMapper.getProductPropertySTableFieldList(productId)).thenReturn(Collections.emptyList());
 
        // 调用
        service.defineDevicePropertyData(productId);
 
        // 断言:字段名统一为小写下划线,避免 PT 和数据库中的 pt 被误判为不同字段
        ArgumentCaptor<List<TDengineTableField>> captor = ArgumentCaptor.forClass(List.class);
        verify(devicePropertyMapper).createProductPropertySTable(eq(productId), captor.capture());
        assertEquals(Arrays.asList("ua", "pf_t", "pt", "pa"), captor.getValue().stream()
                .map(TDengineTableField::getField)
                .collect(Collectors.toList()));
    }
 
    // ========== 辅助方法 ==========
 
    /**
     * 构造一个最简 IotDeviceDO,只设置测试需要的 id 与 productId
     */
    private IotDeviceDO buildDevice() {
        return IotDeviceDO.builder().id(1L).productId(2L).build();
    }
 
    /**
     * 构造物模型;只填 saveDeviceProperty 链路用到的 identifier + property.dataType
     */
    private IotThingModelDO buildThingModel(String identifier, String dataType) {
        ThingModelProperty property = new ThingModelProperty();
        property.setIdentifier(identifier);
        property.setDataType(dataType);
        return IotThingModelDO.builder().identifier(identifier).property(property).build();
    }
 
    /**
     * 构造一条属性上报消息
     */
    private IotDeviceMessage buildMessage(Map<?, ?> params) {
        IotDeviceMessage message = new IotDeviceMessage();
        message.setMethod(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod());
        message.setParams(params);
        message.setReportTime(LocalDateTime.now());
        return message;
    }
 
    /**
     * 抓取 mapper.insert 的 properties 入参
     */
    @SuppressWarnings("unchecked")
    private Map<String, Object> captureMapperInsertProperties() {
        ArgumentCaptor<Map<String, Object>> captor = ArgumentCaptor.forClass(Map.class);
        verify(devicePropertyMapper).insert(any(IotDeviceDO.class), captor.capture(), anyLong(), anyLong());
        return captor.getValue();
    }
 
    /**
     * 抓取 redisDAO.putAll 的 properties 入参
     */
    @SuppressWarnings("unchecked")
    private Map<String, IotDevicePropertyDO> captureRedisPutAllProperties(Long deviceId) {
        ArgumentCaptor<Map<String, IotDevicePropertyDO>> captor = ArgumentCaptor.forClass(Map.class);
        verify(deviceDataRedisDAO).putAll(eq(deviceId), captor.capture());
        return captor.getValue();
    }
 
}