fix(app): 修复 AppController 安全与查询问题
- 加强异常处理、类型安全和图片上传校验 - 优化设备相关查询,避免重复访问数据源 - 补充并记录并发测试与审查修复实施计划
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,17 @@
|
||||
package org.dromara.app.domain.vo;
|
||||
|
||||
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import org.dromara.app.domain.AppSchedule;
|
||||
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
|
||||
import cn.idev.excel.annotation.ExcelProperty;
|
||||
import io.github.linpeilie.annotations.AutoMapper;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
import org.dromara.app.domain.AppSchedule;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/**
|
||||
@@ -47,7 +49,7 @@ public class AppScheduleVo implements Serializable {
|
||||
/**
|
||||
* 设备ids
|
||||
*/
|
||||
private List deviceNos;
|
||||
private List<AppDeviceVo> deviceNos;
|
||||
/**
|
||||
* 状态 0-关闭 1 开启
|
||||
*/
|
||||
@@ -55,7 +57,7 @@ public class AppScheduleVo implements Serializable {
|
||||
private String status;
|
||||
|
||||
@NotEmpty(message = "至少需要配置一天")
|
||||
private List<AppScheduleDetailVo> details;
|
||||
private List<Map<String, Object>> details;
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 设备数据上报处理器 — 匹配 /{deviceNo}/publish/power
|
||||
* 设备电量及在线心跳处理器,匹配 /{deviceNo}/publish/power。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@@ -23,6 +23,7 @@ public class DeviceDataHandler implements MqttTopicHandler {
|
||||
private static final Pattern PATTERN = Pattern.compile("^/([^/]+)/publish/power$");
|
||||
private final IAppDeviceService appDeviceService;
|
||||
private final DeviceIdentityResolver deviceIdentityResolver;
|
||||
private final MqttDeviceStatusService deviceStatusService;
|
||||
|
||||
@Override
|
||||
public Pattern topicPattern() {
|
||||
@@ -44,13 +45,15 @@ public class DeviceDataHandler implements MqttTopicHandler {
|
||||
log.warn("[MQTT] 设备电量上报缺少电量字段 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload);
|
||||
return;
|
||||
}
|
||||
// 更新设备电量 + 同步在线状态到数据库
|
||||
// 有效电量上报同时作为设备在线心跳。
|
||||
AppDeviceBo appDeviceBo = new AppDeviceBo();
|
||||
appDeviceBo.setDeviceNo(deviceNo);
|
||||
appDeviceBo.setPowerLevel(dto.get("powerLevel").toString());
|
||||
appDeviceBo.setPowerLevelUpdatatime(new Date());
|
||||
appDeviceService.updateByBo(appDeviceBo);
|
||||
log.info("[MQTT] 设备电量更新 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload);
|
||||
deviceStatusService.markOnline(deviceNo);
|
||||
log.info("[MQTT] 设备电量更新并刷新在线心跳 时间={} 设备编号={} 消息体={}",
|
||||
HandlerLogTime.now(), deviceNo, payload);
|
||||
} catch (Exception e) {
|
||||
log.error("[MQTT] 设备电量更新失败 时间={} 设备标识={} 设备编号={} 消息体={}",
|
||||
HandlerLogTime.now(), deviceIdentity, deviceNo, payload, e);
|
||||
|
||||
@@ -33,6 +33,7 @@ DeviceRegisterHandler implements MqttTopicHandler {
|
||||
private final AppDeviceMapper appDeviceMapper;
|
||||
private final ObjectProvider<IDeviceCommandPublisher> commandPublisherProvider;
|
||||
private final DeviceIdentityResolver deviceIdentityResolver;
|
||||
private final MqttDeviceStatusService deviceStatusService;
|
||||
|
||||
@Override
|
||||
public Pattern topicPattern() {
|
||||
@@ -60,8 +61,9 @@ DeviceRegisterHandler implements MqttTopicHandler {
|
||||
}
|
||||
AppDeviceBo device = buildRegisterDevice(deviceNo, normalizedDeviceMac, dto);
|
||||
appDeviceService.registerByMqtt(device);
|
||||
deviceStatusService.markOnline(deviceNo);
|
||||
sendDeviceNoToDevice(deviceNo, normalizedDeviceMac);
|
||||
log.info("[MQTT] 设备注册 时间={} MAC={} 设备编号={} 消息体={}",
|
||||
log.info("[MQTT] 设备注册并上线 时间={} MAC={} 设备编号={} 消息体={}",
|
||||
HandlerLogTime.now(), normalizedDeviceMac, deviceNo, payload);
|
||||
} catch (Exception e) {
|
||||
log.error("[MQTT] 设备注册失败 时间={} 设备标识={} 消息体={}", HandlerLogTime.now(), deviceIdentity, payload, e);
|
||||
|
||||
@@ -13,10 +13,10 @@ import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 设备在线/离线状态处理器,匹配 /{deviceIdentity}/publish/status。
|
||||
* 设备遗嘱离线状态处理器,匹配 /{deviceIdentity}/publish/status。
|
||||
* <p>
|
||||
* deviceIdentity 支持设备编号;设备遗嘱消息允许使用 MAC 地址,处理前统一解析为设备编号。
|
||||
* 设备通过 status=online 标记上线,通过 LWT status=offline 标记异常离线。
|
||||
* 在线状态由电量上报刷新;本处理器仅通过 LWT status=offline 标记异常离线。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@@ -42,17 +42,18 @@ public class DeviceStatusHandler implements MqttTopicHandler {
|
||||
|
||||
@Override
|
||||
public void handle(String deviceIdentity, String payload, boolean retained) {
|
||||
String deviceNo = deviceIdentityResolver.resolveDeviceNo(deviceIdentity);
|
||||
Map<String, Object> body = parsePayload(payload);
|
||||
String deviceNo = resolveDeviceNo(deviceIdentity, body);
|
||||
if (deviceNo == null) {
|
||||
log.warn("[MQTT] 设备状态上报未找到设备 时间={} 设备标识={} 消息体={}",
|
||||
HandlerLogTime.now(), deviceIdentity, payload);
|
||||
return;
|
||||
}
|
||||
|
||||
String status = parseStatus(payload);
|
||||
String status = parseStatus(payload, body);
|
||||
if ("online".equals(status) || "1".equals(status)) {
|
||||
deviceStatusService.markOnline(deviceNo);
|
||||
log.info("[MQTT] 设备状态在线 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload);
|
||||
log.debug("[MQTT] 忽略设备主动在线状态,在线状态由电量心跳维护 时间={} 设备编号={} 消息体={}",
|
||||
HandlerLogTime.now(), deviceNo, payload);
|
||||
return;
|
||||
}
|
||||
if ("offline".equals(status) || "0".equals(status)) {
|
||||
@@ -69,22 +70,52 @@ public class DeviceStatusHandler implements MqttTopicHandler {
|
||||
HandlerLogTime.now(), deviceNo, payload);
|
||||
}
|
||||
|
||||
private String parseStatus(String payload) {
|
||||
private String resolveDeviceNo(String deviceIdentity, Map<String, Object> body) {
|
||||
String deviceNo = deviceIdentityResolver.resolveDeviceNo(deviceIdentity);
|
||||
if (deviceNo != null || body == null) {
|
||||
return deviceNo;
|
||||
}
|
||||
Object deviceMac = body.get("deviceMac");
|
||||
if (deviceMac == null || StringUtils.isBlank(String.valueOf(deviceMac))) {
|
||||
return null;
|
||||
}
|
||||
return deviceIdentityResolver.resolveDeviceNo(String.valueOf(deviceMac));
|
||||
}
|
||||
|
||||
private Map<String, Object> parsePayload(String payload) {
|
||||
if (StringUtils.isBlank(payload)) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = payload.trim();
|
||||
if (!trimmed.startsWith("{")) {
|
||||
return trimmed.toLowerCase(Locale.ROOT);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Map<String, Object> body = objectMapper.readValue(trimmed, new TypeReference<Map<String, Object>>() {
|
||||
return objectMapper.readValue(trimmed, new TypeReference<Map<String, Object>>() {
|
||||
});
|
||||
Object status = body.get("status");
|
||||
return status == null ? null : String.valueOf(status).trim().toLowerCase(Locale.ROOT);
|
||||
} catch (Exception e) {
|
||||
log.warn("[MQTT] 设备状态上报 JSON 格式错误 时间={} 消息体={}", HandlerLogTime.now(), payload, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String parseStatus(String payload, Map<String, Object> body) {
|
||||
if (body == null) {
|
||||
if (StringUtils.isBlank(payload)) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = payload.trim();
|
||||
return trimmed.startsWith("{") ? null : trimmed.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
Object status = body.get("status");
|
||||
if (status != null) {
|
||||
return String.valueOf(status).trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
Object offline = body.get("offline");
|
||||
if (offline != null && "true".equalsIgnoreCase(String.valueOf(offline).trim())) {
|
||||
return "offline";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ public class MqttDeviceStatusService {
|
||||
@Value("${mqtt.command-ack.device-status-cache-prefix}")
|
||||
private String deviceStatusCachePrefix;
|
||||
|
||||
@Value("${mqtt.command-ack.device-status-cache-ttl-seconds:900}")
|
||||
@Value("${mqtt.command-ack.device-status-cache-ttl-seconds:600}")
|
||||
private long deviceStatusCacheTtlSeconds;
|
||||
|
||||
public void markOnline(String deviceNo) {
|
||||
|
||||
@@ -29,6 +29,14 @@ public interface IAppDeviceService {
|
||||
*/
|
||||
AppDeviceVo queryById(String deviceNo);
|
||||
|
||||
/**
|
||||
* 按设备编号批量查询设备。
|
||||
*
|
||||
* @param deviceNos 设备编号集合
|
||||
* @return 设备列表
|
||||
*/
|
||||
List<AppDeviceVo> queryByDeviceNos(Collection<String> deviceNos);
|
||||
|
||||
/**
|
||||
* 分页查询设备信息
|
||||
列表
|
||||
|
||||
@@ -8,6 +8,7 @@ import org.dromara.common.mybatis.core.page.TableDataInfo;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public interface IAppSchedulingDeviceService {
|
||||
|
||||
@@ -25,6 +26,14 @@ public interface IAppSchedulingDeviceService {
|
||||
|
||||
List<AppSchedulingDeviceVo> findByDeviceNo(String deviceNo);
|
||||
|
||||
/**
|
||||
* 批量查询已绑定排程的设备编号。
|
||||
*
|
||||
* @param deviceNos 待检查的设备编号
|
||||
* @return 已绑定排程的设备编号
|
||||
*/
|
||||
Set<String> findBoundDeviceNos(Collection<String> deviceNos);
|
||||
|
||||
Boolean deleteWithValidByScheduleIdAndDeviceNo(@NotEmpty(message = "主键不能为空") Long scheduleId, String deviceNo);
|
||||
|
||||
AppSchedulingDeviceVo queryByScheduleIdAndDeviceNo(Long scheduleId, String deviceNo);
|
||||
|
||||
@@ -73,6 +73,14 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
|
||||
return baseMapper.selectVoById(deviceNo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AppDeviceVo> queryByDeviceNos(Collection<String> deviceNos) {
|
||||
if (deviceNos == null || deviceNos.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return baseMapper.selectVoByIds(deviceNos);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TableDataInfo<AppDeviceVo> queryPageList(AppDeviceBo bo, PageQuery pageQuery) {
|
||||
Page<AppDeviceVo> page = pageQuery.build();
|
||||
@@ -307,7 +315,7 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
|
||||
return params;
|
||||
}
|
||||
params.put("bindDevice", exists);
|
||||
if (exists.getUserId() == null && exists.getWorkStatus().equals("2") && exists.getStatus().equals("0") ) {
|
||||
if (exists.getUserId() == null && "2".equals(exists.getWorkStatus()) && "0".equals(exists.getStatus())) {
|
||||
params.put("bindDeviceStatus", 305);
|
||||
params.put("bindDeviceStatusName", "设备未配网,请先去配置网络");
|
||||
return params;
|
||||
|
||||
@@ -165,10 +165,10 @@ public class AppScheduleServiceImpl implements IAppScheduleService {
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
private List saveDetails(Long scheduleId, List<AppScheduleBo.AppScheduleDetail> details) {
|
||||
List list = new ArrayList<>();
|
||||
private List<Map<String, Object>> saveDetails(Long scheduleId, List<AppScheduleBo.AppScheduleDetail> details) {
|
||||
List<Map<String, Object>> detailList = new ArrayList<>();
|
||||
if (details == null) {
|
||||
return list;
|
||||
return detailList;
|
||||
}
|
||||
for (AppScheduleBo.AppScheduleDetail item : details) {
|
||||
AppScheduleDetail detail = new AppScheduleDetail();
|
||||
@@ -181,13 +181,13 @@ public class AppScheduleServiceImpl implements IAppScheduleService {
|
||||
detail.setTriggerType(item.getTriggerType());
|
||||
detail.setStatus(item.getStatus());
|
||||
scheduleDetailMapper.insert(detail);
|
||||
list.add(toDetailMap(detail, item.getTimeData()));
|
||||
detailList.add(toDetailMap(detail, item.getTimeData()));
|
||||
}
|
||||
return list;
|
||||
return detailList;
|
||||
}
|
||||
|
||||
private List updateDetails(List<AppScheduleBo.AppScheduleDetail> details) {
|
||||
List detailList = new ArrayList<>();
|
||||
private List<Map<String, Object>> updateDetails(List<AppScheduleBo.AppScheduleDetail> details) {
|
||||
List<Map<String, Object>> detailList = new ArrayList<>();
|
||||
if (details == null) {
|
||||
return detailList;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ import org.springframework.stereotype.Service;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@@ -106,4 +108,19 @@ public class AppSchedulingDeviceServiceImpl implements IAppSchedulingDeviceServi
|
||||
public List<AppSchedulingDeviceVo> findByDeviceNo(String deviceNo) {
|
||||
return baseMapper.selectVoList(new QueryWrapper<AppSchedulingDevice>().eq("device_no", deviceNo));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> findBoundDeviceNos(Collection<String> deviceNos) {
|
||||
if (deviceNos == null || deviceNos.isEmpty()) {
|
||||
return Set.of();
|
||||
}
|
||||
return baseMapper.selectList(
|
||||
new QueryWrapper<AppSchedulingDevice>()
|
||||
.select("device_no")
|
||||
.in("device_no", deviceNos))
|
||||
.stream()
|
||||
.map(AppSchedulingDevice::getDeviceNo)
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ import java.util.List;
|
||||
/**
|
||||
* 设备离线检测定时任务
|
||||
* <p>
|
||||
* 逻辑:默认通过设备 LWT 离线消息更新状态。
|
||||
* 兼容旧设备时,可打开 ttl-compat-enabled,继续使用 Redis 在线 Key 过期兜底离线。
|
||||
* 逻辑:电量上报刷新 Redis 在线 Key,Key 超时后判定设备离线。
|
||||
* 设备 LWT 离线消息仍可立即更新离线状态,本任务用于处理未收到遗嘱的异常断线。
|
||||
* 本任务定期扫描数据库中非离线状态的设备,如果对应 Redis Key 已不存在,
|
||||
* 则将数据库 status 更新为 0(离线)。
|
||||
* </p>
|
||||
@@ -37,19 +37,19 @@ public class DeviceOfflineCheckTask {
|
||||
@Value("${mqtt.command-ack.device-status-cache-prefix}")
|
||||
private String deviceStatusCachePrefix;
|
||||
|
||||
@Value("${mqtt.command-ack.device-status-cache-ttl-seconds:900}")
|
||||
@Value("${mqtt.command-ack.device-status-cache-ttl-seconds:600}")
|
||||
private long deviceStatusCacheTtlSeconds;
|
||||
|
||||
@Value("${mqtt.offline-check.enabled:true}")
|
||||
private boolean enabled;
|
||||
@Value("${mqtt.offline-check.ttl-compat-enabled:false}")
|
||||
@Value("${mqtt.offline-check.ttl-compat-enabled:true}")
|
||||
private boolean ttlCompatEnabled;
|
||||
|
||||
/**
|
||||
* 每 90 秒检查一次。默认不开启 TTL 兼容离线检测,避免低功耗长连接设备被误判离线。
|
||||
* 每 30 秒检查一次电量心跳是否超时。
|
||||
* 可通过 mqtt.offline-check.interval-ms 覆盖(单位 ms)
|
||||
*/
|
||||
@Scheduled(fixedDelayString = "${mqtt.offline-check.interval-ms:90000}")
|
||||
@Scheduled(fixedDelayString = "${mqtt.offline-check.interval-ms:30000}")
|
||||
public void checkOfflineDevices() {
|
||||
if (!enabled || !ttlCompatEnabled) {
|
||||
return;
|
||||
@@ -96,7 +96,7 @@ public class DeviceOfflineCheckTask {
|
||||
|
||||
List<String> updatedDeviceNos = new ArrayList<>();
|
||||
for (String deviceNo : offlineDeviceNos) {
|
||||
if (deviceStatusService.markOfflineIfCacheMissing(deviceNo, "设备掉线")) {
|
||||
if (deviceStatusService.markOfflineIfCacheMissing(deviceNo, "设备电量心跳超时")) {
|
||||
updatedDeviceNos.add(deviceNo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
package org.dromara.app.controller;
|
||||
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.dromara.app.domain.bo.AppScheduleBo;
|
||||
import org.dromara.app.domain.bo.AppSchedulingDeviceBo;
|
||||
import org.dromara.app.domain.vo.AppScheduleDetailVo;
|
||||
import org.dromara.app.domain.vo.AppScheduleVo;
|
||||
import org.dromara.app.domain.vo.AppSchedulingDeviceVo;
|
||||
import org.dromara.app.service.*;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.satoken.utils.LoginHelper;
|
||||
import org.dromara.system.domain.vo.SysOssUploadVo;
|
||||
import org.dromara.system.domain.vo.SysOssVo;
|
||||
import org.dromara.system.service.ISysOssService;
|
||||
import org.dromara.system.service.ISysUserService;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.context.support.GenericApplicationContext;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.ResolverStyle;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@Tag("dev")
|
||||
class AppControllerConcurrencyTest {
|
||||
|
||||
private static final int WORKER_COUNT = 32;
|
||||
private static final int GROUP_TIMEOUT_SECONDS = 30;
|
||||
private static final Long USER_ID = 99L;
|
||||
private static final DateTimeFormatter STRICT_SECOND_FORMATTER =
|
||||
DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss").withResolverStyle(ResolverStyle.STRICT);
|
||||
|
||||
@Mock private IAppDeviceService appDeviceService;
|
||||
@Mock private IAppScheduleService appScheduleService;
|
||||
@Mock private IAppScheduleDetailService appScheduleDetailService;
|
||||
@Mock private IAppSchedulingDeviceService appSchedulingDeviceService;
|
||||
@Mock private ISysUserService userService;
|
||||
@Mock private IAppWateringLogService appWateringLogService;
|
||||
@Mock private IDeviceCommandService deviceCommandService;
|
||||
@Mock private ISysOssService ossService;
|
||||
@Mock private IAppVersionService appVersionService;
|
||||
|
||||
private GenericApplicationContext applicationContext;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationContext = new GenericApplicationContext();
|
||||
Supplier<ObjectMapper> objectMapperSupplier = ObjectMapper::new;
|
||||
applicationContext.registerBean(ObjectMapper.class, objectMapperSupplier);
|
||||
applicationContext.refresh();
|
||||
new SpringUtil().setApplicationContext(applicationContext);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
applicationContext.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void switchDevice_formatsAllStartTimesCorrectlyUnderConcurrency() throws Exception {
|
||||
AppController controller = newController();
|
||||
int iterationsPerWorker = 100;
|
||||
int expectedCalls = WORKER_COUNT * iterationsPerWorker;
|
||||
ConcurrentLinkedQueue<String> startTimes = new ConcurrentLinkedQueue<>();
|
||||
AtomicInteger successfulResponses = new AtomicInteger();
|
||||
when(appDeviceService.switchDevice(eq("D01"), eq("1"), anyString(), eq(10)))
|
||||
.thenAnswer(invocation -> {
|
||||
startTimes.add(invocation.getArgument(2, String.class));
|
||||
return true;
|
||||
});
|
||||
|
||||
runConcurrently(iterationsPerWorker, (worker, iteration) -> {
|
||||
R<Void> result = controller.switchDevice(
|
||||
"{\"deviceNo\":\"D01\",\"workStatus\":\"1\",\"durationMin\":\"10\"}");
|
||||
assertThat(result.getCode()).isEqualTo(R.SUCCESS);
|
||||
successfulResponses.incrementAndGet();
|
||||
});
|
||||
|
||||
assertThat(successfulResponses.get()).isEqualTo(expectedCalls);
|
||||
assertThat(startTimes).hasSize(expectedCalls).allSatisfy(startTime ->
|
||||
assertThatCode(() -> LocalDateTime.parse(startTime, STRICT_SECOND_FORMATTER))
|
||||
.doesNotThrowAnyException());
|
||||
verify(appDeviceService, times(expectedCalls))
|
||||
.switchDevice(eq("D01"), eq("1"), anyString(), eq(10));
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadImage_validatesAllSupportedFormatsUnderConcurrency() throws Exception {
|
||||
AppController controller = newController();
|
||||
int iterationsPerWorker = 50;
|
||||
int expectedCalls = WORKER_COUNT * iterationsPerWorker;
|
||||
List<ImageFixture> fixtures = supportedImages();
|
||||
SysOssVo oss = new SysOssVo();
|
||||
oss.setOssId(123L);
|
||||
oss.setOriginalName("plant-image");
|
||||
oss.setUrl("https://bucket.oss-cn-hangzhou.aliyuncs.com/plant-image");
|
||||
when(ossService.upload(any(MultipartFile.class))).thenReturn(oss);
|
||||
|
||||
runConcurrently(iterationsPerWorker, (worker, iteration) -> {
|
||||
ImageFixture fixture = fixtures.get((worker + iteration) % fixtures.size());
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file", fixture.fileName(), fixture.contentType(), fixture.content());
|
||||
|
||||
R<SysOssUploadVo> result = controller.uploadImage(file);
|
||||
|
||||
assertThat(result.getCode()).isEqualTo(R.SUCCESS);
|
||||
assertThat(result.getData().getOssId()).isEqualTo("123");
|
||||
assertThat(result.getData().getUrl()).isEqualTo(oss.getUrl());
|
||||
});
|
||||
|
||||
verify(ossService, times(expectedCalls)).upload(any(MultipartFile.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void editScheduleStatus_keepsMqttPayloadsIsolatedUnderConcurrency() throws Exception {
|
||||
AppController controller = newController();
|
||||
int iterationsPerWorker = 20;
|
||||
int expectedCalls = WORKER_COUNT * iterationsPerWorker;
|
||||
ConcurrentHashMap<String, Map<String, Object>> payloadsByDevice = new ConcurrentHashMap<>();
|
||||
when(appScheduleService.queryById(anyLong())).thenAnswer(invocation -> {
|
||||
Long scheduleId = invocation.getArgument(0, Long.class);
|
||||
AppScheduleVo schedule = new AppScheduleVo();
|
||||
schedule.setId(scheduleId);
|
||||
schedule.setUserId(USER_ID);
|
||||
schedule.setStatus("1");
|
||||
return schedule;
|
||||
});
|
||||
when(appScheduleService.updateStatusByBo(any(AppScheduleBo.class))).thenReturn(true);
|
||||
when(appSchedulingDeviceService.queryList(any(AppSchedulingDeviceBo.class))).thenAnswer(invocation -> {
|
||||
AppSchedulingDeviceBo bo = invocation.getArgument(0, AppSchedulingDeviceBo.class);
|
||||
AppSchedulingDeviceVo binding = new AppSchedulingDeviceVo();
|
||||
binding.setScheduleId(bo.getScheduleId());
|
||||
binding.setDeviceNo(deviceNo(bo.getScheduleId()));
|
||||
return List.of(binding);
|
||||
});
|
||||
when(appScheduleDetailService.queryByScheduleIdByStatus(anyLong())).thenAnswer(invocation -> {
|
||||
Long scheduleId = invocation.getArgument(0, Long.class);
|
||||
AppScheduleDetailVo detail = new AppScheduleDetailVo();
|
||||
detail.setScheduleId(scheduleId);
|
||||
detail.setWeekday((int) ((scheduleId - 1) % 7) + 1);
|
||||
detail.setTimeData("[{\"startTime\":\"08:00\",\"durationMin\":15}]");
|
||||
detail.setTriggerType(detailMarker(scheduleId));
|
||||
detail.setStatus("1");
|
||||
return List.of(detail);
|
||||
});
|
||||
when(deviceCommandService.sendScheduleBindCommand(anyString(), any())).thenAnswer(invocation -> {
|
||||
String deviceNo = invocation.getArgument(0, String.class);
|
||||
Map<String, Object> payload = invocation.getArgument(1);
|
||||
assertThat(payloadsByDevice.putIfAbsent(deviceNo, payload)).isNull();
|
||||
return "cmd-" + deviceNo;
|
||||
});
|
||||
|
||||
runConcurrently(iterationsPerWorker, (worker, iteration) -> {
|
||||
try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
|
||||
loginHelper.when(LoginHelper::getUserId).thenReturn(USER_ID);
|
||||
Long scheduleId = (long) worker * iterationsPerWorker + iteration + 1;
|
||||
AppScheduleBo bo = new AppScheduleBo();
|
||||
bo.setId(scheduleId);
|
||||
bo.setStatus("1");
|
||||
|
||||
R<Void> result = controller.editScheduleStatus(bo);
|
||||
|
||||
assertThat(result.getCode()).isEqualTo(R.SUCCESS);
|
||||
}
|
||||
});
|
||||
|
||||
assertThat(payloadsByDevice).hasSize(expectedCalls);
|
||||
for (long scheduleId = 1; scheduleId <= expectedCalls; scheduleId++) {
|
||||
String deviceNo = deviceNo(scheduleId);
|
||||
Map<String, Object> payload = payloadsByDevice.get(deviceNo);
|
||||
assertThat(payload).as(deviceNo).isNotNull();
|
||||
assertThat(payload.get("deviceNo")).isEqualTo(deviceNo);
|
||||
List<?> details = asList(payload.get("details"));
|
||||
assertThat(details).hasSize(1);
|
||||
Map<?, ?> detail = asMap(details.get(0));
|
||||
assertThat(detail.get("triggerType")).isEqualTo(detailMarker(scheduleId));
|
||||
assertThat(detail.get("weekday")).isEqualTo((int) ((scheduleId - 1) % 7) + 1);
|
||||
}
|
||||
verify(deviceCommandService, times(expectedCalls)).sendScheduleBindCommand(anyString(), any());
|
||||
verify(appScheduleDetailService, times(expectedCalls)).queryByScheduleIdByStatus(anyLong());
|
||||
}
|
||||
|
||||
private void runConcurrently(int iterationsPerWorker, ConcurrentOperation operation) throws Exception {
|
||||
ExecutorService executor = Executors.newFixedThreadPool(WORKER_COUNT);
|
||||
CountDownLatch ready = new CountDownLatch(WORKER_COUNT);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
List<Future<Void>> futures = new ArrayList<>(WORKER_COUNT);
|
||||
try {
|
||||
for (int worker = 0; worker < WORKER_COUNT; worker++) {
|
||||
int workerIndex = worker;
|
||||
futures.add(executor.submit(() -> {
|
||||
ready.countDown();
|
||||
if (!start.await(10, TimeUnit.SECONDS)) {
|
||||
throw new TimeoutException("并发工作线程等待启动超时");
|
||||
}
|
||||
for (int iteration = 0; iteration < iterationsPerWorker; iteration++) {
|
||||
operation.execute(workerIndex, iteration);
|
||||
}
|
||||
return null;
|
||||
}));
|
||||
}
|
||||
assertThat(ready.await(10, TimeUnit.SECONDS)).as("32个工作线程应全部就绪").isTrue();
|
||||
start.countDown();
|
||||
awaitAll(futures);
|
||||
} finally {
|
||||
start.countDown();
|
||||
executor.shutdownNow();
|
||||
assertThat(executor.awaitTermination(10, TimeUnit.SECONDS))
|
||||
.as("并发测试线程池应按时结束")
|
||||
.isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
private void awaitAll(List<Future<Void>> futures) throws Exception {
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(GROUP_TIMEOUT_SECONDS);
|
||||
for (Future<Void> future : futures) {
|
||||
long remainingNanos = deadline - System.nanoTime();
|
||||
if (remainingNanos <= 0) {
|
||||
throw new TimeoutException("并发测试组执行超过" + GROUP_TIMEOUT_SECONDS + "秒");
|
||||
}
|
||||
future.get(remainingNanos, TimeUnit.NANOSECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
private AppController newController() {
|
||||
return new AppController(
|
||||
appDeviceService,
|
||||
appScheduleService,
|
||||
appScheduleDetailService,
|
||||
appSchedulingDeviceService,
|
||||
userService,
|
||||
appWateringLogService,
|
||||
deviceCommandService,
|
||||
ossService,
|
||||
appVersionService
|
||||
);
|
||||
}
|
||||
|
||||
private List<ImageFixture> supportedImages() {
|
||||
return List.of(
|
||||
new ImageFixture("plant.jpg", "image/jpeg", new byte[]{
|
||||
(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0,
|
||||
0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00
|
||||
}),
|
||||
new ImageFixture("plant.png", "image/png", new byte[]{
|
||||
(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
|
||||
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52
|
||||
}),
|
||||
new ImageFixture("plant.gif", "image/gif",
|
||||
new byte[]{0x47, 0x49, 0x46, 0x38, 0x39, 0x61}),
|
||||
new ImageFixture("plant.webp", "image/webp", new byte[]{
|
||||
0x52, 0x49, 0x46, 0x46, 0x18, 0x00, 0x00, 0x00,
|
||||
0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x20
|
||||
}),
|
||||
new ImageFixture("plant.bmp", "image/bmp",
|
||||
new byte[]{0x42, 0x4D, 0x00, 0x00, 0x00, 0x00})
|
||||
);
|
||||
}
|
||||
|
||||
private String deviceNo(Long scheduleId) {
|
||||
return "D" + scheduleId;
|
||||
}
|
||||
|
||||
private String detailMarker(Long scheduleId) {
|
||||
return "schedule-" + scheduleId;
|
||||
}
|
||||
|
||||
private List<?> asList(Object value) {
|
||||
assertThat(value).isInstanceOf(List.class);
|
||||
return (List<?>) value;
|
||||
}
|
||||
|
||||
private Map<?, ?> asMap(Object value) {
|
||||
assertThat(value).isInstanceOf(Map.class);
|
||||
return (Map<?, ?>) value;
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface ConcurrentOperation {
|
||||
void execute(int worker, int iteration) throws Exception;
|
||||
}
|
||||
|
||||
private record ImageFixture(String fileName, String contentType, byte[] content) {
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,14 @@ package org.dromara.app.controller;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.dromara.app.domain.bo.AppDeviceBo;
|
||||
import org.dromara.app.domain.bo.AppScheduleBo;
|
||||
import org.dromara.app.domain.bo.AppSchedulingDeviceBo;
|
||||
import org.dromara.app.domain.bo.AppWateringLogBo;
|
||||
import org.dromara.app.domain.vo.*;
|
||||
import org.dromara.app.service.*;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.satoken.utils.LoginHelper;
|
||||
import org.dromara.system.domain.vo.SysOssUploadVo;
|
||||
import org.dromara.system.domain.vo.SysOssVo;
|
||||
@@ -17,22 +19,23 @@ import org.dromara.system.service.ISysUserService;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.*;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.context.support.GenericApplicationContext;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Calendar;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@@ -42,13 +45,13 @@ public class AppControllerTest {
|
||||
@Mock private IAppDeviceService appDeviceService;
|
||||
@Mock private IAppScheduleService appScheduleService;
|
||||
@Mock private IAppScheduleDetailService appScheduleDetailService;
|
||||
@Mock private org.dromara.app.service.impl.AppScheduleServiceImpl appScheduleServiceimpl;
|
||||
@Mock private IAppSchedulingDeviceService appSchedulingDeviceService;
|
||||
@Mock private ISysUserService userService;
|
||||
@Mock private IAppWateringLogService appWateringLogService;
|
||||
@Mock private IDeviceCommandService deviceCommandService;
|
||||
@Mock private ISysOssService ossService;
|
||||
@Mock private IAppVersionService appVersionService;
|
||||
@Captor private ArgumentCaptor<Map<String, Object>> schedulePayloadCaptor;
|
||||
|
||||
@Test
|
||||
public void checkVersion_returnsUpdateAvailableWhenConfiguredVersionDiffers() {
|
||||
@@ -100,10 +103,9 @@ public class AppControllerTest {
|
||||
public void checkVersion_rejectsMissingCurrentVersion() {
|
||||
AppController controller = newController();
|
||||
|
||||
R<AppVersionCheckVo> result = callWithLogin(1L, () -> controller.checkVersion("android", " "));
|
||||
|
||||
assertThat(result.getCode()).isEqualTo(500);
|
||||
assertThat(result.getMsg()).isEqualTo("当前版本不能为空");
|
||||
assertThatThrownBy(() -> callWithLogin(1L, () -> controller.checkVersion("android", " ")))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.hasMessage("当前版本不能为空");
|
||||
verify(appVersionService, never()).queryLatestByPlatform(any());
|
||||
}
|
||||
|
||||
@@ -111,10 +113,9 @@ public class AppControllerTest {
|
||||
public void checkVersion_rejectsUnsupportedPlatform() {
|
||||
AppController controller = newController();
|
||||
|
||||
R<AppVersionCheckVo> result = callWithLogin(1L, () -> controller.checkVersion("harmony", "1.0.0"));
|
||||
|
||||
assertThat(result.getCode()).isEqualTo(500);
|
||||
assertThat(result.getMsg()).isEqualTo("平台类型仅支持 android 或 ios");
|
||||
assertThatThrownBy(() -> callWithLogin(1L, () -> controller.checkVersion("harmony", "1.0.0")))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.hasMessage("平台类型仅支持 android 或 ios");
|
||||
verify(appVersionService, never()).queryLatestByPlatform(any());
|
||||
}
|
||||
|
||||
@@ -123,10 +124,9 @@ public class AppControllerTest {
|
||||
AppController controller = newController();
|
||||
when(appVersionService.queryLatestByPlatform("android")).thenReturn(null);
|
||||
|
||||
R<AppVersionCheckVo> result = callWithLogin(1L, () -> controller.checkVersion("android", "1.0.0"));
|
||||
|
||||
assertThat(result.getCode()).isEqualTo(500);
|
||||
assertThat(result.getMsg()).isEqualTo("版本配置不存在");
|
||||
assertThatThrownBy(() -> callWithLogin(1L, () -> controller.checkVersion("android", "1.0.0")))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.hasMessage("版本配置不存在");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -136,7 +136,7 @@ public class AppControllerTest {
|
||||
"file",
|
||||
"plant.png",
|
||||
"image/png",
|
||||
new byte[]{1, 2, 3}
|
||||
pngBytes()
|
||||
);
|
||||
SysOssVo oss = new SysOssVo();
|
||||
oss.setOssId(123L);
|
||||
@@ -153,6 +153,29 @@ public class AppControllerTest {
|
||||
verify(ossService).upload(file);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void uploadImage_acceptsAllSupportedBinaryImageFormats() {
|
||||
AppController controller = newController();
|
||||
SysOssVo oss = new SysOssVo();
|
||||
oss.setOssId(123L);
|
||||
oss.setOriginalName("plant-image");
|
||||
oss.setUrl("https://bucket.oss-cn-hangzhou.aliyuncs.com/plant-image");
|
||||
when(ossService.upload(any(MultipartFile.class))).thenReturn(oss);
|
||||
List<MockMultipartFile> files = List.of(
|
||||
new MockMultipartFile("file", "plant.jpg", "image/jpeg", jpegBytes()),
|
||||
new MockMultipartFile("file", "plant.gif", "image/gif", gifBytes()),
|
||||
new MockMultipartFile("file", "plant.webp", "image/webp", webpBytes()),
|
||||
new MockMultipartFile("file", "plant.bmp", "image/bmp", bmpBytes())
|
||||
);
|
||||
|
||||
for (MockMultipartFile file : files) {
|
||||
R<SysOssUploadVo> result = callWithLogin(1L, () -> controller.uploadImage(file));
|
||||
assertThat(result.getCode()).as(file.getOriginalFilename()).isEqualTo(200);
|
||||
}
|
||||
|
||||
verify(ossService, times(files.size())).upload(any(MultipartFile.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void uploadImage_rejectsNonImageFile() {
|
||||
AppController controller = newController();
|
||||
@@ -163,13 +186,67 @@ public class AppControllerTest {
|
||||
new byte[]{1, 2, 3}
|
||||
);
|
||||
|
||||
R<SysOssUploadVo> result = callWithLogin(1L, () -> controller.uploadImage(file));
|
||||
|
||||
assertThat(result.getCode()).isEqualTo(500);
|
||||
assertThat(result.getMsg()).isEqualTo("只能上传图片文件");
|
||||
assertThatThrownBy(() -> callWithLogin(1L, () -> controller.uploadImage(file)))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.hasMessage("只能上传图片文件");
|
||||
verify(ossService, never()).upload(any(org.springframework.web.multipart.MultipartFile.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void uploadImage_rejectsForgedImageContent() {
|
||||
AppController controller = newController();
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file",
|
||||
"plant.png",
|
||||
"image/png",
|
||||
"not-an-image".getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> callWithLogin(1L, () -> controller.uploadImage(file)))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.hasMessage("图片文件内容不合法");
|
||||
verify(ossService, never()).upload(any(MultipartFile.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void uploadImage_rejectsFileLargerThanTwentyMegabytes() {
|
||||
AppController controller = newController();
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(false);
|
||||
when(file.getSize()).thenReturn(20L * 1024 * 1024 + 1);
|
||||
|
||||
assertThatThrownBy(() -> callWithLogin(1L, () -> controller.uploadImage(file)))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.hasMessage("图片大小不能超过20MB");
|
||||
verify(ossService, never()).upload(any(MultipartFile.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void uploadImage_rejectsSvg() {
|
||||
AppController controller = newController();
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file",
|
||||
"plant.svg",
|
||||
"image/svg+xml",
|
||||
"<svg/>".getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> callWithLogin(1L, () -> controller.uploadImage(file)))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.hasMessage("仅支持jpg、jpeg、png、gif、webp、bmp图片");
|
||||
verify(ossService, never()).upload(any(MultipartFile.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getDeviceInfo_propagatesUnexpectedException() {
|
||||
AppController controller = newController();
|
||||
IllegalStateException failure = new IllegalStateException("database unavailable");
|
||||
when(appDeviceService.queryById("D01")).thenThrow(failure);
|
||||
|
||||
assertThatThrownBy(() -> callWithLogin(1L, () -> controller.getDeviceInfo("D01")))
|
||||
.isSameAs(failure);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void switchDevice_usesCurrentTimeWithSecondsAsStartTime() throws Exception {
|
||||
AppController controller = newController();
|
||||
@@ -186,6 +263,47 @@ public class AppControllerTest {
|
||||
assertThat(startTimeCaptor.getValue()).doesNotEndWith(":00");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void scheduleDeviceList_usesSingleBatchBindingLookup() {
|
||||
AppController controller = newController();
|
||||
AppDeviceVo first = ownedDevice("D01", 99L);
|
||||
AppDeviceVo second = ownedDevice("D02", 99L);
|
||||
when(appDeviceService.queryList(any(AppDeviceBo.class))).thenReturn(List.of(first, second));
|
||||
when(appSchedulingDeviceService.findBoundDeviceNos(List.of("D01", "D02")))
|
||||
.thenReturn(Set.of("D01"));
|
||||
|
||||
R<Map<String, Object>> result = callWithLogin(99L, () -> controller.scheduleDeviceList(10L));
|
||||
|
||||
assertThat(result.getData().get("deviceList")).isEqualTo(List.of(second));
|
||||
verify(appSchedulingDeviceService, never()).findByDeviceNo(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getScheduleInfo_usesSingleBatchDeviceLookup() {
|
||||
AppController controller = newController();
|
||||
AppScheduleVo schedule = ownedSchedule(10L, 99L);
|
||||
AppSchedulingDeviceVo firstBinding = new AppSchedulingDeviceVo();
|
||||
firstBinding.setDeviceNo("D01");
|
||||
AppSchedulingDeviceVo secondBinding = new AppSchedulingDeviceVo();
|
||||
secondBinding.setDeviceNo("D02");
|
||||
AppDeviceVo firstDevice = ownedDevice("D01", 99L);
|
||||
AppDeviceVo secondDevice = ownedDevice("D02", 99L);
|
||||
List<AppDeviceVo> queriedDevices = List.of(secondDevice, firstDevice);
|
||||
when(appScheduleService.queryById(10L)).thenReturn(schedule);
|
||||
when(appScheduleDetailService.queryByScheduleIdByStatus(10L)).thenReturn(List.of());
|
||||
when(appSchedulingDeviceService.queryList(any(AppSchedulingDeviceBo.class)))
|
||||
.thenReturn(List.of(firstBinding, secondBinding));
|
||||
when(appDeviceService.queryByDeviceNos(List.of("D01", "D02"))).thenReturn(queriedDevices);
|
||||
|
||||
R<AppScheduleVo> result = callWithLogin(99L, () -> controller.getScheduleInfo(10L));
|
||||
|
||||
assertThat(result.getData().getDeviceNos())
|
||||
.extracting(AppDeviceVo::getDeviceNo)
|
||||
.containsExactly("D01", "D02");
|
||||
verify(appDeviceService, never()).queryById("D01");
|
||||
verify(appDeviceService, never()).queryById("D02");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void addScheduleDevice_dispatchesSchedulePayloadAfterBinding() {
|
||||
AppController controller = newController();
|
||||
@@ -217,23 +335,21 @@ public class AppControllerTest {
|
||||
|
||||
assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
|
||||
|
||||
ArgumentCaptor<Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(Map.class);
|
||||
verify(deviceCommandService).sendScheduleBindCommand(eq("D01"), payloadCaptor.capture());
|
||||
Map<String, Object> payload = payloadCaptor.getValue();
|
||||
verify(deviceCommandService).sendScheduleBindCommand(eq("D01"), schedulePayloadCaptor.capture());
|
||||
Map<String, Object> payload = schedulePayloadCaptor.getValue();
|
||||
assertThat(payload.get("deviceNo")).isEqualTo("D01");
|
||||
assertThat(payload).containsKey("details").doesNotContainKeys("cmd", "schedule");
|
||||
|
||||
List<Map<String, Object>> details = (List<Map<String, Object>>) payload.get("details");
|
||||
List<?> details = asList(payload.get("details"));
|
||||
assertThat(details).hasSize(1);
|
||||
Map<String, Object> detailPayload = details.get(0);
|
||||
assertThat(detailPayload)
|
||||
.containsEntry("weekday", 1)
|
||||
.containsEntry("triggerType", "0")
|
||||
.containsEntry("status", "1")
|
||||
.doesNotContainKey("id")
|
||||
.doesNotContainKey("zones");
|
||||
Map<?, ?> detailPayload = asMap(details.get(0));
|
||||
assertThat(detailPayload.get("weekday")).isEqualTo(1);
|
||||
assertThat(detailPayload.get("triggerType")).isEqualTo("0");
|
||||
assertThat(detailPayload.get("status")).isEqualTo("1");
|
||||
assertThat(detailPayload.containsKey("id")).isFalse();
|
||||
assertThat(detailPayload.containsKey("zones")).isFalse();
|
||||
|
||||
List<Object> timeData = (List<Object>) detailPayload.get("timeData");
|
||||
List<?> timeData = asList(detailPayload.get("timeData"));
|
||||
assertThat(timeData).hasSize(1);
|
||||
JSONObject timeSlot = (JSONObject) timeData.get(0);
|
||||
assertThat(timeSlot.getStr("startTime")).isEqualTo("08:00");
|
||||
@@ -275,7 +391,7 @@ public class AppControllerTest {
|
||||
R<Void> result = callWithLogin(userId, () -> controller.removeSchedule(new Long[]{10L}));
|
||||
|
||||
assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
|
||||
var ordered = inOrder(appScheduleService, deviceCommandService);
|
||||
InOrder ordered = inOrder(appScheduleService, deviceCommandService);
|
||||
ordered.verify(appScheduleService).deleteWithValidByIds(List.of(10L), true);
|
||||
ordered.verify(deviceCommandService).sendScheduleUnbindCommand("D01", 10L);
|
||||
}
|
||||
@@ -348,6 +464,29 @@ public class AppControllerTest {
|
||||
verify(deviceCommandService, never()).sendScheduleCanceledCommand("D01", 10L);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void editScheduleStatus_queriesScheduleDetailsOnceForMultipleDevices() {
|
||||
AppController controller = newController();
|
||||
AppScheduleBo bo = new AppScheduleBo();
|
||||
bo.setId(10L);
|
||||
bo.setStatus("1");
|
||||
AppSchedulingDeviceVo first = new AppSchedulingDeviceVo();
|
||||
first.setDeviceNo("D01");
|
||||
AppSchedulingDeviceVo second = new AppSchedulingDeviceVo();
|
||||
second.setDeviceNo("D02");
|
||||
|
||||
when(appScheduleService.queryById(10L)).thenReturn(ownedSchedule(10L, 99L));
|
||||
when(appScheduleService.updateStatusByBo(bo)).thenReturn(true);
|
||||
when(appSchedulingDeviceService.queryList(any(AppSchedulingDeviceBo.class)))
|
||||
.thenReturn(List.of(first, second));
|
||||
when(appScheduleDetailService.queryByScheduleIdByStatus(10L)).thenReturn(List.of());
|
||||
|
||||
callWithLogin(99L, () -> controller.editScheduleStatus(bo));
|
||||
|
||||
verify(appScheduleDetailService, times(1)).queryByScheduleIdByStatus(10L);
|
||||
verify(deviceCommandService, times(2)).sendScheduleBindCommand(anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void latestWaterLog_returnsLatestRunningLogById() throws Exception {
|
||||
AppController controller = newController();
|
||||
@@ -387,7 +526,6 @@ public class AppControllerTest {
|
||||
appDeviceService,
|
||||
appScheduleService,
|
||||
appScheduleDetailService,
|
||||
appScheduleServiceimpl,
|
||||
appSchedulingDeviceService,
|
||||
userService,
|
||||
appWateringLogService,
|
||||
@@ -400,7 +538,8 @@ public class AppControllerTest {
|
||||
private <T> R<T> callWithLogin(Long userId, Supplier<R<T>> action) {
|
||||
try (GenericApplicationContext applicationContext = new GenericApplicationContext();
|
||||
MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
|
||||
applicationContext.registerBean(ObjectMapper.class, (Supplier<ObjectMapper>) ObjectMapper::new);
|
||||
Supplier<ObjectMapper> objectMapperSupplier = ObjectMapper::new;
|
||||
applicationContext.registerBean(ObjectMapper.class, objectMapperSupplier);
|
||||
applicationContext.refresh();
|
||||
new SpringUtil().setApplicationContext(applicationContext);
|
||||
loginHelper.when(LoginHelper::getUserId).thenReturn(userId);
|
||||
@@ -408,6 +547,16 @@ public class AppControllerTest {
|
||||
}
|
||||
}
|
||||
|
||||
private List<?> asList(Object value) {
|
||||
assertThat(value).isInstanceOf(List.class);
|
||||
return (List<?>) value;
|
||||
}
|
||||
|
||||
private Map<?, ?> asMap(Object value) {
|
||||
assertThat(value).isInstanceOf(Map.class);
|
||||
return (Map<?, ?>) value;
|
||||
}
|
||||
|
||||
private AppScheduleVo ownedSchedule(Long scheduleId, Long userId) {
|
||||
AppScheduleVo schedule = new AppScheduleVo();
|
||||
schedule.setId(scheduleId);
|
||||
@@ -422,6 +571,35 @@ public class AppControllerTest {
|
||||
return device;
|
||||
}
|
||||
|
||||
private byte[] pngBytes() {
|
||||
return new byte[]{
|
||||
(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
|
||||
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52
|
||||
};
|
||||
}
|
||||
|
||||
private byte[] jpegBytes() {
|
||||
return new byte[]{
|
||||
(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0,
|
||||
0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00
|
||||
};
|
||||
}
|
||||
|
||||
private byte[] gifBytes() {
|
||||
return new byte[]{0x47, 0x49, 0x46, 0x38, 0x39, 0x61};
|
||||
}
|
||||
|
||||
private byte[] webpBytes() {
|
||||
return new byte[]{
|
||||
0x52, 0x49, 0x46, 0x46, 0x18, 0x00, 0x00, 0x00,
|
||||
0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x20
|
||||
};
|
||||
}
|
||||
|
||||
private byte[] bmpBytes() {
|
||||
return new byte[]{0x42, 0x4D, 0x00, 0x00, 0x00, 0x00};
|
||||
}
|
||||
|
||||
private void waitUntilCurrentSecondIsSafelyNonZero() throws InterruptedException {
|
||||
while (true) {
|
||||
int second = Calendar.getInstance().get(Calendar.SECOND);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package org.dromara.app.handler;
|
||||
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.dromara.app.domain.bo.AppDeviceBo;
|
||||
import org.dromara.app.service.IAppDeviceService;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.context.support.GenericApplicationContext;
|
||||
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@Tag("dev")
|
||||
class DeviceDataHandlerTest {
|
||||
|
||||
@Mock
|
||||
private IAppDeviceService appDeviceService;
|
||||
|
||||
@Mock
|
||||
private DeviceIdentityResolver deviceIdentityResolver;
|
||||
|
||||
@Mock
|
||||
private MqttDeviceStatusService deviceStatusService;
|
||||
|
||||
@InjectMocks
|
||||
private DeviceDataHandler handler;
|
||||
|
||||
@Test
|
||||
void handleMarksDeviceOnlineAndUpdatesPowerLevel() {
|
||||
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
|
||||
|
||||
withJsonContext(() -> {
|
||||
handler.handle("D01", "{\"powerLevel\":\"86\"}");
|
||||
return null;
|
||||
});
|
||||
|
||||
ArgumentCaptor<AppDeviceBo> deviceCaptor = ArgumentCaptor.forClass(AppDeviceBo.class);
|
||||
verify(appDeviceService).updateByBo(deviceCaptor.capture());
|
||||
AppDeviceBo updatedDevice = deviceCaptor.getValue();
|
||||
assertThat(updatedDevice.getDeviceNo()).isEqualTo("D01");
|
||||
assertThat(updatedDevice.getPowerLevel()).isEqualTo("86");
|
||||
assertThat(updatedDevice.getPowerLevelUpdatatime()).isNotNull();
|
||||
verify(deviceStatusService).markOnline("D01");
|
||||
}
|
||||
|
||||
private <T> T withJsonContext(Supplier<T> action) {
|
||||
try (GenericApplicationContext applicationContext = new GenericApplicationContext()) {
|
||||
applicationContext.registerBean(ObjectMapper.class, (Supplier<ObjectMapper>) ObjectMapper::new);
|
||||
applicationContext.refresh();
|
||||
new SpringUtil().setApplicationContext(applicationContext);
|
||||
return action.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.dromara.app.handler;
|
||||
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.dromara.app.domain.AppDevice;
|
||||
import org.dromara.app.domain.bo.AppDeviceBo;
|
||||
import org.dromara.app.domain.mqtt.DeviceCommand;
|
||||
@@ -13,8 +15,10 @@ import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.context.support.GenericApplicationContext;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
@@ -30,6 +34,7 @@ class DeviceRegisterHandlerTest {
|
||||
@Mock private ObjectProvider<IDeviceCommandPublisher> commandPublisherProvider;
|
||||
@Mock private IDeviceCommandPublisher commandPublisher;
|
||||
@Mock private DeviceIdentityResolver deviceIdentityResolver;
|
||||
@Mock private MqttDeviceStatusService deviceStatusService;
|
||||
|
||||
@Test
|
||||
void firstRegistrationReplyStillUsesMacTopic() throws Exception {
|
||||
@@ -37,7 +42,8 @@ class DeviceRegisterHandlerTest {
|
||||
appDeviceService,
|
||||
appDeviceMapper,
|
||||
commandPublisherProvider,
|
||||
deviceIdentityResolver
|
||||
deviceIdentityResolver,
|
||||
deviceStatusService
|
||||
);
|
||||
when(commandPublisherProvider.getIfAvailable()).thenReturn(commandPublisher);
|
||||
when(commandPublisher.send(any(DeviceCommand.class))).thenReturn("cmd-1");
|
||||
@@ -61,7 +67,8 @@ class DeviceRegisterHandlerTest {
|
||||
appDeviceService,
|
||||
appDeviceMapper,
|
||||
commandPublisherProvider,
|
||||
deviceIdentityResolver
|
||||
deviceIdentityResolver,
|
||||
deviceStatusService
|
||||
);
|
||||
AppDevice existing = new AppDevice();
|
||||
existing.setDeviceNo("2075423638947475457");
|
||||
@@ -69,11 +76,24 @@ class DeviceRegisterHandlerTest {
|
||||
when(deviceIdentityResolver.resolve("2075423638947475457")).thenReturn(existing);
|
||||
when(commandPublisherProvider.getIfAvailable()).thenReturn(null);
|
||||
|
||||
handler.handle("2075423638947475457", "{}");
|
||||
withJsonContext(() -> {
|
||||
handler.handle("2075423638947475457", "{}");
|
||||
return null;
|
||||
});
|
||||
|
||||
org.mockito.ArgumentCaptor<AppDeviceBo> captor = org.mockito.ArgumentCaptor.forClass(AppDeviceBo.class);
|
||||
verify(appDeviceService).registerByMqtt(captor.capture());
|
||||
assertThat(captor.getValue().getDeviceNo()).isEqualTo("2075423638947475457");
|
||||
assertThat(captor.getValue().getMacAddress()).isEqualTo("aa:bb:cc");
|
||||
verify(deviceStatusService).markOnline("2075423638947475457");
|
||||
}
|
||||
|
||||
private <T> T withJsonContext(Supplier<T> action) {
|
||||
try (GenericApplicationContext applicationContext = new GenericApplicationContext()) {
|
||||
applicationContext.registerBean(ObjectMapper.class, (Supplier<ObjectMapper>) ObjectMapper::new);
|
||||
applicationContext.refresh();
|
||||
new SpringUtil().setApplicationContext(applicationContext);
|
||||
return action.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,13 +35,13 @@ class DeviceStatusHandlerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleMarksDeviceOnlineFromPlainPayload() {
|
||||
void handleIgnoresOnlineStatusBecausePowerReportMarksDeviceOnline() {
|
||||
DeviceStatusHandler handler = newHandler();
|
||||
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
|
||||
|
||||
handler.handle("D01", "online");
|
||||
|
||||
verify(deviceStatusService).markOnline("D01");
|
||||
verify(deviceStatusService, never()).markOnline(anyString());
|
||||
verify(deviceStatusService, never()).markOffline(anyString(), anyString());
|
||||
}
|
||||
|
||||
@@ -56,6 +56,19 @@ class DeviceStatusHandlerTest {
|
||||
verify(deviceStatusService, never()).markOnline(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleMarksDeviceOfflineFromOfflineFlagAndPayloadMac() {
|
||||
DeviceStatusHandler handler = newHandler();
|
||||
when(deviceIdentityResolver.resolveDeviceNo("unknown-topic-identity")).thenReturn(null);
|
||||
when(deviceIdentityResolver.resolveDeviceNo("DC:DA:0C:FA:29:5E")).thenReturn("D01");
|
||||
|
||||
handler.handle("unknown-topic-identity", "{\"deviceMac\":\"DC:DA:0C:FA:29:5E\",\"offline\":\"true\"}");
|
||||
|
||||
verify(deviceIdentityResolver).resolveDeviceNo("DC:DA:0C:FA:29:5E");
|
||||
verify(deviceStatusService).markOffline("D01", "设备 MQTT 状态离线");
|
||||
verify(deviceStatusService, never()).markOnline(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleResolvesLastWillTopicMacToDeviceNo() {
|
||||
String macAddress = "DC:DA:0C:FA:29:5E";
|
||||
|
||||
@@ -76,7 +76,7 @@ class MqttDeviceStatusServiceTest {
|
||||
redis.verify(() -> RedisUtils.setCacheObject(
|
||||
eq("mqtt:device:status:D01"),
|
||||
any(Map.class),
|
||||
eq(Duration.ofSeconds(900))
|
||||
eq(Duration.ofSeconds(600))
|
||||
));
|
||||
verify(appDeviceMapper).update(eq(null), any(LambdaUpdateWrapper.class));
|
||||
verify(lock).unlock();
|
||||
@@ -128,7 +128,7 @@ class MqttDeviceStatusServiceTest {
|
||||
private MqttDeviceStatusService newService() {
|
||||
MqttDeviceStatusService service = new MqttDeviceStatusService(appDeviceMapper, wateringLogService);
|
||||
ReflectionTestUtils.setField(service, "deviceStatusCachePrefix", "mqtt:device:status:");
|
||||
ReflectionTestUtils.setField(service, "deviceStatusCacheTtlSeconds", 900L);
|
||||
ReflectionTestUtils.setField(service, "deviceStatusCacheTtlSeconds", 600L);
|
||||
return service;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.dromara.app.service.impl;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import org.dromara.app.domain.AppDevice;
|
||||
import org.dromara.app.domain.bo.AppDeviceBo;
|
||||
import org.dromara.app.domain.vo.AppDeviceVo;
|
||||
import org.dromara.app.mapper.AppDeviceMapper;
|
||||
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
|
||||
import org.dromara.app.mapper.AppWateringLogMapper;
|
||||
@@ -174,6 +175,22 @@ class AppDeviceServiceImplTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryByDeviceNos_delegatesToSingleBatchQuery() {
|
||||
AppDeviceServiceImpl service = new AppDeviceServiceImpl(
|
||||
appDeviceMapper,
|
||||
schedulingDeviceMapper,
|
||||
wateringLogMapper,
|
||||
wateringLogService
|
||||
);
|
||||
List<String> deviceNos = List.of("D01", "D02");
|
||||
List<AppDeviceVo> expected = List.of(new AppDeviceVo(), new AppDeviceVo());
|
||||
when(appDeviceMapper.selectVoByIds(deviceNos)).thenReturn(expected);
|
||||
|
||||
assertThat(service.queryByDeviceNos(deviceNos)).isSameAs(expected);
|
||||
verify(appDeviceMapper).selectVoByIds(deviceNos);
|
||||
}
|
||||
|
||||
private int countNonWhitePixels(BufferedImage image, int startY, int endY) {
|
||||
int count = 0;
|
||||
for (int y = startY; y < endY; y++) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.dromara.app.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import org.dromara.app.domain.AppSchedulingDevice;
|
||||
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@Tag("dev")
|
||||
class AppSchedulingDeviceServiceImplTest {
|
||||
|
||||
@Mock
|
||||
private AppSchedulingDeviceMapper mapper;
|
||||
|
||||
@Test
|
||||
void findBoundDeviceNos_usesOneConstrainedQuery() {
|
||||
AppSchedulingDeviceServiceImpl service = new AppSchedulingDeviceServiceImpl(mapper);
|
||||
AppSchedulingDevice first = new AppSchedulingDevice();
|
||||
first.setDeviceNo("D01");
|
||||
AppSchedulingDevice duplicate = new AppSchedulingDevice();
|
||||
duplicate.setDeviceNo("D01");
|
||||
when(mapper.selectList(any(Wrapper.class))).thenReturn(List.of(first, duplicate));
|
||||
|
||||
Set<String> result = service.findBoundDeviceNos(List.of("D01", "D02"));
|
||||
|
||||
assertThat(result).containsExactly("D01");
|
||||
verify(mapper, times(1)).selectList(any(Wrapper.class));
|
||||
}
|
||||
}
|
||||
@@ -67,15 +67,31 @@ class DeviceOfflineCheckTaskTest {
|
||||
|
||||
task.checkOfflineDevices();
|
||||
|
||||
redis.verify(() -> RedisUtils.expire("mqtt:device:status:D01", Duration.ofSeconds(900)));
|
||||
redis.verify(() -> RedisUtils.expire("mqtt:device:status:D01", Duration.ofSeconds(600)));
|
||||
verify(deviceStatusService, never()).markOfflineIfCacheMissing(any(), any());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkOfflineDevicesMarksDeviceOfflineWhenPowerHeartbeatExpired() {
|
||||
DeviceOfflineCheckTask task = newTask();
|
||||
AppDevice device = new AppDevice();
|
||||
device.setDeviceNo("D01");
|
||||
when(appDeviceMapper.selectList(any(Wrapper.class))).thenReturn(List.of(device));
|
||||
|
||||
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
|
||||
redis.when(() -> RedisUtils.hasKey("mqtt:device:status:D01")).thenReturn(false);
|
||||
|
||||
task.checkOfflineDevices();
|
||||
|
||||
verify(deviceStatusService).markOfflineIfCacheMissing("D01", "设备电量心跳超时");
|
||||
}
|
||||
}
|
||||
|
||||
private DeviceOfflineCheckTask newTask() {
|
||||
DeviceOfflineCheckTask task = new DeviceOfflineCheckTask(appDeviceMapper, deviceStatusService);
|
||||
ReflectionTestUtils.setField(task, "deviceStatusCachePrefix", "mqtt:device:status:");
|
||||
ReflectionTestUtils.setField(task, "deviceStatusCacheTtlSeconds", 900L);
|
||||
ReflectionTestUtils.setField(task, "deviceStatusCacheTtlSeconds", 600L);
|
||||
ReflectionTestUtils.setField(task, "enabled", true);
|
||||
ReflectionTestUtils.setField(task, "ttlCompatEnabled", true);
|
||||
return task;
|
||||
|
||||
Reference in New Issue
Block a user