Parcourir la source

test(S-cfc-224): child.energy.gain TDD-RED contract tests (SC-C-24/SC-C-25, 8 red + 2 green anchors)

Sisyphus Agent il y a 1 jour
Parent
commit
d54600136b

+ 383 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ChildEnergyGainContractTest.java

@@ -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());
+    }
+}