浏览代码

test(cfc-backend): 新增 growth.leaderboard.view 契约级 RED 单测(S-cfc-302)

SC-P-86 成长打卡榜/家庭排行:看榜→排名/进度,数据仅本家庭可见。
基线缺口(src/main 现状):
- /api/game/leaderboard 无家庭隔离(service 缺 familyId 参数、
  mapper SQL 无 family_id 过滤、controller 未消费 @RequestAttribute familyId);
- 游戏/积分/家庭勋章三个排行端点返回项均缺进度字段。
7 条契约断言预期红(Tests: 8, Failures: 7),权限流说明性用例通过。
Sisyphus 1 天之前
父节点
当前提交
5b824ae9bf
共有 1 个文件被更改,包括 302 次插入 和 0 次删除
  1. 302 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/GrowthLeaderboardViewContractTest.java

+ 302 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/GrowthLeaderboardViewContractTest.java

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