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