|
|
@@ -0,0 +1,394 @@
|
|
|
+package com.etotem.cfc.unit;
|
|
|
+
|
|
|
+import com.etotem.cfc.entity.EnergyBalance;
|
|
|
+import com.etotem.cfc.entity.EnergyDimension;
|
|
|
+import com.etotem.cfc.entity.EnergyLog;
|
|
|
+import com.etotem.cfc.entity.EnergyRule;
|
|
|
+import com.etotem.cfc.entity.FamilyMember;
|
|
|
+import com.etotem.cfc.mapper.EnergyBalanceMapper;
|
|
|
+import com.etotem.cfc.mapper.EnergyBehaviorConfigMapper;
|
|
|
+import com.etotem.cfc.mapper.EnergyDimensionMapper;
|
|
|
+import com.etotem.cfc.mapper.EnergyLogMapper;
|
|
|
+import com.etotem.cfc.mapper.EnergyRuleMapper;
|
|
|
+import com.etotem.cfc.mapper.EnergySourceConfigMapper;
|
|
|
+import com.etotem.cfc.mapper.FamilyMemberMapper;
|
|
|
+import com.etotem.cfc.mapper.WishMapper;
|
|
|
+import com.etotem.cfc.service.EnergyRuleEngine;
|
|
|
+import com.etotem.cfc.service.EnergyService;
|
|
|
+import com.etotem.cfc.service.NotificationDispatchService;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.mockito.ArgumentCaptor;
|
|
|
+
|
|
|
+import java.lang.reflect.Method;
|
|
|
+import java.lang.reflect.Modifier;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+import static org.junit.jupiter.api.Assertions.fail;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.ArgumentMatchers.anyInt;
|
|
|
+import static org.mockito.ArgumentMatchers.anyLong;
|
|
|
+import static org.mockito.Mockito.atLeastOnce;
|
|
|
+import static org.mockito.Mockito.mock;
|
|
|
+import static org.mockito.Mockito.never;
|
|
|
+import static org.mockito.Mockito.times;
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 契约级单测:五维能量规则引擎实时触发写入(能力键 {@code energy.ruleEngine.apply},任务 S-cfc-304,TDD-RED 阶段)。
|
|
|
+ *
|
|
|
+ * <p><b>需求/设计溯源</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li>需求:{@code tests/user-scenarios/应用场景总览.md} 业务域 C-2——
|
|
|
+ * SC-P-87(任务完成触发五维能量按规则归因增长)、SC-P-88(心愿兑换扣减五维能量余额不为负)、
|
|
|
+ * SC-P-90(五维每日获取上限 DailyLimit)、SC-P-93(文章互动与活动参与触发五维能量);
|
|
|
+ * 叙事 {@code tests/user-scenarios/07-five-dimension.md} 场景 ED-1(任务归因)/ED-2(心愿扣减)/
|
|
|
+ * ED-4(日上限)/ED-7(文章·活动);
|
|
|
+ * 用例 {@code tests/test-cases/parent.md} TC-P-87 / TC-P-88 / TC-P-90 / TC-P-93(含 -E 异常流 / -P 权限流)。</li>
|
|
|
+ * <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.5(/api/energy/logs 流水写盘依据——
|
|
|
+ * {@code sourceType}/{@code sourceId}/{@code dimensionId}/{@code amount}/{@code balanceAfter});
|
|
|
+ * 模块 {@link EnergyRuleEngine}(事件→规则匹配→归因维度+数值)/ {@link EnergyBalance}(余额账本)/
|
|
|
+ * {@code DimensionDailyLimit}(日上限,S-cfc-306 同链)。</li>
|
|
|
+ * <li><b>职责边界</b>:本键 = <b>实时触发写入(事前/事中)</b>——事件触发时规则引擎把事件映射为
|
|
|
+ * 归因维度+数值并写 {@code EnergyLog}/{@code EnergyBalance}(SC-P-87/88/90/93 的写入路径);
|
|
|
+ * <b>事后校验</b>(流水对账/唯一键/归属守卫)由 {@code energy.ledger.guard}(S-cfc-225,
|
|
|
+ * EnergyLedgerGuardContractTest)覆盖;<b>服务侧日上限守卫入口</b>(public guard + 配置驱动)由
|
|
|
+ * {@code energy.dailyLimit.guard}(S-cfc-306,EnergyDailyLimitGuardContractTest)覆盖。
|
|
|
+ * 本键只锚 apply 写路径自身:入口/可观测结果/归因生效/幂等/异常告警传播/扣减拒绝,与二者互补不重复。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>被校验的契约行为(TDD-RED 锚点,当前实现未落地 → 红)</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li><b>apply 入口(能力键名)</b>:{@link EnergyRuleEngine} 应暴露 public {@code apply(...)} 实时触发入口
|
|
|
+ * (事件→规则匹配→归因维度+数值→写 EnergyLog/EnergyBalance)——当前仅有 {@code evaluate(...)}(反射结构探测);</li>
|
|
|
+ * <li><b>返回可观测结果(SC-P-87/90,事前·事中可感知)</b>:{@code apply} 应返回非 void 结果
|
|
|
+ * (归因维度→amount、拒绝原因、日上限触顶标记「已达今日上限」),调用方(TaskController/ArticleService/
|
|
|
+ * ActivityService)可感知写入结果——当前 {@code evaluate} 返回 void,日上限触顶(ED-4)、拒绝均不可观测;</li>
|
|
|
+ * <li><b>规则归因生效(SC-P-87 主流程)</b>:规则声明 {@code dimensionCode=body} 时,apply 应把能量写入 body
|
|
|
+ * 维度({@code EnergyLog.dimensionId=body})——当前 {@code evaluate} increase 分支把规则解析出的 dimId
|
|
|
+ * 丢弃、直接 {@code awardEnergy},无 behavior/source 配置时回落默认 action(ED-1「系统按 EnergyRuleEngine
|
|
|
+ * 将任务类型映射到的维度增加 amount」不成立);</li>
|
|
|
+ * <li><b>文章互动归因(SC-P-93/ED-7)</b>:{@code eventType=article} 规则声明 {@code dimensionCode=mind} 时
|
|
|
+ * 写 mind 维度——当前同样回落 action(行为断言);</li>
|
|
|
+ * <li><b>同源重复触发幂等(SC-P-87 边界/ED-1)</b>:同一 {@code (memberId, sourceType=task, sourceId)}
|
|
|
+ * 重复触发(重复审批/网络重试)只写一次 {@code EnergyLog}——当前 {@code evaluate} 每次直接 award,
|
|
|
+ * 无写路径幂等(行为断言;schema 侧幂等唯一键由 S-cfc-225 覆盖);</li>
|
|
|
+ * <li><b>规则计算异常告警传播(SC-P-87-E-4/ED-1)</b>:写入层/规则计算异常应传播给调用方(可告警、可回滚),
|
|
|
+ * 不得静默吞掉——当前 {@code evaluate} 内部 {@code catch (Exception e) { log.error(...) }} 吞掉继续;</li>
|
|
|
+ * <li><b>amount null/负拒绝(SC-P-87-E-4)</b>:规则 {@code amount} 缺失/负属配置异常,应拒绝并告警——
|
|
|
+ * 当前 {@code calculateAmount} 对 null 返回 0 静默 {@code continue};</li>
|
|
|
+ * <li><b>余额不足可感知拒绝(SC-P-88-E-1/ED-2)</b>:decrease 规则在余额不足时应向调用方暴露拒绝
|
|
|
+ * (异常/结果标记「能量不足」),不得静默忽略 {@code deductEnergy} 的 -1——当前 {@code evaluate}
|
|
|
+ * 忽略返回且不抛错(余额不为负本身由 {@code EnergyService.deductEnergy} 保证,本键锚调用方可感知性)。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>正常流锚点(已实现 → 绿,如实记录非 RED)</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li>无匹配规则的事件:{@code evaluate} 直接返回、不写任何流水;</li>
|
|
|
+ * <li>归因写入的流水承载 {@code sourceType=task}/{@code sourceId=任务实例ID}(/api/energy/logs 写盘依据)。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>权限流(TC-P-87-P/88-P/90-P/93-P)</b>:跨角色/跨家庭能量访问 403 由
|
|
|
+ * {@link EnergyIsolationContractTest}(数据隔离)覆盖、日上限配置越权由
|
|
|
+ * {@link EnergyConfigAdminContractTest}(admin 守卫)覆盖,本键为内部触发写入路径,权限流不适用(说明性)。</p>
|
|
|
+ *
|
|
|
+ * <p><b>RED 预期</b>:8 条契约缺口断言失败(红,失败原文含 {@code RED[energy.ruleEngine.apply] …})、
|
|
|
+ * 2 条正常流锚点通过(绿)、1 条权限流标注(绿,说明性)。GREEN 阶段按 SC-P-87/88/90/93 落地
|
|
|
+ * {@code apply}(public 入口 + 可观测结果 + 规则归因生效 + 写路径幂等 + 异常告警传播 + 扣减拒绝感知)后自动转绿。</p>
|
|
|
+ *
|
|
|
+ * <p><b>测试范围</b>:纯 Mockito / 无 Spring 上下文 / 无 DB / 无外部服务;直接实例化
|
|
|
+ * {@link EnergyRuleEngine} 注入 mock {@link EnergyRuleMapper}/{@link EnergyDimensionMapper},并把
|
|
|
+ * mock 依赖注入的 {@link EnergyService} 实例作为 {@code energyService} 字段注入引擎
|
|
|
+ * (复用 {@link EnergyContractTestBase#inject}/{@link EnergyContractTestBase#fiveDims}),
|
|
|
+ * 反射结构探测 + 行为断言(写入捕获/异常传播/幂等计数)。</p>
|
|
|
+ *
|
|
|
+ * <p><b>本类与同族能量测试互补清单</b>:
|
|
|
+ * {@code energy.ledger.guard}(S-cfc-225)= 事后对账/唯一键/归属守卫;
|
|
|
+ * {@code energy.dailyLimit.guard}(S-cfc-306)= 服务侧 public 守卫入口 + 配置驱动;
|
|
|
+ * {@code energy.fiveDim.view}/view 查看口径由 EnergyFiveDimViewContractTest 覆盖;
|
|
|
+ * 本键 {@code energy.ruleEngine.apply}(S-cfc-304)= 实时触发写入(事前/事中)。</p>
|
|
|
+ */
|
|
|
+class EnergyRuleEngineApplyContractTest extends EnergyContractTestBase {
|
|
|
+
|
|
|
+ private static final Long MEMBER_ID = 50L;
|
|
|
+ private static final Long TASK_ID = 100L;
|
|
|
+
|
|
|
+ private EnergyRuleEngine engine;
|
|
|
+ private EnergyService service;
|
|
|
+
|
|
|
+ private EnergyRuleMapper ruleMapper;
|
|
|
+ private EnergyDimensionMapper dimensionMapper;
|
|
|
+ private EnergyBehaviorConfigMapper behaviorConfigMapper;
|
|
|
+ private EnergyLogMapper logMapper;
|
|
|
+ private EnergyBalanceMapper balanceMapper;
|
|
|
+ private FamilyMemberMapper familyMemberMapper;
|
|
|
+ private EnergySourceConfigMapper sourceConfigMapper;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() throws Exception {
|
|
|
+ engine = new EnergyRuleEngine();
|
|
|
+ service = new EnergyService();
|
|
|
+
|
|
|
+ ruleMapper = mock(EnergyRuleMapper.class);
|
|
|
+ dimensionMapper = mock(EnergyDimensionMapper.class);
|
|
|
+ behaviorConfigMapper = mock(EnergyBehaviorConfigMapper.class);
|
|
|
+ logMapper = mock(EnergyLogMapper.class);
|
|
|
+ balanceMapper = mock(EnergyBalanceMapper.class);
|
|
|
+ familyMemberMapper = mock(FamilyMemberMapper.class);
|
|
|
+ sourceConfigMapper = mock(EnergySourceConfigMapper.class);
|
|
|
+
|
|
|
+ inject(engine, "energyRuleMapper", ruleMapper);
|
|
|
+ inject(engine, "energyDimensionMapper", dimensionMapper);
|
|
|
+ inject(engine, "energyService", service);
|
|
|
+
|
|
|
+ inject(service, "energyDimensionMapper", dimensionMapper);
|
|
|
+ inject(service, "energyBehaviorConfigMapper", behaviorConfigMapper);
|
|
|
+ inject(service, "energyLogMapper", logMapper);
|
|
|
+ inject(service, "energyBalanceMapper", balanceMapper);
|
|
|
+ inject(service, "familyMemberMapper", familyMemberMapper);
|
|
|
+ inject(service, "energySourceConfigMapper", sourceConfigMapper);
|
|
|
+ inject(service, "wishMapper", mock(WishMapper.class));
|
|
|
+ inject(service, "notificationDispatchService", mock(NotificationDispatchService.class));
|
|
|
+
|
|
|
+ when(dimensionMapper.selectList(any())).thenReturn(Arrays.asList(fiveDims()));
|
|
|
+ when(dimensionMapper.selectOne(any())).thenReturn(null);
|
|
|
+ when(behaviorConfigMapper.selectOne(any())).thenReturn(null);
|
|
|
+ when(logMapper.selectList(any())).thenReturn(Collections.emptyList());
|
|
|
+ when(balanceMapper.selectOne(any())).thenReturn(null);
|
|
|
+
|
|
|
+ FamilyMember member = new FamilyMember();
|
|
|
+ member.setId(MEMBER_ID);
|
|
|
+ member.setUserId(12L);
|
|
|
+ when(familyMemberMapper.selectById(any())).thenReturn(member);
|
|
|
+
|
|
|
+ initTable(EnergyDimension.class);
|
|
|
+ initTable(EnergyLog.class);
|
|
|
+ initTable(EnergyBalance.class);
|
|
|
+ initTable(EnergyRule.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== RED-1/2:apply 入口 + 可观测结果(SC-P-87/90,事前·事中可感知) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("RED:EnergyRuleEngine 应暴露 public apply 实时触发入口(能力键名)")
|
|
|
+ void RED_apply入口_应存在public方法() {
|
|
|
+ assertTrue(findPublicApply() != null,
|
|
|
+ "RED[energy.ruleEngine.apply]:EnergyRuleEngine 应暴露 public apply(...) 实时触发入口"
|
|
|
+ + "(事件→规则匹配→归因维度+数值→写 EnergyLog/EnergyBalance,SC-P-87 主流程);"
|
|
|
+ + "当前仅有 evaluate(...),能力键 apply 未落地,调用方只能依赖无返回、吞异常的 evaluate。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("RED:apply 应返回可观测结果(归因/拒绝/日上限触顶可感知,SC-P-87/90)")
|
|
|
+ void RED_apply结果_非void可观测() {
|
|
|
+ Method apply = findPublicApply();
|
|
|
+ if (apply == null) {
|
|
|
+ fail("RED[energy.ruleEngine.apply]:apply 入口缺失 → 调用方必然无法拿到归因/拒绝/触顶结果"
|
|
|
+ + "(SC-P-87/ED-1 归因可核对、SC-P-90/ED-4『已达今日上限』触顶展示、SC-P-88『能量不足』拒绝文案),"
|
|
|
+ + "当前 evaluate 返回 void 且吞异常,触发侧(TaskController/ArticleService/ActivityService)"
|
|
|
+ + "无法感知写入结果。");
|
|
|
+ }
|
|
|
+ assertTrue(apply.getReturnType() != void.class && apply.getReturnType() != Void.class,
|
|
|
+ "RED[energy.ruleEngine.apply]:apply 应返回非 void 结果(Map<dimensionCode,amount> 或带"
|
|
|
+ + "capped/rejected 标记的结果对象),使日上限触顶(ED-4)、余额不足拒绝(ED-2)可被调用方感知;"
|
|
|
+ + "当前 evaluate 返回 void,触顶/拒绝均不可观测。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== RED-3/4:规则归因生效(SC-P-87/93,ED-1/ED-7 主流程) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("RED:任务事件规则声明归因维度 body → EnergyLog 应写 body 维度(SC-P-87)")
|
|
|
+ void RED_归因_任务规则写body维度() {
|
|
|
+ EnergyRule rule = rule(1L, "task", "increase", "body", 10, 1);
|
|
|
+ when(ruleMapper.selectList(any())).thenReturn(Collections.singletonList(rule));
|
|
|
+ when(dimensionMapper.selectOne(any())).thenReturn(dim(5L, "body", "身体", "土"));
|
|
|
+
|
|
|
+ engine.evaluate("task", TASK_ID, MEMBER_ID, new HashMap<>());
|
|
|
+
|
|
|
+ List<EnergyLog> written = captureInsertedLogs();
|
|
|
+ boolean wroteBody = written.stream().anyMatch(l ->
|
|
|
+ l.getDimensionId() != null && l.getDimensionId().longValue() == 5L);
|
|
|
+ assertTrue(wroteBody,
|
|
|
+ "RED[energy.ruleEngine.apply]:任务事件规则声明归因维度 body(id=5),按规则应把能量写入 body 维度"
|
|
|
+ + "(ED-1 主流程 2『系统按 EnergyRuleEngine 将任务类型映射到的维度增加 amount』+ 主流程 3 写"
|
|
|
+ + " EnergyLog.dimensionId)。当前 evaluate 的 increase 分支把规则解析出的 dimId 丢弃、直接"
|
|
|
+ + " awardEnergy → 无 behavior/source 配置时回落默认 action(id=3)维度,实际写入 dimensionId="
|
|
|
+ + written.get(0).getDimensionId() + ",规则归因维度未生效(SC-P-87 缺口)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("RED:文章互动事件规则声明归因维度 mind → EnergyLog 应写 mind 维度(SC-P-93/ED-7)")
|
|
|
+ void RED_归因_文章互动规则写mind维度() {
|
|
|
+ EnergyRule rule = rule(2L, "article", "increase", "mind", 5, 1);
|
|
|
+ when(ruleMapper.selectList(any())).thenReturn(Collections.singletonList(rule));
|
|
|
+ when(dimensionMapper.selectOne(any())).thenReturn(dim(4L, "mind", "心智", "火"));
|
|
|
+
|
|
|
+ engine.evaluate("article", 200L, MEMBER_ID, new HashMap<>());
|
|
|
+
|
|
|
+ List<EnergyLog> written = captureInsertedLogs();
|
|
|
+ boolean wroteMind = written.stream().anyMatch(l ->
|
|
|
+ l.getDimensionId() != null && l.getDimensionId().longValue() == 4L);
|
|
|
+ assertTrue(wroteMind,
|
|
|
+ "RED[energy.ruleEngine.apply]:文章互动事件(SC-P-93/ED-7 主流程 1『发布/评论文章…触发 EnergyRuleEngine"
|
|
|
+ + " 对应维度 +amount』)规则声明归因维度 mind(id=4),应写入 mind 维度;当前回落默认 action"
|
|
|
+ + "(id=3)维度,实际写入 dimensionId=" + written.get(0).getDimensionId()
|
|
|
+ + ",文章互动归因维度未生效(SC-P-93 缺口)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== RED-5:同源重复触发幂等(SC-P-87 边界/ED-1) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("RED:同 member+task+sourceId 重复触发只写一次 EnergyLog(重复审批幂等)")
|
|
|
+ void RED_幂等_同源重复触发只写一次() {
|
|
|
+ EnergyRule rule = rule(3L, "task", "increase", "body", 1, 1);
|
|
|
+ when(ruleMapper.selectList(any())).thenReturn(Collections.singletonList(rule));
|
|
|
+ when(dimensionMapper.selectOne(any())).thenReturn(dim(5L, "body", "身体", "土"));
|
|
|
+
|
|
|
+ engine.evaluate("task", TASK_ID, MEMBER_ID, new HashMap<>());
|
|
|
+ engine.evaluate("task", TASK_ID, MEMBER_ID, new HashMap<>());
|
|
|
+
|
|
|
+ verify(logMapper, times(1)).insert(any(EnergyLog.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== RED-6/7:规则计算异常告警(SC-P-87-E-4/ED-1 边界) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("RED:写入层异常应传播给调用方(可告警),不得静默吞掉")
|
|
|
+ void RED_告警_写入异常应传播() throws Exception {
|
|
|
+ // 注入一个写入即抛的 mock EnergyService:契约要求 apply 把异常传播给调用方(TaskController 可告警/回滚)
|
|
|
+ EnergyService throwing = mock(EnergyService.class);
|
|
|
+ when(throwing.awardEnergy(anyLong(), any(), any(), anyInt(), any(), any()))
|
|
|
+ .thenThrow(new RuntimeException("写入层故障: energy_balance update failed"));
|
|
|
+
|
|
|
+ EnergyRuleEngine engineWithThrowing = new EnergyRuleEngine();
|
|
|
+ inject(engineWithThrowing, "energyRuleMapper", ruleMapper);
|
|
|
+ inject(engineWithThrowing, "energyDimensionMapper", dimensionMapper);
|
|
|
+ inject(engineWithThrowing, "energyService", throwing);
|
|
|
+
|
|
|
+ EnergyRule rule = rule(4L, "task", "increase", "body", 10, 1);
|
|
|
+ when(ruleMapper.selectList(any())).thenReturn(Collections.singletonList(rule));
|
|
|
+ when(dimensionMapper.selectOne(any())).thenReturn(dim(5L, "body", "身体", "土"));
|
|
|
+
|
|
|
+ assertThrows(RuntimeException.class,
|
|
|
+ () -> engineWithThrowing.evaluate("task", TASK_ID, MEMBER_ID, new HashMap<>()),
|
|
|
+ "RED[energy.ruleEngine.apply]:写入层异常应传播给调用方(SC-P-87-E-4『规则计算异常告警、不写脏流水』"
|
|
|
+ + "——调用方可感知失败并告警/回滚)。当前 evaluate 内部 catch(Exception) 仅 log.error 吞掉异常,"
|
|
|
+ + "调用方(TaskController)无法得知写入失败,告警链路断点。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("RED:规则 amount 缺失/负值应拒绝并告警,不得静默跳过")
|
|
|
+ void RED_告警_amount缺失负值应拒绝() {
|
|
|
+ // 规则 amount 缺失(配置异常):合约 = 拒绝并告警;当前 calculateAmount 对 null 返回 0 → 静默 continue
|
|
|
+ EnergyRule rule = rule(5L, "task", "increase", "body", null, 1);
|
|
|
+ when(ruleMapper.selectList(any())).thenReturn(Collections.singletonList(rule));
|
|
|
+
|
|
|
+ assertThrows(IllegalArgumentException.class,
|
|
|
+ () -> engine.evaluate("task", TASK_ID, MEMBER_ID, new HashMap<>()),
|
|
|
+ "RED[energy.ruleEngine.apply]:规则 amount 缺失/负值属配置异常,契约要求拒绝并告警"
|
|
|
+ + "(SC-P-87-E-4:规则计算异常→拒绝执行并告警,不写脏流水);当前 calculateAmount 对 null"
|
|
|
+ + " 返回 0 后 evaluate 静默 continue,无任何拒绝/告警信号。");
|
|
|
+ verify(logMapper, never()).insert(any(EnergyLog.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== RED-8:余额不足可感知拒绝(SC-P-88-E-1/ED-2) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("RED:decrease 规则余额不足应向调用方暴露拒绝(不得静默忽略 deductEnergy 的 -1)")
|
|
|
+ void RED_扣减_余额不足应可感知() {
|
|
|
+ // 心愿兑换扣减场景(ED-2/SC-P-88):B 该维度 balance=0,decrease 规则 amount=10 → 余额不足
|
|
|
+ EnergyRule rule = rule(6L, "wish", "decrease", "body", 10, 1);
|
|
|
+ when(ruleMapper.selectList(any())).thenReturn(Collections.singletonList(rule));
|
|
|
+ when(dimensionMapper.selectOne(any())).thenReturn(dim(5L, "body", "身体", "土"));
|
|
|
+
|
|
|
+ assertThrows(IllegalStateException.class,
|
|
|
+ () -> engine.evaluate("wish", 300L, MEMBER_ID, new HashMap<>()),
|
|
|
+ "RED[energy.ruleEngine.apply]:心愿兑换扣减(SC-P-88-E-1『余额不足明确返回能量不足、心愿保持待审批、"
|
|
|
+ + "不出现负余额』)余额不足时,apply 应向调用方暴露拒绝信号(异常/结果标记),不得静默忽略"
|
|
|
+ + " deductEnergy 的 -1 返回值;当前 evaluate 忽略扣减返回且不抛错,兑换侧无法感知『能量不足』。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 正常流锚点(已实现 → 绿,如实记录非 RED) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("GREEN anchor:无匹配规则的事件不写任何流水")
|
|
|
+ void GREEN_无规则不写流水() {
|
|
|
+ when(ruleMapper.selectList(any())).thenReturn(Collections.emptyList());
|
|
|
+
|
|
|
+ engine.evaluate("task", TASK_ID, MEMBER_ID, new HashMap<>());
|
|
|
+
|
|
|
+ verify(logMapper, never()).insert(any(EnergyLog.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("GREEN anchor:归因写入的流水承载 sourceType=task/sourceId(/api/energy/logs 写盘依据)")
|
|
|
+ void GREEN_流水承载sourceType与sourceId() {
|
|
|
+ EnergyRule rule = rule(7L, "task", "increase", "body", 1, 1);
|
|
|
+ when(ruleMapper.selectList(any())).thenReturn(Collections.singletonList(rule));
|
|
|
+ when(dimensionMapper.selectOne(any())).thenReturn(dim(5L, "body", "身体", "土"));
|
|
|
+
|
|
|
+ engine.evaluate("task", TASK_ID, MEMBER_ID, new HashMap<>());
|
|
|
+
|
|
|
+ List<EnergyLog> written = captureInsertedLogs();
|
|
|
+ assertTrue(written.stream().anyMatch(l -> "task".equals(l.getSourceType())
|
|
|
+ && Long.valueOf(TASK_ID).equals(l.getSourceId())),
|
|
|
+ "GREEN anchor:归因写入的 EnergyLog 应承载 sourceType=task、sourceId=任务实例ID"
|
|
|
+ + "(ED-1 主流程 3 + API_REFERENCE §4.5 /api/energy/logs 写盘依据,当前已生效)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 权限流(TC-P-87-P/88-P/90-P/93-P,说明性) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("权限流:跨角色/跨家庭能量访问 403 由隔离/守卫测试覆盖,本键不适用")
|
|
|
+ void 权限流_不适用() {
|
|
|
+ // 契约 AC 为「事件→规则→写入」内部触发链路,无用户权限维度。
|
|
|
+ // TC-P-87-P/88-P/93-P(家长 A 查/改 F9 成员能量 403、孩子 B 调 grant/deduct 被拒)
|
|
|
+ // 由 EnergyIsolationContractTest(数据隔离)覆盖;TC-P-90-P(越权改 F9 DailyLimit 403)
|
|
|
+ // 由 EnergyConfigAdminContractTest(admin 守卫)覆盖。
|
|
|
+ assertTrue(true, "权限流不适用:energy.ruleEngine.apply 为内部实时触发写入路径,访问控制在 Controller 层。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 辅助 ====================
|
|
|
+
|
|
|
+ /** 反射寻找 public apply(...) 入口(当前不存在 → null) */
|
|
|
+ private static Method findPublicApply() {
|
|
|
+ for (Method m : EnergyRuleEngine.class.getDeclaredMethods()) {
|
|
|
+ if ("apply".equals(m.getName()) && Modifier.isPublic(m.getModifiers())) {
|
|
|
+ return m;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 捕获并返回本次发生的全部 EnergyLog.insert 参数 */
|
|
|
+ private List<EnergyLog> captureInsertedLogs() {
|
|
|
+ ArgumentCaptor<EnergyLog> captor = ArgumentCaptor.forClass(EnergyLog.class);
|
|
|
+ verify(logMapper, atLeastOnce()).insert(captor.capture());
|
|
|
+ return captor.getAllValues();
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 规则构造(status=1 active,conditions 空 → 无条件匹配) */
|
|
|
+ private static EnergyRule rule(Long id, String eventType, String action, String dimCode,
|
|
|
+ Integer amount, int priority) {
|
|
|
+ EnergyRule r = new EnergyRule();
|
|
|
+ r.setId(id);
|
|
|
+ r.setRuleCode("R" + id);
|
|
|
+ r.setRuleName("规则" + id);
|
|
|
+ r.setEventType(eventType);
|
|
|
+ r.setAction(action);
|
|
|
+ r.setDimensionCode(dimCode);
|
|
|
+ r.setAmount(amount);
|
|
|
+ r.setPriority(priority);
|
|
|
+ r.setStatus(1);
|
|
|
+ r.setDescription(eventType + "-" + dimCode + "归因");
|
|
|
+ return r;
|
|
|
+ }
|
|
|
+}
|