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

test(contract): admin.badge.manage RED 契约测试(S-cfc-206)

SC-A-05 勋章配置 CRUD 契约级单测,TDD-RED 阶段:
- 正常流:BadgeController(11端点)/AdminChallengeScoreConfigController(list/update)/
  AdminLeaderboardController(top/search) 端点面反射校验(现状已落地,转绿)
- 边界流(RED):已发放勋章不可删(停用代替)——当前 deleteBadge 无条件物理删除
- 权限流(RED):/api/badges/create|update|delete 与 challenge-score-config/update
  无 role=admin 守卫,非 admin 可越权

纯 Mockito+反射+MockMvc standalone,无 Spring/DB。结果:11 run / 5 fail / 0 error(RED 达标)。
Sisyphus 4 часов назад
Родитель
Сommit
d0b585a3bb

+ 363 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/AdminBadgeManageContractTest.java

@@ -0,0 +1,363 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.controller.BadgeController;
+import com.etotem.cfc.controller.admin.AdminChallengeScoreConfigController;
+import com.etotem.cfc.mapper.BadgeMapper;
+import com.etotem.cfc.mapper.FamilyChallengeScoreConfigMapper;
+import com.etotem.cfc.mapper.MemberBadgeMapper;
+import com.etotem.cfc.service.BadgeService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+import org.springframework.http.MediaType;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Scanner;
+import java.util.Set;
+
+import static org.hamcrest.Matchers.not;
+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.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+
+/**
+ * 契约级单测:勋章配置 CRUD + 挑战分配置 + 成就排名治理(能力键 {@code admin.badge.manage})。
+ *
+ * <p>契约来源:</p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} <b>SC-A-05 勋章配置 CRUD</b> ——
+ *       目标:管理成就体系;流程:勋章增删改 → 生效;<b>边界:已发放勋章不可删(停用代替)</b>。</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.60.5 ——
+ *       {@code BadgeController /api/badges}(all/list/detail/create/update/delete/grant/
+ *       member/list/favorite/statistics/member/ranking/family)
+ *       + {@code AdminChallengeScoreConfigController /api/admin/challenge-score-config}
+ *       (list/update)+ {@code AdminLeaderboardController}(成就排名治理);§4.13 管理员接口需
+ *       {@code role=admin}。</li>
+ *   <li>开工包:{@code docs/tdd/orchestration/task-incentive-kickoff-20261107.md} SC-A-05 行
+ *       (契约接口范围:/api/badges/create、/update、/delete、
+ *       /api/admin/challenge-score-config/list、/update)。</li>
+ * </ul>
+ *
+ * <p><b>实现现状(本分支 src/main 核实)</b></p>
+ * <ul>
+ *   <li>端点面已落地:{@code BadgeController}(11 个 @PostMapping)、
+ *       {@code AdminChallengeScoreConfigController}(list/update)、
+ *       {@code AdminLeaderboardController}(top/search)均在位;
+ *       对应正常流端点面用例在本测试中应转绿。</li>
+ *   <li><b>契约边界未落地(RED 焦点)</b>:{@link BadgeService#deleteBadge(Long)} 当前无条件
+ *       {@code badgeMapper.deleteById(id)} 物理删除(未检查是否已发放)——未实现 SC-A-05 边界
+ *       「已发放勋章不可删(停用代替)」。</li>
+ *   <li><b>权限守卫未落地(RED 焦点)</b>:{@code /api/badges/create|update|delete} 与
+ *       {@code /api/admin/challenge-score-config/update} 控制器内均无 {@code role=admin} 校验
+ *       (对照已实现守卫的 {@code AdminLeaderboardController#top} 模式),非 admin 可越权管理勋章字典。</li>
+ * </ul>
+ *
+ * <p><b>测试方式</b>:纯 Mockito + 反射 + MockMvc standalone,无 Spring 上下文 / 无 DB / 无外部服务,
+ * 与同批 RED 契约测试(TeamProfitHookContractTest / FamilyJoinValidationRulesContractTest)风格一致。</p>
+ *
+ * <p><b>RED 预期</b>:边界流(已发放不可删)与权限流(admin 守卫)用例在实现补齐前应失败(红);
+ * 端点面正常流用例随现状转绿。若全量转绿(含边界/权限),说明实现已合入,应交编排侧转测试登记。</p>
+ */
+class AdminBadgeManageContractTest {
+
+    private static final String BADGE_CONTROLLER = "com.etotem.cfc.controller.BadgeController";
+    private static final String CHALLENGE_CONFIG_CONTROLLER =
+            "com.etotem.cfc.controller.admin.AdminChallengeScoreConfigController";
+    private static final String LEADERBOARD_CONTROLLER =
+            "com.etotem.cfc.controller.admin.AdminLeaderboardController";
+    private static final String BADGE_ENTITY = "com.etotem.cfc.entity.Badge";
+
+    private static final Long BADGE_ID = 1001L;
+
+    /** 契约端点面(§4.60.5) */
+    private static final String[] REQUIRED_BADGE_PATHS = {
+            "/api/badges/all",
+            "/api/badges/list",
+            "/api/badges/detail",
+            "/api/badges/create",
+            "/api/badges/update",
+            "/api/badges/delete",
+            "/api/badges/grant",
+            "/api/badges/member/list",
+            "/api/badges/favorite",
+            "/api/badges/statistics/member",
+            "/api/badges/ranking/family"
+    };
+
+    // ---------------- MockMvc / Controller 层 ----------------
+
+    private MockMvc badgesMvc;
+    private MockMvc challengeMvc;
+
+    @Mock
+    private BadgeService badgeService;
+
+    @Mock
+    private FamilyChallengeScoreConfigMapper configMapper;
+
+    // ---------------- Service 层(真实实例 + Mock Mapper) ----------------
+
+    @Mock
+    private BadgeMapper badgeMapper;
+
+    @Mock
+    private MemberBadgeMapper memberBadgeMapper;
+
+    private BadgeService badgeServiceUnderTest;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+
+        BadgeController badges = new BadgeController();
+        ReflectionTestUtils.setField(badges, "badgeService", badgeService);
+        badgesMvc = MockMvcBuilders.standaloneSetup(badges).build();
+
+        AdminChallengeScoreConfigController challenge = new AdminChallengeScoreConfigController();
+        ReflectionTestUtils.setField(challenge, "configMapper", configMapper);
+        challengeMvc = MockMvcBuilders.standaloneSetup(challenge).build();
+
+        badgeServiceUnderTest = new BadgeService();
+        ReflectionTestUtils.setField(badgeServiceUnderTest, "badgeMapper", badgeMapper);
+        ReflectionTestUtils.setField(badgeServiceUnderTest, "memberBadgeMapper", memberBadgeMapper);
+    }
+
+    // ====================================================================
+    // 正常流 · 端点面(结构契约,§4.60.5 + 开工包 SC-A-05 行)
+    // ====================================================================
+
+    @Test
+    @DisplayName("正常流-1:BadgeController 应提供勋章字典 CRUD + 发放/收藏端点")
+    void badgeController_crudAndGrantSurface() {
+        Class<?> clazz = loadClass(BADGE_CONTROLLER);
+        assertNotNull(clazz, "[admin.badge.manage] BadgeController 类不存在:"
+                + "SC-A-05 勋章配置 CRUD 契约要求 BadgeController /api/badges 提供"
+                + " all/list/detail/create/update/delete/grant/member/list/favorite/statistics/member/ranking/family(§4.60.5)。");
+        Set<String> paths = postMappings(clazz);
+        for (String required : REQUIRED_BADGE_PATHS) {
+            assertTrue(paths.contains(required),
+                    "[admin.badge.manage] BadgeController 缺少契约端点 " + required + "(§4.60.5)。");
+        }
+    }
+
+    @Test
+    @DisplayName("正常流-2:AdminChallengeScoreConfigController 应提供挑战分配置 list/update")
+    void challengeScoreConfig_listUpdateSurface() {
+        Class<?> clazz = loadClass(CHALLENGE_CONFIG_CONTROLLER);
+        assertNotNull(clazz, "[admin.badge.manage] AdminChallengeScoreConfigController 类不存在:"
+                + "契约要求 /api/admin/challenge-score-config 提供 list/update(§4.60.5)。");
+        Set<String> paths = postMappings(clazz);
+        assertTrue(paths.contains("/api/admin/challenge-score-config/list"),
+                "[admin.badge.manage] AdminChallengeScoreConfigController 缺少 /list(§4.60.5)。");
+        assertTrue(paths.contains("/api/admin/challenge-score-config/update"),
+                "[admin.badge.manage] AdminChallengeScoreConfigController 缺少 /update(§4.60.5)。");
+    }
+
+    @Test
+    @DisplayName("正常流-3:AdminLeaderboardController 应提供成就排名治理端点(top/search)")
+    void adminLeaderboard_achievementRankGovernanceSurface() {
+        Class<?> clazz = loadClass(LEADERBOARD_CONTROLLER);
+        assertNotNull(clazz, "[admin.badge.manage] AdminLeaderboardController 类不存在:"
+                + "成就排名治理契约要求 /api/admin/leaderboard 提供 top/search。");
+        Set<String> paths = postMappings(clazz);
+        assertTrue(paths.contains("/api/admin/leaderboard/top"),
+                "[admin.badge.manage] AdminLeaderboardController 缺少 /top(成就排名治理)。");
+        assertTrue(paths.contains("/api/admin/leaderboard/search"),
+                "[admin.badge.manage] AdminLeaderboardController 缺少 /search(成就排名治理)。");
+    }
+
+    // ====================================================================
+    // 边界流 · 已发放勋章不可删(停用代替)—— SC-A-05 边界条款
+    // ====================================================================
+
+    @Test
+    @DisplayName("边界流-1(RED):已发放勋章 deleteBadge 不得物理删除(停用代替)")
+    void deleteBadge_issuedBadge_notHardDeleted() {
+        // 该勋章已有成员授予记录(child_badge / member_badges 存在 badgeId 记录)
+        when(memberBadgeMapper.selectCount(any())).thenReturn(1L);
+
+        try {
+            badgeServiceUnderTest.deleteBadge(BADGE_ID);
+        } catch (RuntimeException expected) {
+            // GREEN 实现可“抛业务异常拦截删除”(视为满足契约),不在此失败
+        }
+
+        long hardDeletes = countInvocations(badgeMapper, "deleteById");
+        assertTrue(hardDeletes == 0,
+                "[admin.badge.manage] SC-A-05 边界:已发放勋章不可删(停用代替)。"
+                        + "当前 BadgeService.deleteBadge 无条件 badgeMapper.deleteById(id) 物理删除了已发放勋章定义;"
+                        + "契约要求:存在成员授予记录时拒绝删除,改为停用(is_active=0,勋章墙仍可展示但不再新发)。");
+    }
+
+    @Test
+    @DisplayName("边界流-2:未发放勋章可正常删除(不阻断合法 CRUD)")
+    void deleteBadge_notIssued_canDelete() {
+        when(memberBadgeMapper.selectCount(any())).thenReturn(0L);
+
+        badgeServiceUnderTest.deleteBadge(BADGE_ID);
+
+        long hardDeletes = countInvocations(badgeMapper, "deleteById");
+        assertTrue(hardDeletes == 1,
+                "[admin.badge.manage] SC-A-05 正常流程:未发放勋章应可删除(增删改 → 生效)。"
+                        + "deleteBadge 应删除未发放的勋章定义。");
+    }
+
+    @Test
+    @DisplayName("边界流-3:停用代替的载体应存在(Badge.isActive + 建表 is_active)")
+    void badgeEntity_andSchema_carryDisableFlag() {
+        Class<?> entity = loadClass(BADGE_ENTITY);
+        assertNotNull(entity, "[admin.badge.manage] Badge 实体不存在。");
+        boolean hasIsActive = false;
+        for (Field f : entity.getDeclaredFields()) {
+            if (f.getName().equals("isActive")) {
+                hasIsActive = true;
+                break;
+            }
+        }
+        assertTrue(hasIsActive,
+                "[admin.badge.manage] Badge 实体应含 isActive 字段(停用代替的落点,对应 is_active 列)。");
+
+        String badgeTable = extractCreateTable("badge");
+        assertTrue(badgeTable.contains("is_active"),
+                "[admin.badge.manage] badge 建表应含 is_active 列(已发放勋章停用代替的落点)。");
+    }
+
+    // ====================================================================
+    // 权限流 · 管理员守卫(§4.13:管理员接口需 role=admin)
+    // ====================================================================
+
+    @Test
+    @DisplayName("权限流-1(RED):非 admin 调用 POST /api/badges/create 应被拒绝")
+    void createBadge_rejectsNonAdmin() throws Exception {
+        badgesMvc.perform(post("/api/badges/create")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .requestAttr("role", "parent")
+                        .content("{\"name\":\"契约测试勋章\"}"))
+                .andExpect(jsonPath("$.code").value(not(200)));
+    }
+
+    @Test
+    @DisplayName("权限流-2(RED):非 admin 调用 POST /api/badges/update 应被拒绝")
+    void updateBadge_rejectsNonAdmin() throws Exception {
+        badgesMvc.perform(post("/api/badges/update")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .requestAttr("role", "parent")
+                        .content("{\"id\":1001,\"name\":\"契约测试勋章\"}"))
+                .andExpect(jsonPath("$.code").value(not(200)));
+    }
+
+    @Test
+    @DisplayName("权限流-3(RED):非 admin 调用 POST /api/badges/delete 应被拒绝")
+    void deleteBadge_rejectsNonAdmin() throws Exception {
+        badgesMvc.perform(post("/api/badges/delete")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .requestAttr("role", "parent")
+                        .content("{\"id\":1001}"))
+                .andExpect(jsonPath("$.code").value(not(200)));
+    }
+
+    @Test
+    @DisplayName("权限流-4(RED):非 admin 调用 POST /api/admin/challenge-score-config/update 应被拒绝")
+    void challengeScoreConfigUpdate_rejectsNonAdmin() throws Exception {
+        challengeMvc.perform(post("/api/admin/challenge-score-config/update")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .requestAttr("role", "vendor")
+                        .content("{\"action\":\"daily_checkin\",\"scoreType\":\"fixed\",\"value\":10}"))
+                .andExpect(jsonPath("$.code").value(not(200)));
+    }
+
+    @Test
+    @DisplayName("权限流-5:家长/管理端均可授予勋章(grant 不限定 admin,§4.61.4)")
+    void grant_allowedForParent() throws Exception {
+        badgesMvc.perform(post("/api/badges/grant")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .requestAttr("role", "parent")
+                        .content("{\"memberId\":2001,\"badgeId\":1001}"))
+                .andExpect(jsonPath("$.code").value(200));
+    }
+
+    // ====================================================================
+    // 反射 / Mockito 辅助
+    // ====================================================================
+
+    /** 收集类上 @PostMapping 全路径(类级 @RequestMapping + 方法级路径) */
+    private Set<String> postMappings(Class<?> clazz) {
+        Set<String> paths = new HashSet<>();
+        String base = "";
+        RequestMapping classMapping = clazz.getAnnotation(RequestMapping.class);
+        if (classMapping != null && classMapping.value().length > 0) {
+            base = classMapping.value()[0];
+        }
+        for (Method m : clazz.getDeclaredMethods()) {
+            PostMapping pm = m.getAnnotation(PostMapping.class);
+            if (pm != null) {
+                for (String path : pm.value()) {
+                    paths.add(base + "/" + path.replaceFirst("^/", ""));
+                }
+            }
+        }
+        return paths;
+    }
+
+    private long countInvocations(Object mock, String methodName) {
+        return Mockito.mockingDetails(mock).getInvocations().stream()
+                .filter(inv -> inv.getMethod().getName().equals(methodName))
+                .count();
+    }
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    /** 从 schema.sql 抽取指定表的 CREATE TABLE 块(不含尾部 ENGINE 行之后) */
+    private String extractCreateTable(String tableName) {
+        String schema = readSchemaSql();
+        if (schema.isEmpty()) {
+            return "";
+        }
+        String marker = "CREATE TABLE IF NOT EXISTS " + tableName + " (";
+        int start = schema.indexOf(marker);
+        if (start < 0) {
+            return "";
+        }
+        int end = schema.indexOf(") ENGINE=InnoDB", start);
+        if (end < 0) {
+            return "";
+        }
+        return schema.substring(start, end);
+    }
+
+    private String readSchemaSql() {
+        try (InputStream is = getClass().getClassLoader().getResourceAsStream("schema.sql")) {
+            if (is == null) {
+                return "";
+            }
+            try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
+                scanner.useDelimiter("\\A");
+                return scanner.hasNext() ? scanner.next() : "";
+            }
+        } catch (Exception e) {
+            return "";
+        }
+    }
+}