feat(iot): Wave 5 Round 2 — B9/B14/B16 统一消费入口 + 告警分布式锁 + 通知集成
B9 IotRuleEngineMessageHandler(统一消费入口)
- 新消费者 v2 统一入口,@PostConstruct 注册到 IotMessageBus
- versionResolver.shouldUseV2 三态路由(V1/V2/HYBRID),绝不双跑
- device null 时 WARN + skip;RuleEngine 异常 try-catch 吞掉防重试风暴
- v1 三消费者(DataRule/SceneRule/CleanRule)增加前置 v2 bypass 判断
- 6 个单元测试(global-v1/v2/hybrid 白名单命中/未命中/device-null/引擎异常)
B14 告警缓存 + SET NX PX 分布式锁 + 有效性判断
- IotAlarmLockService:SET NX PX + Lua 原子解锁,锁冲突抛 ALARM_LOCK_CONFLICT
- IotAlarmCacheService:Redis Hash iot:alarm:state:{id},TTL 7天,cache miss 从DB重建
- AlarmStateValidator:isEffectiveTrigger/isEffectiveClear 时序校验,防旧消息重置已清除告警
- IotAlarmRecordServiceImpl:trigger/clear/ack/archive 全部在锁内,DB写后立即同步缓存
- 新增 ALARM_LOCK_CONFLICT 错误码;AlarmTriggerRequest 增加 timestamp 字段
- 17 个单元测试(锁 4 + 缓存 5 + 校验 9 + 集成 3)
B16 NotifyAction 4 通道集成 + 模板解析
- NotifyChannel SPI 接口 + Sms/Email/InApp/Webhook 四实现(@Component 注册)
- WebhookNotifyChannel:JDK 17 HttpClient 10s 超时 + SSRF 强制 HTTPS 校验
- NotifyDispatcher:独立 ForkJoinPool(8) 并行分发,30s 整体超时,部分失败不阻塞
- 模板变量统一走 TemplateResolver(评审 C5),缺失变量降级为空串
- NotifyAction 移除 stub,委托 NotifyDispatcher
- viewsh-module-system-api 依赖引入;13 个测试(Dispatcher 7 + Webhook SSRF 6)
测试:iot-rule 177/177 全绿;iot-server 251/251 全绿(含 Skipped 161 旧 v1 测试)
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
package com.viewsh.module.iot.mq.consumer.rule;
|
||||
|
||||
import com.viewsh.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import com.viewsh.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import com.viewsh.module.iot.dal.dataobject.device.IotDeviceDO;
|
||||
import com.viewsh.module.iot.rule.config.IotRuleEngineVersionResolver;
|
||||
import com.viewsh.module.iot.rule.engine.RuleEngine;
|
||||
import com.viewsh.module.iot.rule.engine.RuleEngineResult;
|
||||
import com.viewsh.module.iot.service.device.IotDeviceService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* {@link IotRuleEngineMessageHandler} 单元测试
|
||||
*
|
||||
* <p>验证 B9 的六个场景(见任务卡 §6 Test Cases):
|
||||
* <ol>
|
||||
* <li>全 v1 — shouldUseV2=false → handler 提前返回,ruleEngine 不调用</li>
|
||||
* <li>全 v2 — shouldUseV2=true → ruleEngine.execute() 被调用</li>
|
||||
* <li>hybrid + 白名单命中 — shouldUseV2=true → ruleEngine.execute() 被调用</li>
|
||||
* <li>hybrid + 白名单未命中 — shouldUseV2=false → handler 提前返回</li>
|
||||
* <li>device 已删除(null)— log WARN + skip,ruleEngine 不调用</li>
|
||||
* <li>RuleEngine 抛异常 — handler 捕获异常,不向上抛出</li>
|
||||
* </ol>
|
||||
*/
|
||||
class IotRuleEngineMessageHandlerTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private IotRuleEngineMessageHandler handler;
|
||||
|
||||
@Mock
|
||||
private IotRuleEngineVersionResolver versionResolver;
|
||||
|
||||
@Mock
|
||||
private RuleEngine ruleEngine;
|
||||
|
||||
@Mock
|
||||
private IotDeviceService deviceService;
|
||||
|
||||
// ---- 测试数据 ----
|
||||
|
||||
private static final Long DEVICE_ID = 100L;
|
||||
private static final Long TENANT_ID = 1L;
|
||||
private static final Long SUBSYSTEM_ID = 5L;
|
||||
private static final Long PRODUCT_ID = 10L;
|
||||
|
||||
private IotDeviceMessage buildMessage() {
|
||||
return IotDeviceMessage.builder()
|
||||
.deviceId(DEVICE_ID)
|
||||
.tenantId(TENANT_ID)
|
||||
.build();
|
||||
}
|
||||
|
||||
private IotDeviceDO buildDevice(Long subsystemId) {
|
||||
IotDeviceDO device = IotDeviceDO.builder()
|
||||
.id(DEVICE_ID)
|
||||
.subsystemId(subsystemId)
|
||||
.productId(PRODUCT_ID)
|
||||
.build();
|
||||
device.setTenantId(TENANT_ID);
|
||||
return device;
|
||||
}
|
||||
|
||||
// ---- 场景 1:全 v1,shouldUseV2=false,handler 不处理 ----
|
||||
|
||||
@Test
|
||||
void testOnMessage_globalV1_skipHandler() {
|
||||
IotDeviceMessage msg = buildMessage();
|
||||
IotDeviceDO device = buildDevice(SUBSYSTEM_ID);
|
||||
|
||||
when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device);
|
||||
when(versionResolver.shouldUseV2(SUBSYSTEM_ID, TENANT_ID)).thenReturn(false);
|
||||
|
||||
handler.onMessage(msg);
|
||||
|
||||
verify(ruleEngine, never()).execute(any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
// ---- 场景 2:全 v2,shouldUseV2=true,ruleEngine.execute() 被调用 ----
|
||||
|
||||
@Test
|
||||
void testOnMessage_globalV2_executeRuleEngine() {
|
||||
IotDeviceMessage msg = buildMessage();
|
||||
IotDeviceDO device = buildDevice(SUBSYSTEM_ID);
|
||||
RuleEngineResult result = new RuleEngineResult();
|
||||
|
||||
when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device);
|
||||
when(versionResolver.shouldUseV2(SUBSYSTEM_ID, TENANT_ID)).thenReturn(true);
|
||||
when(ruleEngine.execute(eq(msg), eq(TENANT_ID), eq(SUBSYSTEM_ID), eq(PRODUCT_ID), eq(DEVICE_ID)))
|
||||
.thenReturn(result);
|
||||
|
||||
handler.onMessage(msg);
|
||||
|
||||
verify(ruleEngine, times(1)).execute(eq(msg), eq(TENANT_ID), eq(SUBSYSTEM_ID), eq(PRODUCT_ID), eq(DEVICE_ID));
|
||||
}
|
||||
|
||||
// ---- 场景 3:hybrid + 白名单命中(subsystem=5),走 v2 ----
|
||||
|
||||
@Test
|
||||
void testOnMessage_hybridWhitelistHit_executeRuleEngine() {
|
||||
IotDeviceMessage msg = buildMessage();
|
||||
IotDeviceDO device = buildDevice(SUBSYSTEM_ID);
|
||||
RuleEngineResult result = new RuleEngineResult();
|
||||
|
||||
when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device);
|
||||
when(versionResolver.shouldUseV2(SUBSYSTEM_ID, TENANT_ID)).thenReturn(true); // hybrid: subsystem 5 in whitelist
|
||||
when(ruleEngine.execute(eq(msg), eq(TENANT_ID), eq(SUBSYSTEM_ID), eq(PRODUCT_ID), eq(DEVICE_ID)))
|
||||
.thenReturn(result);
|
||||
|
||||
handler.onMessage(msg);
|
||||
|
||||
verify(ruleEngine, times(1)).execute(any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
// ---- 场景 4:hybrid + 白名单未命中(subsystem=3),走 v1,handler 跳过 ----
|
||||
|
||||
@Test
|
||||
void testOnMessage_hybridWhitelistMiss_skipHandler() {
|
||||
Long otherSubsystem = 3L;
|
||||
IotDeviceMessage msg = buildMessage();
|
||||
IotDeviceDO device = buildDevice(otherSubsystem);
|
||||
|
||||
when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device);
|
||||
when(versionResolver.shouldUseV2(otherSubsystem, TENANT_ID)).thenReturn(false); // subsystem 3 not in whitelist
|
||||
|
||||
handler.onMessage(msg);
|
||||
|
||||
verify(ruleEngine, never()).execute(any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
// ---- 场景 5:device 已删除(返回 null),log WARN + skip ----
|
||||
|
||||
@Test
|
||||
void testOnMessage_deviceNull_skipHandler() {
|
||||
IotDeviceMessage msg = buildMessage();
|
||||
|
||||
when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(null);
|
||||
|
||||
handler.onMessage(msg);
|
||||
|
||||
verify(versionResolver, never()).shouldUseV2(any(), any());
|
||||
verify(ruleEngine, never()).execute(any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
// ---- 场景 6:RuleEngine 抛异常,handler 捕获,不向上抛出 ----
|
||||
|
||||
@Test
|
||||
void testOnMessage_ruleEngineException_swallowed() {
|
||||
IotDeviceMessage msg = buildMessage();
|
||||
IotDeviceDO device = buildDevice(SUBSYSTEM_ID);
|
||||
|
||||
when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device);
|
||||
when(versionResolver.shouldUseV2(SUBSYSTEM_ID, TENANT_ID)).thenReturn(true);
|
||||
when(ruleEngine.execute(any(), any(), any(), any(), any()))
|
||||
.thenThrow(new RuntimeException("simulated engine error"));
|
||||
|
||||
// 不应向外抛出异常
|
||||
handler.onMessage(msg);
|
||||
|
||||
verify(ruleEngine, times(1)).execute(any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.viewsh.module.iot.service.alarm;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* {@link AlarmStateValidator} 单元测试
|
||||
*
|
||||
* <p>任务卡 B14 §3.3 时序校验逻辑:
|
||||
* <ul>
|
||||
* <li>isEffectiveTrigger:已清除告警 + 旧消息 → false</li>
|
||||
* <li>isEffectiveTrigger:已清除告警 + 新消息 → true(重新激活)</li>
|
||||
* <li>isEffectiveTrigger:活跃告警任何消息 → true</li>
|
||||
* <li>isEffectiveTrigger:cache == null(首次)→ true</li>
|
||||
* <li>isEffectiveClear:活跃告警 + 新消息 → true</li>
|
||||
* <li>isEffectiveClear:已清除告警 → false(幂等)</li>
|
||||
* <li>isEffectiveClear:活跃告警 + 旧消息 → false</li>
|
||||
* <li>isEffectiveClear:cache == null → false</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author B14
|
||||
*/
|
||||
class AlarmStateValidatorTest {
|
||||
|
||||
private final AlarmStateValidator validator = new AlarmStateValidator();
|
||||
|
||||
// ==================== isEffectiveTrigger ====================
|
||||
|
||||
@Test
|
||||
void testIsEffectiveTrigger_clearedAlarm_oldMessage_returnFalse() {
|
||||
AlarmCacheState cache = AlarmCacheState.builder()
|
||||
.clearState(1)
|
||||
.clearTime(5000L)
|
||||
.alarmTime(1000L)
|
||||
.build();
|
||||
// 消息时间戳 3000 < clearTime 5000 → 旧消息,不生效
|
||||
assertFalse(validator.isEffectiveTrigger(cache, 3000L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsEffectiveTrigger_clearedAlarm_newMessage_returnTrue() {
|
||||
AlarmCacheState cache = AlarmCacheState.builder()
|
||||
.clearState(1)
|
||||
.clearTime(5000L)
|
||||
.alarmTime(1000L)
|
||||
.build();
|
||||
// 消息时间戳 6000 > clearTime 5000 → 重新激活,生效
|
||||
assertTrue(validator.isEffectiveTrigger(cache, 6000L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsEffectiveTrigger_activeAlarm_anyMessage_returnTrue() {
|
||||
AlarmCacheState cache = AlarmCacheState.builder()
|
||||
.clearState(0)
|
||||
.alarmTime(1000L)
|
||||
.clearTime(0L)
|
||||
.build();
|
||||
// 活跃告警,任何时间戳都接受
|
||||
assertTrue(validator.isEffectiveTrigger(cache, 500L));
|
||||
assertTrue(validator.isEffectiveTrigger(cache, 2000L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsEffectiveTrigger_nullCache_returnTrue() {
|
||||
// 首次触发,无缓存
|
||||
assertTrue(validator.isEffectiveTrigger(null, 1000L));
|
||||
}
|
||||
|
||||
// ==================== isEffectiveClear ====================
|
||||
|
||||
@Test
|
||||
void testIsEffectiveClear_activeAlarm_newMessage_returnTrue() {
|
||||
AlarmCacheState cache = AlarmCacheState.builder()
|
||||
.clearState(0)
|
||||
.alarmTime(2000L)
|
||||
.build();
|
||||
// 消息时间戳 3000 > alarmTime 2000 → 有效清除
|
||||
assertTrue(validator.isEffectiveClear(cache, 3000L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsEffectiveClear_alreadyCleared_returnFalse() {
|
||||
AlarmCacheState cache = AlarmCacheState.builder()
|
||||
.clearState(1)
|
||||
.alarmTime(2000L)
|
||||
.clearTime(5000L)
|
||||
.build();
|
||||
// 已清除,幂等忽略
|
||||
assertFalse(validator.isEffectiveClear(cache, 6000L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsEffectiveClear_activeAlarm_oldMessage_returnFalse() {
|
||||
AlarmCacheState cache = AlarmCacheState.builder()
|
||||
.clearState(0)
|
||||
.alarmTime(5000L)
|
||||
.build();
|
||||
// 消息时间戳 3000 < alarmTime 5000 → 旧消息,不清除
|
||||
assertFalse(validator.isEffectiveClear(cache, 3000L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsEffectiveClear_nullCache_returnFalse() {
|
||||
// cache 为 null 表示告警不存在,清除无效
|
||||
assertFalse(validator.isEffectiveClear(null, 1000L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsEffectiveClear_sameTimestamp_returnFalse() {
|
||||
AlarmCacheState cache = AlarmCacheState.builder()
|
||||
.clearState(0)
|
||||
.alarmTime(5000L)
|
||||
.build();
|
||||
// 严格大于,等于不生效
|
||||
assertFalse(validator.isEffectiveClear(cache, 5000L));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.viewsh.module.iot.service.alarm;
|
||||
|
||||
import com.viewsh.module.iot.dal.dataobject.alarm.IotAlarmRecordDO;
|
||||
import com.viewsh.module.iot.dal.mysql.alarm.IotAlarmRecordMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.redis.core.HashOperations;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* {@link IotAlarmCacheService} 单元测试
|
||||
*
|
||||
* <p>测试用例:
|
||||
* <ul>
|
||||
* <li>testGet_cacheHit:缓存命中,直接返回</li>
|
||||
* <li>testCacheMiss_reloadFromDB:cache miss → 从 DB 重建</li>
|
||||
* <li>testGet_cacheAndDbMiss:Redis + DB 均无 → 返回 null</li>
|
||||
* <li>testUpdateState:写入 Hash + 刷新 TTL</li>
|
||||
* <li>testEvict:删除 key</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author B14
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class IotAlarmCacheServiceTest {
|
||||
|
||||
@InjectMocks
|
||||
private IotAlarmCacheService cacheService;
|
||||
|
||||
@Mock
|
||||
private StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
@Mock
|
||||
private IotAlarmRecordMapper alarmRecordMapper;
|
||||
|
||||
@Mock
|
||||
@SuppressWarnings("rawtypes")
|
||||
private HashOperations hashOps;
|
||||
|
||||
@BeforeEach
|
||||
@SuppressWarnings("unchecked")
|
||||
void setUp() {
|
||||
// lenient: testEvict does not call opsForHash(), so this stub is not used in that test
|
||||
lenient().when(stringRedisTemplate.opsForHash()).thenReturn(hashOps);
|
||||
}
|
||||
|
||||
// ==================== testGet_cacheHit ====================
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void testGet_cacheHit() {
|
||||
Map<Object, Object> fields = new HashMap<>();
|
||||
fields.put("ack_state", "1");
|
||||
fields.put("clear_state", "1");
|
||||
fields.put("archived", "0");
|
||||
fields.put("severity", "3");
|
||||
fields.put("alarm_time", "1000");
|
||||
fields.put("clear_time", "2000");
|
||||
|
||||
when(hashOps.entries("iot:alarm:state:42")).thenReturn(fields);
|
||||
|
||||
AlarmCacheState state = cacheService.get(42L);
|
||||
|
||||
assertNotNull(state);
|
||||
assertEquals(1, state.getAckState());
|
||||
assertEquals(1, state.getClearState());
|
||||
assertEquals(0, state.getArchived());
|
||||
assertEquals(3, state.getSeverity());
|
||||
assertEquals(1000L, state.getAlarmTime());
|
||||
assertEquals(2000L, state.getClearTime());
|
||||
|
||||
// DB 不应被查询
|
||||
verify(alarmRecordMapper, never()).selectById(anyLong());
|
||||
}
|
||||
|
||||
// ==================== testCacheMiss_reloadFromDB ====================
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void testCacheMiss_reloadFromDB() {
|
||||
// Redis 返回空 map(cache miss)
|
||||
when(hashOps.entries(anyString())).thenReturn(Collections.emptyMap());
|
||||
|
||||
IotAlarmRecordDO record = IotAlarmRecordDO.builder()
|
||||
.id(99L)
|
||||
.ackState(0).clearState(0).archived(0).severity(2)
|
||||
.endTs(LocalDateTime.of(2024, 1, 1, 10, 0, 0))
|
||||
.build();
|
||||
when(alarmRecordMapper.selectById(99L)).thenReturn(record);
|
||||
|
||||
AlarmCacheState state = cacheService.get(99L);
|
||||
|
||||
assertNotNull(state);
|
||||
assertEquals(0, state.getAckState());
|
||||
assertEquals(0, state.getClearState());
|
||||
assertEquals(2, state.getSeverity());
|
||||
|
||||
// DB 被查询,且缓存被回填
|
||||
verify(alarmRecordMapper).selectById(99L);
|
||||
verify(hashOps).putAll(eq("iot:alarm:state:99"), anyMap());
|
||||
verify(stringRedisTemplate).expire(eq("iot:alarm:state:99"), eq(7L), eq(TimeUnit.DAYS));
|
||||
}
|
||||
|
||||
// ==================== testGet_cacheAndDbMiss ====================
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void testGet_cacheAndDbMiss() {
|
||||
when(hashOps.entries(anyString())).thenReturn(Collections.emptyMap());
|
||||
when(alarmRecordMapper.selectById(123L)).thenReturn(null);
|
||||
|
||||
AlarmCacheState state = cacheService.get(123L);
|
||||
|
||||
assertNull(state);
|
||||
}
|
||||
|
||||
// ==================== testUpdateState ====================
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void testUpdateState() {
|
||||
IotAlarmRecordDO record = IotAlarmRecordDO.builder()
|
||||
.id(55L)
|
||||
.ackState(1).clearState(0).archived(0).severity(4)
|
||||
.endTs(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
cacheService.updateState(55L, record);
|
||||
|
||||
verify(hashOps).putAll(eq("iot:alarm:state:55"), anyMap());
|
||||
verify(stringRedisTemplate).expire(eq("iot:alarm:state:55"), eq(7L), eq(TimeUnit.DAYS));
|
||||
}
|
||||
|
||||
// ==================== testEvict ====================
|
||||
|
||||
@Test
|
||||
void testEvict() {
|
||||
cacheService.evict(777L);
|
||||
verify(stringRedisTemplate).delete("iot:alarm:state:777");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.viewsh.module.iot.service.alarm;
|
||||
|
||||
import com.viewsh.framework.common.exception.ServiceException;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.redis.core.script.DefaultRedisScript;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
import static com.viewsh.module.iot.enums.ErrorCodeConstants.ALARM_LOCK_CONFLICT;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* {@link IotAlarmLockService} 单元测试
|
||||
*
|
||||
* <p>任务卡 B14 §6 测试用例:
|
||||
* <ul>
|
||||
* <li>testLock_acquireRelease:正常获取并释放</li>
|
||||
* <li>testLock_conflict:锁被占用时抛 ALARM_LOCK_CONFLICT</li>
|
||||
* <li>testLock_releaseOtherToken:Lua 脚本返回 0(token 不匹配),warn 日志但不抛异常</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author B14
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class IotAlarmLockServiceTest {
|
||||
|
||||
@InjectMocks
|
||||
private IotAlarmLockService lockService;
|
||||
|
||||
@Mock
|
||||
private StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
@Mock
|
||||
private ValueOperations<String, String> valueOps;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
when(stringRedisTemplate.opsForValue()).thenReturn(valueOps);
|
||||
}
|
||||
|
||||
// ==================== testLock_acquireRelease ====================
|
||||
|
||||
/**
|
||||
* 正常获取锁 + 执行 action + 释放锁(Lua 返回 1)
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void testLock_acquireRelease() {
|
||||
// SET NX 成功
|
||||
when(valueOps.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(true);
|
||||
// Lua 解锁返回 1(成功删除)
|
||||
when(stringRedisTemplate.execute(any(DefaultRedisScript.class), anyList(), any())).thenReturn(1L);
|
||||
|
||||
String result = lockService.executeWithLock(100L, Duration.ofSeconds(5), () -> "ok");
|
||||
|
||||
assertEquals("ok", result);
|
||||
verify(valueOps).setIfAbsent(eq("iot:alarm:lock:100"), anyString(), eq(Duration.ofSeconds(5)));
|
||||
// Lua 解锁被执行
|
||||
verify(stringRedisTemplate).execute(
|
||||
any(DefaultRedisScript.class),
|
||||
eq(List.of("iot:alarm:lock:100")),
|
||||
anyString()
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== testLock_conflict ====================
|
||||
|
||||
/**
|
||||
* SET NX 返回 false(另一个进程持有锁)→ 抛 ALARM_LOCK_CONFLICT
|
||||
*/
|
||||
@Test
|
||||
void testLock_conflict() {
|
||||
// setIfAbsent 返回 false → 获取锁失败
|
||||
when(valueOps.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(false);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> lockService.executeWithLock(200L, Duration.ofSeconds(5), () -> "should not run"));
|
||||
|
||||
assertEquals(ALARM_LOCK_CONFLICT.getCode(), ex.getCode());
|
||||
// action 未执行,也不应尝试解锁
|
||||
verify(stringRedisTemplate, never()).execute(any(DefaultRedisScript.class), anyList(), anyString());
|
||||
}
|
||||
|
||||
// ==================== testLock_releaseOtherToken ====================
|
||||
|
||||
/**
|
||||
* Lua 脚本返回 0(token 不匹配:锁已过期,B 拿到了),A 释放时不删 B 的锁,仅打 warn 日志
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void testLock_releaseOtherToken() {
|
||||
// 获取锁成功
|
||||
when(valueOps.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(true);
|
||||
// Lua 返回 0 → token 不匹配
|
||||
when(stringRedisTemplate.execute(any(DefaultRedisScript.class), anyList(), any())).thenReturn(0L);
|
||||
|
||||
// 不应抛异常(仅 warn 日志,不影响业务)
|
||||
assertDoesNotThrow(() -> lockService.executeWithLock(300L, Duration.ofSeconds(5), () -> "done"));
|
||||
|
||||
// Lua 脚本被调用了
|
||||
verify(stringRedisTemplate).execute(
|
||||
any(DefaultRedisScript.class),
|
||||
eq(List.of("iot:alarm:lock:300")),
|
||||
anyString()
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== action 异常时也要释放锁 ====================
|
||||
|
||||
/**
|
||||
* action 抛出异常时,finally 块中仍应执行 Lua 解锁
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void testLock_releaseOnException() {
|
||||
when(valueOps.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(true);
|
||||
when(stringRedisTemplate.execute(any(DefaultRedisScript.class), anyList(), any())).thenReturn(1L);
|
||||
|
||||
RuntimeException thrown = assertThrows(RuntimeException.class,
|
||||
() -> lockService.executeWithLock(400L, Duration.ofSeconds(5), () -> {
|
||||
throw new RuntimeException("action failed");
|
||||
}));
|
||||
|
||||
assertEquals("action failed", thrown.getMessage());
|
||||
// 即使 action 失败,锁也应被释放
|
||||
verify(stringRedisTemplate).execute(
|
||||
any(DefaultRedisScript.class),
|
||||
eq(List.of("iot:alarm:lock:400")),
|
||||
anyString()
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -18,6 +18,9 @@ import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static com.viewsh.module.iot.enums.ErrorCodeConstants.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
@@ -25,9 +28,12 @@ import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* {@link IotAlarmRecordServiceImpl} 单元测试(覆盖任务卡 B12 §6.1 的 10 个用例)。
|
||||
* {@link IotAlarmRecordServiceImpl} 单元测试(覆盖任务卡 B12 §6.1 + B14 集成路径)。
|
||||
*
|
||||
* @author B12
|
||||
* <p>B14 新增依赖({@link IotAlarmLockService}/{@link IotAlarmCacheService}/{@link AlarmStateValidator})
|
||||
* 均通过 Mockito @Mock 注入,锁默认透传执行(执行 Supplier),不模拟冲突(冲突用例在专项测试中)。
|
||||
*
|
||||
* @author B12 / B14
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class IotAlarmRecordServiceImplTest {
|
||||
@@ -38,6 +44,15 @@ class IotAlarmRecordServiceImplTest {
|
||||
@Mock
|
||||
private IotAlarmRecordMapper alarmRecordMapper;
|
||||
|
||||
@Mock
|
||||
private IotAlarmLockService lockService;
|
||||
|
||||
@Mock
|
||||
private IotAlarmCacheService cacheService;
|
||||
|
||||
@Mock
|
||||
private AlarmStateValidator validator;
|
||||
|
||||
private MockedStatic<TenantContextHolder> tenantMock;
|
||||
|
||||
private static final Long TENANT_ID = 1L;
|
||||
@@ -45,9 +60,19 @@ class IotAlarmRecordServiceImplTest {
|
||||
private static final Long CONFIG_ID = 200L;
|
||||
|
||||
@BeforeEach
|
||||
@SuppressWarnings("unchecked")
|
||||
void setUp() {
|
||||
tenantMock = mockStatic(TenantContextHolder.class);
|
||||
tenantMock.when(TenantContextHolder::getTenantId).thenReturn(TENANT_ID);
|
||||
|
||||
// 默认:lockService 直接执行 Supplier(无锁冲突)— lenient 避免部分测试不触发锁时报 UnnecessaryStubbing
|
||||
lenient().doAnswer(inv -> {
|
||||
Supplier<?> supplier = inv.getArgument(2);
|
||||
return supplier.get();
|
||||
}).when(lockService).executeWithLock(any(), any(Duration.class), any(Supplier.class));
|
||||
|
||||
// 默认:validator 允许所有操作 — lenient 避免首次触发路径不调用 validator 时报 UnnecessaryStubbing
|
||||
lenient().when(validator.isEffectiveTrigger(any(), anyLong())).thenReturn(true);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
@@ -91,6 +116,8 @@ class IotAlarmRecordServiceImplTest {
|
||||
.build();
|
||||
when(alarmRecordMapper.selectActiveByDeviceAndConfig(DEVICE_ID, CONFIG_ID, TENANT_ID))
|
||||
.thenReturn(existing);
|
||||
// 锁内再查最新 DO
|
||||
when(alarmRecordMapper.selectById(555L)).thenReturn(existing);
|
||||
|
||||
Long id = alarmService.triggerAlarm(buildTriggerReq(3));
|
||||
|
||||
@@ -260,8 +287,9 @@ class IotAlarmRecordServiceImplTest {
|
||||
|
||||
alarmService.ackAlarm(AlarmStateTransitionRequest.builder().alarmId(17L).build());
|
||||
|
||||
// 已确认 → no-op
|
||||
// 已确认 → no-op,不走锁
|
||||
verify(alarmRecordMapper, never()).updateById(any(IotAlarmRecordDO.class));
|
||||
verify(lockService, never()).executeWithLock(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -273,6 +301,64 @@ class IotAlarmRecordServiceImplTest {
|
||||
assertEquals(ALARM_RECORD_NOT_EXISTS.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
// ==================== B14 新增:旧消息忽略测试 ====================
|
||||
|
||||
/**
|
||||
* testTrigger_oldMessage:消息时间戳早于 clear_time,validator 返回 false → 不更新状态
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void testTrigger_oldMessage() {
|
||||
IotAlarmRecordDO existing = IotAlarmRecordDO.builder()
|
||||
.id(888L).deviceId(DEVICE_ID).alarmConfigId(CONFIG_ID)
|
||||
.ackState(0).clearState(1).archived(0).triggerCount(5)
|
||||
.build();
|
||||
when(alarmRecordMapper.selectActiveByDeviceAndConfig(DEVICE_ID, CONFIG_ID, TENANT_ID))
|
||||
.thenReturn(existing);
|
||||
|
||||
// validator 判断为旧消息
|
||||
when(validator.isEffectiveTrigger(any(), anyLong())).thenReturn(false);
|
||||
|
||||
Long id = alarmService.triggerAlarm(buildTriggerReq(3));
|
||||
|
||||
assertEquals(888L, id);
|
||||
// 旧消息不触发 DB 写
|
||||
verify(alarmRecordMapper, never()).updateById(any(IotAlarmRecordDO.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* testTrigger_cacheUpdate:trigger 成功后缓存被更新
|
||||
*/
|
||||
@Test
|
||||
void testTrigger_cacheUpdate() {
|
||||
IotAlarmRecordDO existing = IotAlarmRecordDO.builder()
|
||||
.id(777L).deviceId(DEVICE_ID).alarmConfigId(CONFIG_ID)
|
||||
.ackState(0).clearState(0).archived(0).triggerCount(1)
|
||||
.build();
|
||||
when(alarmRecordMapper.selectActiveByDeviceAndConfig(DEVICE_ID, CONFIG_ID, TENANT_ID))
|
||||
.thenReturn(existing);
|
||||
when(alarmRecordMapper.selectById(777L)).thenReturn(existing);
|
||||
when(validator.isEffectiveTrigger(any(), anyLong())).thenReturn(true);
|
||||
|
||||
alarmService.triggerAlarm(buildTriggerReq(3));
|
||||
|
||||
// 缓存应被更新
|
||||
verify(cacheService, atLeastOnce()).updateState(eq(777L), any(IotAlarmRecordDO.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* testArchive_cacheEvict:归档后缓存被驱逐
|
||||
*/
|
||||
@Test
|
||||
void testArchive_cacheEvict() {
|
||||
IotAlarmRecordDO active = activeAlarm(666L);
|
||||
when(alarmRecordMapper.selectById(666L)).thenReturn(active);
|
||||
|
||||
alarmService.archiveAlarm(AlarmStateTransitionRequest.builder().alarmId(666L).build());
|
||||
|
||||
verify(cacheService).evict(666L);
|
||||
}
|
||||
|
||||
// ==================== 辅助 ====================
|
||||
|
||||
private AlarmTriggerRequest buildTriggerReq(int severity) {
|
||||
|
||||
Reference in New Issue
Block a user