Просмотр исходного кода

test(S-cfc-213): 契约RED单测 child.game.reportScore 小游戏成绩上报

- 能力键 child.game.reportScore(SC-C-03 小游戏成绩上报)
- 契约锚点:需求 SC-C-03(异常分数拦截)/SC-C-02(成绩上报幂等)、设计 API_REFERENCE §4.61.3 + 缺口 G-09
- 纯 Mockito 契约测试 MiniGameService.completeGame:3 正常流/边界合法端点绿 + 4 契约缺口红
- RED 实测:Tests run 7, Failures 4(score 超上限/负分未拦截、score=999999 积分膨胀 124997>10、重复上报未幂等)
Agnes Agent 55 минут назад
Родитель
Сommit
dca0505a2e

+ 215 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ChildGameReportScoreContractTest.java

@@ -0,0 +1,215 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.MiniGame;
+import com.etotem.cfc.entity.PointsLog;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.MiniGameMapper;
+import com.etotem.cfc.mapper.PointsLogMapper;
+import com.etotem.cfc.service.ContentDimensionThresholdService;
+import com.etotem.cfc.service.FiveDimensionScoreService;
+import com.etotem.cfc.service.GameRecordService;
+import com.etotem.cfc.service.MiniGameService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+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.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:小游戏成绩上报(能力键 {@code child.game.reportScore})。
+ *
+ * <p><b>契约锚点</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-C-03「小游戏成绩上报」——
+ *       流程:完游戏 → 上报分数 → 关联能量/任务;<b>边界:异常分数拦截</b>;SC-C-02「执行任务/小游戏得积分」——
+ *       <b>边界:成绩上报幂等</b>。</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.61.3——{@code POST /api/mini-game/complete}
+ *       ({@code CompleteGameDTO{memberId, gameCode, completionTime, score}} → {@code {pointsEarned, ...}})
+ *       成绩上报:加积分 + 写流水 + 写 {@code game_record} + 能量;G-09 缺口:
+ *       <b>{@code /api/mini-game/complete} 无幂等、{@code score} 无上限({@code score=999999}
+ *       可换巨额积分并污染 Redis 排行榜)</b>。</li>
+ * </ul>
+ *
+ * <p><b>被校验契约行为(GREEN 目标)</b></p>
+ * <ul>
+ *   <li><b>正常流</b>:合法 {@code score}(0–100)→ 加积分 + 写积分流水 + 写 {@code game_record}
+ *       + 返回 {@code pointsEarned}。</li>
+ *   <li><b>边界流-异常分数拦截(SC-C-03)</b>:{@code score} 越界({@code >100} 如 {@code 999999}、
+ *       {@code <0} 如 {@code -5})→ 拒绝发分(拦截),防止巨额积分与 Redis 排行榜污染(G-09)。</li>
+ *   <li><b>边界流-积分不膨胀(G-09)</b>:{@code score=999999} 时 {@code pointsEarned} 不得超过
+ *       游戏合理上限(基础分 ×2,满分 100 分线性折算上界)。</li>
+ *   <li><b>边界流-成绩上报幂等(SC-C-02)</b>:同一局重复上报 → 不重复发分 / 不重复写流水(幂等)。</li>
+ * </ul>
+ *
+ * <p><b>RED 预期</b>:{@code MiniGameService.completeGame} 当前已落地正常流(加积分/写流水/写记录),
+ * 但 <b>异常分数拦截(score 越界)与上报幂等均未实现</b>(G-09 缺口),故边界流用例在实现补齐前
+ * 应失败(红);正常流为已实现契约锚点(绿),用于区分「实现存在但契约缺口行为未落地」而非全绿。</p>
+ *
+ * <p><b>测试范围</b>:{@link MiniGameService#completeGame(Long, String, Integer, Integer)} 契约行为。
+ * 纯 Mockito(无 Spring / 无 DB / 无 Redis / 无外部服务),断言不依赖真实基础设施。</p>
+ */
+class ChildGameReportScoreContractTest {
+
+    /** 契约合法分数区间上界(文档:score 0-100) */
+    private static final int SCORE_MAX = 100;
+    /** 契约合法分数区间下界 */
+    private static final int SCORE_MIN = 0;
+    /** 游戏基础分(game.defaultPoints) */
+    private static final int BASE_POINTS = 5;
+    /** 契约:满分 100 分线性折算上界 = 基础分 ×2 */
+    private static final int REASONABLE_POINTS_CAP = BASE_POINTS * 2;
+
+    private static final Long MEMBER_ID = 100L;
+    private static final String GAME_CODE = "contract-test";
+    private static final String GAME_NAME = "契约测试游戏";
+
+    @Mock
+    private MiniGameMapper miniGameMapper;
+
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Mock
+    private PointsLogMapper pointsLogMapper;
+
+    @Mock
+    private GameRecordService gameRecordService;
+
+    @Mock
+    private FiveDimensionScoreService fiveDimensionScoreService;
+
+    @Mock
+    private ContentDimensionThresholdService contentDimensionThresholdService;
+
+    @InjectMocks
+    private MiniGameService service;
+
+    private MiniGame game;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        game = new MiniGame();
+        game.setId(1L);
+        game.setGameCode(GAME_CODE);
+        game.setGameName(GAME_NAME);
+        game.setDefaultPoints(BASE_POINTS);
+        game.setStatus(1);
+        when(miniGameMapper.selectOne(any())).thenReturn(game);
+    }
+
+    private FamilyMember child(int totalPoints) {
+        FamilyMember child = new FamilyMember();
+        child.setId(MEMBER_ID);
+        child.setTotalPoints(totalPoints);
+        when(familyMemberMapper.selectById(MEMBER_ID)).thenReturn(child);
+        return child;
+    }
+
+    // ==================== 正常流(SC-C-03 已实现契约锚点) ====================
+
+    @Test
+    @DisplayName("正常流-1:合法分数上报 → 加积分 + 写流水 + 写记录 + 返回积分")
+    void 合法分数_加积分写流水写记录返回积分() {
+        child(100);
+        // score=80 → 5 + (80-60)/40*5 = 7(默认分支无时间加成)
+        Map<String, Object> result = service.completeGame(MEMBER_ID, GAME_CODE, null, 80);
+
+        assertNotNull(result, "[child.game.reportScore] 成绩上报应返回结果 Map(含 pointsEarned)。");
+        assertEquals(7, result.get("pointsEarned"),
+                "[child.game.reportScore] score=80 应折算积分 7(基础分 5 + 线性 2)。");
+        assertEquals(107, result.get("newBalance"),
+                "[child.game.reportScore] 上报后成员总积分应累加(100 → 107)。");
+        verify(familyMemberMapper).updateById(any(FamilyMember.class));
+        verify(pointsLogMapper).insert(any(PointsLog.class));
+        verify(gameRecordService).saveRecord(eq(MEMBER_ID), eq(GAME_CODE), isNull(), eq(80), eq("medium"), eq(7));
+    }
+
+    // ==================== 边界流-合法区间端点(契约允许,应接受) ====================
+
+    @Test
+    @DisplayName("边界-1:score=100(合法上界)→ 接受且积分不超上限")
+    void 分数恰好100_接受且积分不超上限() {
+        child(0);
+        Map<String, Object> result = service.completeGame(MEMBER_ID, GAME_CODE, null, SCORE_MAX);
+
+        assertNotNull(result);
+        int earned = ((Number) result.get("pointsEarned")).intValue();
+        assertTrue(earned <= REASONABLE_POINTS_CAP,
+                "[child.game.reportScore] score=100(满分)积分不应超过基础分×2(" + REASONABLE_POINTS_CAP + ")。");
+    }
+
+    @Test
+    @DisplayName("边界-2:score=0(合法下界)→ 接受(保底 1 分)")
+    void 分数恰好0_接受() {
+        child(0);
+        Map<String, Object> result = service.completeGame(MEMBER_ID, GAME_CODE, null, SCORE_MIN);
+        assertNotNull(result);
+    }
+
+    // ==================== 边界流-异常分数拦截(SC-C-03,G-09 未落地 → 红) ====================
+
+    @Test
+    @DisplayName("边界流-3:score 超过 100 → 应拒绝发分(异常分数拦截)")
+    void 分数超上限_应拒绝发分() {
+        child(0);
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.completeGame(MEMBER_ID, GAME_CODE, null, 999999),
+                "[child.game.reportScore] score=999999 超过合法上限,契约要求拦截(异常分数,SC-C-03 / G-09)。");
+        assertTrue(ex.getMessage() != null && ex.getMessage().contains("分数"),
+                "[child.game.reportScore] 拒绝消息应指明分数不合法(G-09:score 无上限可换巨额积分)。");
+    }
+
+    @Test
+    @DisplayName("边界流-4:score 为负 → 应拒绝发分(异常分数拦截)")
+    void 分数为负_应拒绝发分() {
+        child(0);
+        assertThrows(RuntimeException.class,
+                () -> service.completeGame(MEMBER_ID, GAME_CODE, null, -5),
+                "[child.game.reportScore] score=-5 为负数,契约要求拦截(异常分数,SC-C-03)。");
+    }
+
+    // ==================== 边界流-积分不膨胀(G-09 → 红) ====================
+
+    @Test
+    @DisplayName("边界流-5:score=999999 积分不得膨胀污染排行榜(G-09)")
+    void 巨额分数_积分不膨胀() {
+        child(0);
+        Map<String, Object> result = service.completeGame(MEMBER_ID, GAME_CODE, null, 999999);
+
+        int earned = ((Number) result.get("pointsEarned")).intValue();
+        assertTrue(earned <= REASONABLE_POINTS_CAP,
+                "[child.game.reportScore] score=999999 折算积分(实际 " + earned
+                        + ")不得超过游戏合理上限(基础分×2=" + REASONABLE_POINTS_CAP
+                        + "),否则巨额积分污染 Redis 排行榜(G-09)。");
+    }
+
+    // ==================== 边界流-成绩上报幂等(SC-C-02,G-09 未落地 → 红) ====================
+
+    @Test
+    @DisplayName("边界流-6:同一局重复上报 → 幂等,不重复发分/不重复写流水")
+    void 重复上报同一局_幂等不重复发分() {
+        child(100);
+        service.completeGame(MEMBER_ID, GAME_CODE, null, 80);
+        service.completeGame(MEMBER_ID, GAME_CODE, null, 80);
+
+        verify(pointsLogMapper, times(1)).insert(any(PointsLog.class));
+        verify(gameRecordService, times(1)).saveRecord(
+                eq(MEMBER_ID), eq(GAME_CODE), isNull(), eq(80), eq("medium"), eq(7));
+    }
+}