|
|
@@ -0,0 +1,383 @@
|
|
|
+package com.etotem.cfc.unit;
|
|
|
+
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.controller.EnergyController;
|
|
|
+import com.etotem.cfc.entity.EnergyBalance;
|
|
|
+import com.etotem.cfc.entity.EnergyDimension;
|
|
|
+import com.etotem.cfc.entity.EnergyLog;
|
|
|
+import com.etotem.cfc.entity.FamilyMember;
|
|
|
+import com.etotem.cfc.entity.GrowthTask;
|
|
|
+import com.etotem.cfc.entity.GrowthTaskLog;
|
|
|
+import com.etotem.cfc.entity.MiniGame;
|
|
|
+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.EnergySourceConfigMapper;
|
|
|
+import com.etotem.cfc.mapper.FamilyMemberMapper;
|
|
|
+import com.etotem.cfc.mapper.GameRecordMapper;
|
|
|
+import com.etotem.cfc.mapper.GrowthTaskLogMapper;
|
|
|
+import com.etotem.cfc.mapper.GrowthTaskMapper;
|
|
|
+import com.etotem.cfc.mapper.MiniGameMapper;
|
|
|
+import com.etotem.cfc.mapper.PointsLogMapper;
|
|
|
+import com.etotem.cfc.mapper.UserMapper;
|
|
|
+import com.etotem.cfc.mapper.WishMapper;
|
|
|
+import com.etotem.cfc.service.CfPathwayEarnService;
|
|
|
+import com.etotem.cfc.service.ContentDimensionThresholdService;
|
|
|
+import com.etotem.cfc.service.EnergyRuleEngine;
|
|
|
+import com.etotem.cfc.service.EnergyService;
|
|
|
+import com.etotem.cfc.service.FiveDimensionScoreService;
|
|
|
+import com.etotem.cfc.service.GameRecordService;
|
|
|
+import com.etotem.cfc.service.GrowthTaskService;
|
|
|
+import com.etotem.cfc.service.MiniGameService;
|
|
|
+import com.etotem.cfc.service.NotificationDispatchService;
|
|
|
+import com.etotem.cfc.service.UnlockGateService;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.springframework.data.redis.core.RedisTemplate;
|
|
|
+import org.springframework.data.redis.core.ZSetOperations;
|
|
|
+import org.springframework.web.bind.annotation.RequestAttribute;
|
|
|
+
|
|
|
+import java.lang.reflect.Method;
|
|
|
+import java.lang.reflect.Parameter;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.ArgumentMatchers.anyString;
|
|
|
+import static org.mockito.ArgumentMatchers.argThat;
|
|
|
+import static org.mockito.ArgumentMatchers.eq;
|
|
|
+import static org.mockito.ArgumentMatchers.isNull;
|
|
|
+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 child.energy.gain}(任务 S-cfc-224,TDD-RED 阶段)契约级单测。
|
|
|
+ *
|
|
|
+ * <p><b>需求/设计溯源</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-C-24(小游戏/任务得分 → 五维能量)+
|
|
|
+ * SC-C-25(方案任务打卡 → 五维能量);契约条款 {@code tests/test-cases/child.md}
|
|
|
+ * TC-C-24 / TC-C-24-E(异常分数拦截、重复上报幂等)/ TC-C-24-P(他人能量 403、孩子端禁
|
|
|
+ * grant/deduct)、TC-C-25 / TC-C-25-E(重复打卡幂等)/ TC-C-25-P。</li>
|
|
|
+ * <li>设计:{@code docs/superpowers/specs/2026-11-07-child-zone-contract.md} §5
|
|
|
+ * G-09(小游戏上报无幂等、无分数上限,{@code /api/game/record} 的 {@code pointsEarned}
|
|
|
+ * 前端可控——建议加 {@code (memberId,gameCode,date)} 幂等键、{@code score} 限 0~100、
|
|
|
+ * {@code pointsEarned} 改服务端计算)/
|
|
|
+ * G-10({@code /api/energy/overview}、{@code /logs} 无成员级/家庭级归属校验,可按传入
|
|
|
+ * {@code memberId} 跨家庭读取)。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>被校验的契约行为(TDD-RED 锚点,当前实现未落地 → 红)</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li><b>异常流(SC-C-24-E / G-09)</b>:{@link MiniGameService#completeGame} 对超上限(如
|
|
|
+ * {@code score=999999})/ 负分必须拦截(抛异常且不写能量)——当前 {@code calculatePoints}
|
|
|
+ * 无上限校验、所有分数均被折算发放;</li>
|
|
|
+ * <li><b>异常流(SC-C-24-E / G-09)</b>:{@link GameRecordService#saveRecord} 将客户端可控
|
|
|
+ * {@code pointsEarned} 经 {@code Math.abs} 原样作为能量额发放——必须改服务端计算并封顶;</li>
|
|
|
+ * <li><b>幂等流(SC-C-24-E / G-09)</b>:同 {@code (memberId, gameCode)} 连续上报只发一次能量——
|
|
|
+ * 当前每次上报均触发 {@code awardEnergy("game")};</li>
|
|
|
+ * <li><b>权限流(TC-C-24-P / G-10)</b>:{@code /api/energy/overview} 对跨家庭 {@code memberId}
|
|
|
+ * 必须 403——当前直接返回 200(同一缺口面由 ChildEnergyViewContractTest SC-C-06 钉住);</li>
|
|
|
+ * <li><b>权限流(TC-C-24-P)</b>:{@code grantEnergy} / {@code deductForWish} 必须含
|
|
|
+ * {@code @RequestAttribute("role")} 角色门禁(孩子端直接调被拒)——当前无(反射结构探测);</li>
|
|
|
+ * <li><b>守卫钩子(G-09)</b>:{@link EnergyRuleEngine} 应有分数校验/幂等守卫方法
|
|
|
+ * ({@code validate/guard/score} 命名)——当前无(反射结构探测)。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>正常流锚点(已实现 → 绿,如实记录非 RED)</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li>小游戏完成 → {@code awardEnergy(memberId,"game")} 触发链已存在({@link GameRecordService#saveRecord});</li>
|
|
|
+ * <li>{@link GrowthTaskService#claimReward} → {@code awardEnergy(childId,"growth_day")} 触发链已存在;
|
|
|
+ * SC-C-25 的 {@code TaskService.completeTask → awardEnergy("task")} 正常/幂等流已由
|
|
|
+ * PlanActivationExecutionContractTest 覆盖,本类不重复。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>RED 预期</b>:8 条契约缺口断言失败(红)、2 条正常流锚点通过(绿);失败原文含
|
|
|
+ * {@code RED[child.energy.gain] …}。GREEN 阶段按 G-09/G-10 建议落地后自动转绿。</p>
|
|
|
+ *
|
|
|
+ * <p><b>测试范围</b>:纯 Mockito / 无 Spring 上下文 / 无 DB / 无外部服务;直接实例化 Service/
|
|
|
+ * Controller 并注入 mock,行为断言 + 反射结构探测(复用
|
|
|
+ * {@link EnergyContractTestBase#inject}/{@link EnergyContractTestBase#initTable}/
|
|
|
+ * {@link EnergyContractTestBase#fiveDims})。</p>
|
|
|
+ */
|
|
|
+class ChildEnergyGainContractTest extends EnergyContractTestBase {
|
|
|
+
|
|
|
+ // ==================== 契约 fixture ====================
|
|
|
+
|
|
|
+ private static final Long MEMBER_ID = 50L;
|
|
|
+ private static final String GAME_CODE = "schulte";
|
|
|
+ private static final Long CALLER_FAMILY_ID = 1L;
|
|
|
+ /** 越权目标:不属于调用方家庭的另一成员。 */
|
|
|
+ private static final Long FOREIGN_MEMBER_ID = 999999L;
|
|
|
+
|
|
|
+ // ---- SC-C-24 小游戏链(真实 MiniGameService + 真实 GameRecordService + mock EnergyService)----
|
|
|
+ private MiniGameService miniGameService;
|
|
|
+ private GameRecordService gameRecordService;
|
|
|
+ private EnergyService gameEnergyService;
|
|
|
+ private MiniGameMapper miniGameMapper;
|
|
|
+ private FamilyMemberMapper gameFamilyMemberMapper;
|
|
|
+ private PointsLogMapper pointsLogMapper;
|
|
|
+
|
|
|
+ // ---- SC-C-25 成长任务链(真实 GrowthTaskService + mock 依赖)----
|
|
|
+ private GrowthTaskService growthTaskService;
|
|
|
+ private GrowthTaskMapper growthTaskMapper;
|
|
|
+ private GrowthTaskLogMapper growthTaskLogMapper;
|
|
|
+ private FamilyMemberMapper growthFamilyMemberMapper;
|
|
|
+ private EnergyService growthEnergyService;
|
|
|
+
|
|
|
+ // ---- EnergyController(真实 EnergyService + mock 依赖)----
|
|
|
+ private EnergyController controller;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() throws Exception {
|
|
|
+ buildGameChain();
|
|
|
+ buildGrowthChain();
|
|
|
+ buildController();
|
|
|
+ }
|
|
|
+
|
|
|
+ private void buildGameChain() throws Exception {
|
|
|
+ gameEnergyService = mock(EnergyService.class);
|
|
|
+ gameRecordService = new GameRecordService();
|
|
|
+ GameRecordMapper gameRecordMapper = mock(GameRecordMapper.class);
|
|
|
+ miniGameMapper = mock(MiniGameMapper.class);
|
|
|
+ RedisTemplate<String, Object> redisTemplate = mock(RedisTemplate.class);
|
|
|
+ ZSetOperations<String, Object> zsetOps = mock(ZSetOperations.class);
|
|
|
+ when(redisTemplate.opsForZSet()).thenReturn(zsetOps);
|
|
|
+
|
|
|
+ inject(gameRecordService, "gameRecordMapper", gameRecordMapper);
|
|
|
+ inject(gameRecordService, "miniGameMapper", miniGameMapper);
|
|
|
+ inject(gameRecordService, "energyService", gameEnergyService);
|
|
|
+ inject(gameRecordService, "redisTemplate", redisTemplate);
|
|
|
+
|
|
|
+ miniGameService = new MiniGameService();
|
|
|
+ gameFamilyMemberMapper = mock(FamilyMemberMapper.class);
|
|
|
+ pointsLogMapper = mock(PointsLogMapper.class);
|
|
|
+ inject(miniGameService, "miniGameMapper", miniGameMapper);
|
|
|
+ inject(miniGameService, "familyMemberMapper", gameFamilyMemberMapper);
|
|
|
+ inject(miniGameService, "pointsLogMapper", pointsLogMapper);
|
|
|
+ inject(miniGameService, "gameRecordService", gameRecordService);
|
|
|
+ inject(miniGameService, "fiveDimensionScoreService", mock(FiveDimensionScoreService.class));
|
|
|
+ inject(miniGameService, "contentDimensionThresholdService", mock(ContentDimensionThresholdService.class));
|
|
|
+
|
|
|
+ MiniGame game = new MiniGame();
|
|
|
+ game.setId(1L);
|
|
|
+ game.setGameCode(GAME_CODE);
|
|
|
+ game.setGameName("舒尔特方格");
|
|
|
+ game.setDefaultPoints(5);
|
|
|
+ game.setSortOrder(1);
|
|
|
+ game.setStatus(1);
|
|
|
+ when(miniGameMapper.selectOne(any())).thenReturn(game);
|
|
|
+
|
|
|
+ FamilyMember child = new FamilyMember();
|
|
|
+ child.setId(MEMBER_ID);
|
|
|
+ child.setUserId(12L);
|
|
|
+ child.setTotalPoints(0);
|
|
|
+ when(gameFamilyMemberMapper.selectById(any())).thenReturn(child);
|
|
|
+
|
|
|
+ initTable(MiniGame.class);
|
|
|
+ initTable(FamilyMember.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ private void buildGrowthChain() throws Exception {
|
|
|
+ growthTaskService = new GrowthTaskService();
|
|
|
+ growthTaskMapper = mock(GrowthTaskMapper.class);
|
|
|
+ growthTaskLogMapper = mock(GrowthTaskLogMapper.class);
|
|
|
+ growthFamilyMemberMapper = mock(FamilyMemberMapper.class);
|
|
|
+ growthEnergyService = mock(EnergyService.class);
|
|
|
+ CfPathwayEarnService cfPathwayEarnService = mock(CfPathwayEarnService.class);
|
|
|
+
|
|
|
+ inject(growthTaskService, "growthTaskMapper", growthTaskMapper);
|
|
|
+ inject(growthTaskService, "growthTaskLogMapper", growthTaskLogMapper);
|
|
|
+ inject(growthTaskService, "familyMemberMapper", growthFamilyMemberMapper);
|
|
|
+ inject(growthTaskService, "cfPathwayEarnService", cfPathwayEarnService);
|
|
|
+ inject(growthTaskService, "energyService", growthEnergyService);
|
|
|
+ inject(growthTaskService, "taskService", mock(com.etotem.cfc.service.TaskService.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ private void buildController() throws Exception {
|
|
|
+ EnergyService service = new EnergyService();
|
|
|
+ EnergyDimensionMapper dimensionMapper = mock(EnergyDimensionMapper.class);
|
|
|
+ EnergyBehaviorConfigMapper behaviorConfigMapper = mock(EnergyBehaviorConfigMapper.class);
|
|
|
+ EnergyLogMapper logMapper = mock(EnergyLogMapper.class);
|
|
|
+ EnergyBalanceMapper balanceMapper = mock(EnergyBalanceMapper.class);
|
|
|
+ FamilyMemberMapper familyMemberMapper = mock(FamilyMemberMapper.class);
|
|
|
+
|
|
|
+ inject(service, "energyDimensionMapper", dimensionMapper);
|
|
|
+ inject(service, "energyBehaviorConfigMapper", behaviorConfigMapper);
|
|
|
+ inject(service, "energyLogMapper", logMapper);
|
|
|
+ inject(service, "energyBalanceMapper", balanceMapper);
|
|
|
+ inject(service, "energySourceConfigMapper", mock(EnergySourceConfigMapper.class));
|
|
|
+ inject(service, "wishMapper", mock(WishMapper.class));
|
|
|
+ inject(service, "familyMemberMapper", familyMemberMapper);
|
|
|
+ inject(service, "notificationDispatchService", mock(NotificationDispatchService.class));
|
|
|
+
|
|
|
+ when(dimensionMapper.selectList(any())).thenReturn(Arrays.asList(fiveDims()));
|
|
|
+ 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(FOREIGN_MEMBER_ID);
|
|
|
+ member.setUserId(888L);
|
|
|
+ when(familyMemberMapper.selectById(any())).thenReturn(member);
|
|
|
+
|
|
|
+ controller = new EnergyController();
|
|
|
+ inject(controller, "energyService", service);
|
|
|
+ inject(controller, "fiveDimensionScoreService", mock(FiveDimensionScoreService.class));
|
|
|
+ inject(controller, "userMapper", mock(UserMapper.class));
|
|
|
+ inject(controller, "unlockGateService", mock(UnlockGateService.class));
|
|
|
+
|
|
|
+ initTable(EnergyBalance.class);
|
|
|
+ initTable(EnergyDimension.class);
|
|
|
+ initTable(EnergyLog.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 异常流(SC-C-24-E / G-09,RED) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-C-24-E RED:completeGame 超上限分数(999999)应拦截且不写能量")
|
|
|
+ void 异常流_超上限分数_应拦截() {
|
|
|
+ assertThrows(RuntimeException.class,
|
|
|
+ () -> miniGameService.completeGame(MEMBER_ID, GAME_CODE, 10, 999999),
|
|
|
+ "RED[child.energy.gain] SC-C-24-E/G-09:score>100 必须拦截(当前 calculatePoints 无上限,"
|
|
|
+ + "999999 分被直接折算成上万能量发放)");
|
|
|
+ verify(gameEnergyService, never()).awardEnergy(any(), eq("game"), any(), any(), anyString(), isNull());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-C-24-E RED:completeGame 负分(-5)应拦截且不写能量")
|
|
|
+ void 异常流_负分_应拦截() {
|
|
|
+ assertThrows(RuntimeException.class,
|
|
|
+ () -> miniGameService.completeGame(MEMBER_ID, GAME_CODE, 10, -5),
|
|
|
+ "RED[child.energy.gain] SC-C-24-E/G-09:score<0 必须拦截(当前负分被折算为保底积分照常发放)");
|
|
|
+ verify(gameEnergyService, never()).awardEnergy(any(), eq("game"), any(), any(), anyString(), isNull());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-C-24-E RED:saveRecord 客户端 pointsEarned 直入能量必须封顶")
|
|
|
+ void 异常流_pointsEarned直入能量_应封顶() {
|
|
|
+ // 契约现场:客户端报文 pointsEarned=999999 直入能量(当前实现 Math.abs 原样发放)
|
|
|
+ gameRecordService.saveRecord(MEMBER_ID, GAME_CODE, 10, 999999, "medium", 999999);
|
|
|
+
|
|
|
+ // 契约:能量额必须由服务端计算并封顶(多发放 1000 即为违约)
|
|
|
+ verify(gameEnergyService, never()).awardEnergy(any(), eq("game"), any(),
|
|
|
+ argThat((Integer amt) -> amt != null && amt > 1000), anyString(), isNull());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 幂等流(SC-C-24-E / G-09,RED) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-C-24-E RED:同 (memberId,gameCode) 连续上报只发一次能量(幂等键缺失)")
|
|
|
+ void 幂等流_同游戏重复上报_只发一次() {
|
|
|
+ miniGameService.completeGame(MEMBER_ID, GAME_CODE, 10, 80);
|
|
|
+ miniGameService.completeGame(MEMBER_ID, GAME_CODE, 10, 80);
|
|
|
+
|
|
|
+ // 契约:(memberId,gameCode,date) 幂等键下同一游戏同日仅发一次能量
|
|
|
+ verify(gameEnergyService, times(1)).awardEnergy(any(), eq("game"), any(), any(), anyString(), isNull());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 权限流(TC-C-24-P / G-10,RED) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("TC-C-24-P RED:/api/energy/overview 跨家庭 memberId 应 403(归属校验缺失)")
|
|
|
+ void 权限流_overview跨家庭_应403() {
|
|
|
+ Map<String, Object> params = new HashMap<>();
|
|
|
+ params.put("memberId", FOREIGN_MEMBER_ID);
|
|
|
+ Result<Map<String, Object>> r = controller.getEnergyOverview(params, CALLER_FAMILY_ID, MEMBER_ID);
|
|
|
+
|
|
|
+ assertEquals(Integer.valueOf(403), r.getCode(),
|
|
|
+ "RED[child.energy.gain] TC-C-24-P/G-10:跨家庭 memberId 必须 403(当前无归属校验直接返回 200)");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("TC-C-24-P RED:grantEnergy 应含 @RequestAttribute(\"role\") 角色门禁")
|
|
|
+ void 权限流_grantEnergy_应含role门禁() throws Exception {
|
|
|
+ Method m = EnergyController.class.getDeclaredMethod("grantEnergy", Map.class, Long.class, Long.class);
|
|
|
+ boolean hasRoleGate = false;
|
|
|
+ for (Parameter p : m.getParameters()) {
|
|
|
+ RequestAttribute ra = p.getAnnotation(RequestAttribute.class);
|
|
|
+ if (ra != null && "role".equals(ra.value())) hasRoleGate = true;
|
|
|
+ }
|
|
|
+ assertTrue(hasRoleGate,
|
|
|
+ "RED[child.energy.gain] TC-C-24-P:grantEnergy 必须含 @RequestAttribute(\"role\") 角色门禁"
|
|
|
+ + "(孩子端直接调被拒),当前无角色参数");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("TC-C-24-P RED:deductForWish 应含 @RequestAttribute(\"role\") 角色门禁")
|
|
|
+ void 权限流_deductForWish_应含role门禁() throws Exception {
|
|
|
+ Method m = EnergyController.class.getDeclaredMethod("deductForWish", Map.class, Long.class, Long.class);
|
|
|
+ boolean hasRoleGate = false;
|
|
|
+ for (Parameter p : m.getParameters()) {
|
|
|
+ RequestAttribute ra = p.getAnnotation(RequestAttribute.class);
|
|
|
+ if (ra != null && "role".equals(ra.value())) hasRoleGate = true;
|
|
|
+ }
|
|
|
+ assertTrue(hasRoleGate,
|
|
|
+ "RED[child.energy.gain] TC-C-24-P:deductForWish 必须含 @RequestAttribute(\"role\") 角色门禁"
|
|
|
+ + "(孩子端直接调被拒),当前无角色参数");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 守卫钩子(G-09,RED) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("守卫钩子 RED:EnergyRuleEngine 应有分数校验/幂等守卫方法")
|
|
|
+ void 守卫钩子_EnergyRuleEngine_应含守卫方法() {
|
|
|
+ boolean hasGuard = false;
|
|
|
+ for (Method m : EnergyRuleEngine.class.getDeclaredMethods()) {
|
|
|
+ if (m.getName().matches("(?i).*(validate|guard|score|idempot|dedup).*")) hasGuard = true;
|
|
|
+ }
|
|
|
+ assertTrue(hasGuard,
|
|
|
+ "RED[child.energy.gain] G-09:EnergyRuleEngine 应提供分数校验/幂等守卫方法"
|
|
|
+ + "(validate/guard/score 命名),当前无");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 正常流锚点(已实现,绿) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-C-24 正常锚点:completeGame 完成 → awardEnergy(memberId,\"game\") 触发链存在")
|
|
|
+ void 正常流_game触发链存在() {
|
|
|
+ miniGameService.completeGame(MEMBER_ID, GAME_CODE, 10, 80);
|
|
|
+
|
|
|
+ verify(gameEnergyService, atLeastOnce()).awardEnergy(any(), eq("game"), any(), any(), anyString(), isNull());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-C-25 正常锚点:claimReward 领取 → awardEnergy(childId,\"growth_day\") 触发链存在")
|
|
|
+ void 正常流_growthDay触发链存在() {
|
|
|
+ Long userId = 100L;
|
|
|
+ GrowthTaskLog taskLog = new GrowthTaskLog();
|
|
|
+ taskLog.setId(88L);
|
|
|
+ taskLog.setUserId(userId);
|
|
|
+ taskLog.setTaskId(1L);
|
|
|
+ taskLog.setCompleted(1);
|
|
|
+ taskLog.setClaimed(0);
|
|
|
+ when(growthTaskLogMapper.selectById(88L)).thenReturn(taskLog);
|
|
|
+
|
|
|
+ GrowthTask task = new GrowthTask();
|
|
|
+ task.setId(1L);
|
|
|
+ task.setTaskKey("DAILY_TEST");
|
|
|
+ task.setRewardPoints(0);
|
|
|
+ task.setRewardEnergy(10);
|
|
|
+ when(growthTaskMapper.selectById(1L)).thenReturn(task);
|
|
|
+
|
|
|
+ FamilyMember child = new FamilyMember();
|
|
|
+ child.setId(MEMBER_ID);
|
|
|
+ child.setUserId(userId);
|
|
|
+ when(growthFamilyMemberMapper.selectOne(any())).thenReturn(child);
|
|
|
+
|
|
|
+ String r = growthTaskService.claimReward(userId, 88L);
|
|
|
+
|
|
|
+ assertEquals("领取成功", r);
|
|
|
+ verify(growthEnergyService).awardEnergy(eq(MEMBER_ID), eq("growth_day"), any(), any(), anyString(), any());
|
|
|
+ }
|
|
|
+}
|