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