|
|
@@ -0,0 +1,302 @@
|
|
|
+package com.etotem.cfc.unit;
|
|
|
+
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.controller.GameRecordController;
|
|
|
+import com.etotem.cfc.controller.stats.StatsController;
|
|
|
+import com.etotem.cfc.entity.FamilyMember;
|
|
|
+import com.etotem.cfc.mapper.FamilyMemberMapper;
|
|
|
+import com.etotem.cfc.mapper.GameRecordMapper;
|
|
|
+import com.etotem.cfc.mapper.MemberBadgeMapper;
|
|
|
+import com.etotem.cfc.service.BadgeService;
|
|
|
+import com.etotem.cfc.service.GameRecordService;
|
|
|
+import org.apache.ibatis.annotations.Select;
|
|
|
+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 org.springframework.data.redis.core.RedisTemplate;
|
|
|
+import org.springframework.web.bind.annotation.RequestAttribute;
|
|
|
+
|
|
|
+import java.lang.annotation.Annotation;
|
|
|
+import java.lang.reflect.Method;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.List;
|
|
|
+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.assertTrue;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.ArgumentMatchers.anyInt;
|
|
|
+import static org.mockito.ArgumentMatchers.isNull;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 契约级单测:能力键 {@code growth.leaderboard.view}(<b>TDD-RED / 契约登记批次</b>)。
|
|
|
+ *
|
|
|
+ * <p><b>契约锚点</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-P-86(成长打卡榜/家庭排行:
|
|
|
+ * 看榜 → 排名/进度;<b>边界:数据仅本家庭可见;排名实时性</b>);</li>
|
|
|
+ * <li>用例:{@code tests/test-cases/parent.md} TC-P-86(正常流:看榜→排名/进度)、
|
|
|
+ * TC-P-86-E(异常流:看他人家庭排行榜不可见 / 排名实时性滞后)、
|
|
|
+ * TC-P-86-P(权限流:非归属家庭数据统一拦截);</li>
|
|
|
+ * <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.61.3
|
|
|
+ * {@code POST /api/game/leaderboard}(游戏排行榜)、§4.61.4
|
|
|
+ * {@code POST /api/badges/ranking/family}(家庭勋章排行)、§4.60.2
|
|
|
+ * {@code POST /api/stats/points/ranking}(积分排行);</li>
|
|
|
+ * <li>编排登记:{@code docs/tdd/orchestration/sc-key-map.md} SC-P-86 →
|
|
|
+ * growth.leaderboard.view → S-cfc-302。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>测试范围</b>:三个排行端点的<b>家庭隔离</b>与<b>进度字段</b>契约。
|
|
|
+ * 仅用 Mockito + 反射(无 Spring / 无 DB / 无 Redis / 无真实外部服务),断言不依赖真实数据源。
|
|
|
+ * 游戏排行的 Redis 路径用 {@code opsForZSet()} 未 stub(返回 null → NPE 被 try-catch 吞掉)
|
|
|
+ * 强制走 DB 回退分支,从而以 {@code gameRecordMapper} mock 精确驱动。</p>
|
|
|
+ *
|
|
|
+ * <p><b>基线状态(实测,TDD-RED 阶段实现未落地)</b>:</p>
|
|
|
+ * <ul>
|
|
|
+ * <li><b>游戏排行榜无家庭隔离</b>:{@code GameRecordService.getLeaderboard(String, Integer)}
|
|
|
+ * 无 {@code familyId} 参数;{@code GameRecordMapper.selectLeaderboard} SQL 仅按
|
|
|
+ * {@code game_code} 过滤(无家庭约束);{@code GameRecordController} 未消费
|
|
|
+ * {@code @RequestAttribute familyId};Redis key {@code game:leaderboard:{gameCode}}
|
|
|
+ * 全局共享不区分家庭 —— 违反 SC-P-86「数据仅本家庭可见」;</li>
|
|
|
+ * <li><b>排行项无进度字段</b>:三个排行端点返回项均只含
|
|
|
+ * {@code rank/memberId/childName/points}(或 {@code child_id/total}),
|
|
|
+ * 缺 SC-P-86「看榜 → 排名/进度」中的<b>进度</b>维度。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>RED 预期</b>:上述缺口在 src/main 实现补齐前应失败(红);
|
|
|
+ * GREEN 阶段按契约补齐(游戏排行榜家庭隔离 + 排行项进度字段)后自动转绿。
|
|
|
+ * 若实测意外全绿,交编排侧核实能力键状态(转测试登记),不私改 src/main 实现。</p>
|
|
|
+ */
|
|
|
+class GrowthLeaderboardViewContractTest {
|
|
|
+
|
|
|
+ /** 测试家庭:family_members.family_id(会话家庭维度) */
|
|
|
+ private static final Long FAMILY_ID = 9L;
|
|
|
+
|
|
|
+ // ---------- 游戏排行榜(/api/game/leaderboard) ----------
|
|
|
+ @Mock
|
|
|
+ private GameRecordMapper gameRecordMapper;
|
|
|
+
|
|
|
+ /** 未 stub:opsForZSet() 返回 null → try-catch 吞掉 NPE → 强制走 DB 回退分支 */
|
|
|
+ @Mock
|
|
|
+ private RedisTemplate<String, Object> redisTemplate;
|
|
|
+
|
|
|
+ @InjectMocks
|
|
|
+ private GameRecordService gameRecordService;
|
|
|
+
|
|
|
+ // ---------- 积分排行(/api/stats/points/ranking) ----------
|
|
|
+ @Mock
|
|
|
+ private FamilyMemberMapper familyMemberMapper;
|
|
|
+
|
|
|
+ @InjectMocks
|
|
|
+ private StatsController statsController;
|
|
|
+
|
|
|
+ // ---------- 家庭勋章排行(/api/badges/ranking/family) ----------
|
|
|
+ @Mock
|
|
|
+ private MemberBadgeMapper memberBadgeMapper;
|
|
|
+
|
|
|
+ @InjectMocks
|
|
|
+ private BadgeService badgeService;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ MockitoAnnotations.openMocks(this);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ================================================================
|
|
|
+ // 正常流:游戏排行榜家庭隔离(SC-P-86「数据仅本家庭可见」)
|
|
|
+ // ================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-1:GameRecordService.getLeaderboard 应接受 familyId 参数(家庭隔离)")
|
|
|
+ void 游戏排行_service应接受familyId参数() {
|
|
|
+ Method m;
|
|
|
+ try {
|
|
|
+ m = GameRecordService.class.getDeclaredMethod("getLeaderboard", String.class, Integer.class);
|
|
|
+ } catch (NoSuchMethodException e) {
|
|
|
+ throw new AssertionError(
|
|
|
+ "[growth.leaderboard.view] GameRecordService.getLeaderboard(String, Integer) 未按契约签名存在。", e);
|
|
|
+ }
|
|
|
+ boolean hasFamilyId = false;
|
|
|
+ for (Class<?> p : m.getParameterTypes()) {
|
|
|
+ if (p == Long.class || p == long.class) {
|
|
|
+ hasFamilyId = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ assertTrue(hasFamilyId,
|
|
|
+ "[growth.leaderboard.view] GameRecordService.getLeaderboard 缺 familyId 参数:游戏排行榜"
|
|
|
+ + "按全局返回(Redis key game:leaderboard:{gameCode} 不区分家庭 + SQL 无家庭过滤),"
|
|
|
+ + "违反 SC-P-86『数据仅本家庭可见』。GREEN 应新增 familyId 维度(会话家庭)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-2:GameRecordMapper.selectLeaderboard 的 SQL 应含 family_id 过滤")
|
|
|
+ void 游戏排行_mapperSQL应含family_id过滤() throws Exception {
|
|
|
+ Method m = GameRecordMapper.class.getDeclaredMethod("selectLeaderboard", String.class, int.class);
|
|
|
+ Select select = m.getAnnotation(Select.class);
|
|
|
+ assertNotNull(select,
|
|
|
+ "[growth.leaderboard.view] GameRecordMapper.selectLeaderboard 应以 @Select 声明(现状保留)。");
|
|
|
+ String sql = String.join(" ", select.value());
|
|
|
+ assertTrue(sql.contains("family_id"),
|
|
|
+ "[growth.leaderboard.view] selectLeaderboard SQL 未按家庭过滤(应加 fm.family_id = #{familyId}"
|
|
|
+ + " 之类约束),返回跨家庭排行榜,违反 SC-P-86『数据仅本家庭可见』。实际 SQL: " + sql);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-3:GameRecordController.getLeaderboard 应消费 @RequestAttribute(\"familyId\")")
|
|
|
+ void 游戏排行_controller应读取会话familyId() throws Exception {
|
|
|
+ Method m = GameRecordController.class.getDeclaredMethod("getLeaderboard", Map.class);
|
|
|
+ boolean hasFamilyAttr = false;
|
|
|
+ for (Annotation[] anns : m.getParameterAnnotations()) {
|
|
|
+ for (Annotation a : anns) {
|
|
|
+ if (a instanceof RequestAttribute && "familyId".equals(((RequestAttribute) a).value())) {
|
|
|
+ hasFamilyAttr = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ assertTrue(hasFamilyAttr,
|
|
|
+ "[growth.leaderboard.view] GameRecordController.getLeaderboard 未消费 @RequestAttribute(\"familyId\"):"
|
|
|
+ + "排行无法按会话家庭过滤,违反 SC-P-86『数据仅本家庭可见』"
|
|
|
+ + "(badges/points 排行已按会话 familyId 隔离,game 排行需对齐)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ================================================================
|
|
|
+ // 正常流:排行项应含进度字段(SC-P-86「看榜 → 排名/进度」)
|
|
|
+ // ================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-4:游戏排行榜项应含进度字段(progress/totalMembers)")
|
|
|
+ void 游戏排行_项应含进度字段() {
|
|
|
+ Map<String, Object> row = new HashMap<>();
|
|
|
+ row.put("memberId", 1L);
|
|
|
+ row.put("score", 100);
|
|
|
+ row.put("rank", 1);
|
|
|
+ row.put("child_name", "小明");
|
|
|
+ List<Map<String, Object>> rows = new ArrayList<>();
|
|
|
+ rows.add(row);
|
|
|
+ when(gameRecordMapper.selectLeaderboard(isNull(), anyInt())).thenReturn(rows);
|
|
|
+
|
|
|
+ List<Map<String, Object>> board = gameRecordService.getLeaderboard(null, 10);
|
|
|
+
|
|
|
+ assertNotNull(board);
|
|
|
+ assertEquals(1, board.size());
|
|
|
+ assertCarriesProgress(board.get(0), "游戏排行榜");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-5:积分排行项应含进度字段(progress/totalMembers)")
|
|
|
+ void 积分排行_项应含进度字段() {
|
|
|
+ List<FamilyMember> members = new ArrayList<>();
|
|
|
+ members.add(familyMember(1L, "小明", 120));
|
|
|
+ members.add(familyMember(2L, "小红", 80));
|
|
|
+ when(familyMemberMapper.selectList(any())).thenReturn(members);
|
|
|
+
|
|
|
+ Result<List<Map<String, Object>>> res = statsController.pointsRanking(new HashMap<>(), FAMILY_ID);
|
|
|
+
|
|
|
+ List<Map<String, Object>> data = res.getData();
|
|
|
+ assertNotNull(data);
|
|
|
+ assertEquals(2, data.size());
|
|
|
+ for (Map<String, Object> item : data) {
|
|
|
+ assertCarriesProgress(item, "积分排行");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-6:家庭勋章排行项应含进度字段(progress/totalMembers)")
|
|
|
+ void 家庭勋章排行_项应含进度字段() {
|
|
|
+ List<Map<String, Object>> rows = new ArrayList<>();
|
|
|
+ rows.add(badgeRankRow(1L, 5L));
|
|
|
+ rows.add(badgeRankRow(2L, 3L));
|
|
|
+ when(memberBadgeMapper.selectFamilyRanking(FAMILY_ID)).thenReturn(rows);
|
|
|
+
|
|
|
+ List<Map<String, Object>> data = badgeService.getFamilyRanking(FAMILY_ID);
|
|
|
+
|
|
|
+ assertNotNull(data);
|
|
|
+ assertEquals(2, data.size());
|
|
|
+ for (Map<String, Object> item : data) {
|
|
|
+ assertCarriesProgress(item, "家庭勋章排行");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ================================================================
|
|
|
+ // 异常流:跨家庭数据不得泄露(TC-P-86-E「看他人家庭排行榜不可见」)
|
|
|
+ // ================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("异常流-1:跨家庭数据行注入后应被过滤到本家庭(不得原样返回)")
|
|
|
+ void 游戏排行_跨家庭数据行应被过滤() {
|
|
|
+ Map<String, Object> own = new HashMap<>();
|
|
|
+ own.put("memberId", 1L);
|
|
|
+ own.put("familyId", FAMILY_ID);
|
|
|
+ own.put("score", 90);
|
|
|
+ Map<String, Object> other = new HashMap<>();
|
|
|
+ other.put("memberId", 2L);
|
|
|
+ other.put("familyId", 77L);
|
|
|
+ other.put("score", 95);
|
|
|
+ List<Map<String, Object>> rows = new ArrayList<>();
|
|
|
+ rows.add(own);
|
|
|
+ rows.add(other);
|
|
|
+ when(gameRecordMapper.selectLeaderboard(isNull(), anyInt())).thenReturn(rows);
|
|
|
+
|
|
|
+ List<Map<String, Object>> board = gameRecordService.getLeaderboard(null, 10);
|
|
|
+
|
|
|
+ assertNotNull(board);
|
|
|
+ assertEquals(2, board.size(),
|
|
|
+ "[growth.leaderboard.view] 上游返回的跨家庭行应被服务端过滤(契约 GREEN 实现后此处应仅剩本家庭行)");
|
|
|
+ for (Map<String, Object> item : board) {
|
|
|
+ assertEquals(FAMILY_ID, ((Number) item.get("familyId")).longValue(),
|
|
|
+ "[growth.leaderboard.view] 本家庭排行榜不得返回其他家庭(familyId=77)成员数据,"
|
|
|
+ + "违反 SC-P-86『数据仅本家庭可见』(TC-P-86-E)。");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ================================================================
|
|
|
+ // 权限流(说明性):SC-P-86-P 越权拦截
|
|
|
+ // ================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("权限流(说明性):家庭勋章/积分排行已按会话 familyId 隔离;游戏排行隔离缺失由红断言覆盖")
|
|
|
+ void 权限流_说明性() {
|
|
|
+ // SC-P-86-P:非归属家庭数据统一 403/401、不泄露他人数据。
|
|
|
+ // 现状核实:/api/badges/ranking/family 与 /api/stats/points/ranking 的 Controller
|
|
|
+ // 均已 @RequestAttribute("familyId") 且未入家庭返回 noFamily(越权=无家庭)已拦截;
|
|
|
+ // /api/game/leaderboard 的家庭隔离缺失由正常流-1/2/3、异常流-1 红断言覆盖。
|
|
|
+ assertTrue(true,
|
|
|
+ "权限流不适用单独红断言:家庭隔离契约已由正常/异常流断言覆盖;"
|
|
|
+ + "badges/points 排行会话 familyId 拦截已实现。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 辅助构造 ====================
|
|
|
+
|
|
|
+ /** 契约断言:排行项应携带进度信息(字段名宽松:progress / totalMembers / completionRate) */
|
|
|
+ private void assertCarriesProgress(Map<String, Object> item, String boardName) {
|
|
|
+ assertTrue(item.containsKey("progress") || item.containsKey("totalMembers")
|
|
|
+ || item.containsKey("completionRate"),
|
|
|
+ "[growth.leaderboard.view] " + boardName + " 项缺进度维度:SC-P-86 流程为「看榜 → 排名/进度」,"
|
|
|
+ + "排行项应携带进度信息(如 排名/总成员数、完成率)。实际 keys=" + item.keySet());
|
|
|
+ }
|
|
|
+
|
|
|
+ private FamilyMember familyMember(Long id, String nickname, Integer totalPoints) {
|
|
|
+ FamilyMember m = new FamilyMember();
|
|
|
+ m.setId(id);
|
|
|
+ m.setFamilyId(FAMILY_ID);
|
|
|
+ m.setNickname(nickname);
|
|
|
+ m.setTotalPoints(totalPoints);
|
|
|
+ return m;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> badgeRankRow(Long memberId, Long total) {
|
|
|
+ Map<String, Object> row = new HashMap<>();
|
|
|
+ row.put("child_id", memberId);
|
|
|
+ row.put("total", total);
|
|
|
+ return row;
|
|
|
+ }
|
|
|
+}
|