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

test: S-cfc-199 admin.dimension.config RED 契约测试(维度/权重/上限 CRUD + 来源比例/归因权重治理)

能力键: admin.dimension.config
溯源: 应用场景总览 SC-A-09/SC-A-25/SC-A-26; API_REFERENCE §4.13/§4.60.9/§4.60.16; TC-A-25/26-E/P
新增: DimensionConfigAdminContractTest (17 用例, 13 红 / 4 绿基线)

RED 覆盖:
- 权重/上限/来源比例非法(负/超界/和≠100) → 400 拦截 (未实现, 现返回 200)
- 删除被引用维度拒绝 + 不级联删除 (deleteConfig 无引用检查, 现静默 removeById)
- 乐观锁 version 字段 (ProductDimensionConfig 无 version)
- @RequestAttribute('role') admin 403 守卫 (4 个控制器均无 role 参数)

GREEN 基线: 4 个控制器 (DimensionAdminController/DimensionConfigController/
DimensionWeightController/AdminEnergyConfigController) CRUD 端点存在。

纯 Mockito+反射, 无 Spring/DB/外部服务。禁改 src/main。
Sisyphus Agent 2 часов назад
Родитель
Сommit
896a7275e7

+ 419 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/DimensionConfigAdminContractTest.java

@@ -0,0 +1,419 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.admin.AdminEnergyConfigController;
+import com.etotem.cfc.controller.admin.AdminEnergyConfigController.DailyLimitSaveRequest;
+import com.etotem.cfc.controller.admin.AdminEnergyConfigController.DimensionRatio;
+import com.etotem.cfc.controller.admin.AdminEnergyConfigController.EnergySourceConfigSaveRequest;
+import com.etotem.cfc.controller.admin.DimensionAdminController;
+import com.etotem.cfc.controller.admin.DimensionConfigController;
+import com.etotem.cfc.controller.admin.DimensionWeightController;
+import com.etotem.cfc.entity.DimensionDailyLimit;
+import com.etotem.cfc.entity.EnergySourceConfig;
+import com.etotem.cfc.entity.ProductDimensionConfig;
+import com.etotem.cfc.mapper.DimensionDailyLimitMapper;
+import com.etotem.cfc.mapper.EnergySourceConfigMapper;
+import com.etotem.cfc.mapper.ProductDimensionConfigMapper;
+import com.etotem.cfc.service.DimensionWeightService;
+import com.etotem.cfc.service.ProductDimensionConfigService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:管理端维度配置治理(能力键 {@code admin.dimension.config})。
+ *
+ * <p><b>需求/设计锚点</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-A-09(维度配置与知识库管理)、
+ *       SC-A-25(维度/权重/每日上限配置 CRUD 与生效)、SC-A-26(能量来源比例与归因权重治理);
+ *       {@code tests/user-scenarios/07-five-dimension.md} ED-13 / ED-14;</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.13 + §4.60.9 + §4.60.16 ——
+ *       {@code DimensionAdminController} /api/admin/dimension(norm/list/save/delete、data-source)、
+ *       {@code DimensionConfigController} /api/admin/dimension-config(list/get/save/delete/tree)、
+ *       {@code DimensionWeightController} /api/admin/dimension-weight(save/list)、
+ *       {@code AdminEnergyConfigController} /api/admin/energy-config(source-list/source-save、
+ *       daily-limits/daily-limit-save);</li>
+ *   <li>用例:{@code tests/test-cases/admin.md} TC-A-25 / TC-A-25-E / TC-A-25-P /
+ *       TC-A-26 / TC-A-26-E / TC-A-26-P。</li>
+ * </ul>
+ *
+ * <p><b>被校验的契约行为</b></p>
+ * <ul>
+ *   <li><b>正常流(结构锚点)</b>:四个控制器存在并暴露约定端点(SC-A-25/26 主流程 1:CRUD 可用);</li>
+ *   <li><b>异常流(TC-A-25-E / TC-A-26-E)</b>:
+ *       <ul>
+ *         <li>维度权重非法(负/超界/和≠100%)→ 400 拦截,不落库;</li>
+ *         <li>每日上限非法(负/超界)→ 400 拦截;</li>
+ *         <li>能量来源比例非法(负/超 1/和≠1 即和≠100)→ 400 拦截;</li>
+ *         <li>删除被引用维度 → 需确认/拒绝,不级联破坏存量;</li>
+ *         <li>并发改同配置 → 乐观锁/版本控制({@code ProductDimensionConfig} 应含 {@code version} 字段);</li>
+ *       </ul></li>
+ *   <li><b>权限流(TC-A-25-P / TC-A-26-P)</b>:非 admin 操作 403 —— 控制器方法应带
+ *       {@code @RequestAttribute("role")} 守卫(仿 {@code FileRecordController}:{@code !"admin".equals(role) → 403})。</li>
+ * </ul>
+ *
+ * <p><b>实现现状(2026-11-13 核实)</b>:四个控制器类均已存在且 CRUD 端点已落地,
+ * 但<b>异常流 / 边界流 / 权限流契约行为均未实现</b>:
+ * <ul>
+ *   <li>{@code DimensionWeightController.save} 仅校验非空,无权重和/范围校验;</li>
+ *   <li>{@code AdminEnergyConfigController.dailyLimitSave} 仅校验非空,无上限范围校验;</li>
+ *   <li>{@code AdminEnergyConfigController.sourceSave} 无比例和/范围校验;</li>
+ *   <li>{@code ProductDimensionConfigService.deleteConfig} 直接 {@code removeById},无「被引用维度」拒绝;</li>
+ *   <li>{@code ProductDimensionConfig} 无 {@code version} 字段(无乐观锁);</li>
+ *   <li>四个控制器方法均无 {@code @RequestAttribute("role")} 权限守卫(非 admin 不 403)。</li>
+ * </ul>
+ * 因此<b>异常流 / 边界流 / 权限流用例应失败(红)</b>,GREEN 阶段按上述契约实现后转绿。</p>
+ *
+ * <p><b>测试范围</b>:纯契约级单测,仅 Mockito + 反射,无 Spring 上下文 / 无 DB / 无外部服务。</p>
+ *
+ * <p><b>RED 预期</b>:结构锚点用例通过(绿,基线已落地);异常流 / 边界流 / 权限流用例失败(红),
+ * 断言原文为契约要求的「400 拦截 / 拒绝删除 / version 字段 / role 守卫」缺失。</p>
+ */
+class DimensionConfigAdminContractTest extends EnergyContractTestBase {
+
+    // ==================== 被校验对象 ====================
+
+    private DimensionWeightController weightController;
+    private DimensionWeightService weightService;
+
+    private AdminEnergyConfigController configController;
+    private EnergySourceConfigMapper sourceConfigMapper;
+    private DimensionDailyLimitMapper dailyLimitMapper;
+
+    private ProductDimensionConfigService dimensionConfigService;
+    private ProductDimensionConfigMapper dimensionConfigMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        // --- DimensionWeightController(权重治理,SC-A-25) ---
+        weightController = new DimensionWeightController();
+        weightService = mock(DimensionWeightService.class);
+        inject(weightController, "dimensionWeightService", weightService);
+
+        // --- AdminEnergyConfigController(来源比例 + 每日上限,SC-A-25/A-26) ---
+        configController = new AdminEnergyConfigController();
+        sourceConfigMapper = mock(EnergySourceConfigMapper.class);
+        dailyLimitMapper = mock(DimensionDailyLimitMapper.class);
+        inject(configController, "energySourceConfigMapper", sourceConfigMapper);
+        inject(configController, "dimensionDailyLimitMapper", dailyLimitMapper);
+
+        // --- ProductDimensionConfigService(维度配置 CRUD,SC-A-25) ---
+        dimensionConfigService = new ProductDimensionConfigService();
+        dimensionConfigMapper = mock(ProductDimensionConfigMapper.class);
+        inject(dimensionConfigService, "baseMapper", dimensionConfigMapper);
+
+        // MyBatis-Plus TableInfo 预注册:使 LambdaQueryWrapper 无 Spring 上下文可解析列名
+        initTable(EnergySourceConfig.class);
+        initTable(DimensionDailyLimit.class);
+        initTable(ProductDimensionConfig.class);
+    }
+
+    // ==================== 正常流(结构锚点,SC-A-25/26 主流程 1:CRUD 可用) ====================
+
+    @Test
+    @DisplayName("SC-A-25 正常流(结构锚点):DimensionConfigController 暴露 list/get/save/delete/tree")
+    void dimensionConfigController_exposesRequiredEndpoints() {
+        assertTrue(hasEndpoint(DimensionConfigController.class, "/list"),
+                "DimensionConfigController 应暴露 POST /api/admin/dimension-config/list");
+        assertTrue(hasEndpoint(DimensionConfigController.class, "/get"),
+                "DimensionConfigController 应暴露 POST /api/admin/dimension-config/get");
+        assertTrue(hasEndpoint(DimensionConfigController.class, "/save"),
+                "DimensionConfigController 应暴露 POST /api/admin/dimension-config/save");
+        assertTrue(hasEndpoint(DimensionConfigController.class, "/delete"),
+                "DimensionConfigController 应暴露 POST /api/admin/dimension-config/delete");
+        assertTrue(hasEndpoint(DimensionConfigController.class, "/tree"),
+                "DimensionConfigController 应暴露 POST /api/admin/dimension-config/tree");
+    }
+
+    @Test
+    @DisplayName("SC-A-09/SC-A-25 正常流(结构锚点):DimensionAdminController 暴露 norm + data-source")
+    void dimensionAdminController_exposesNormAndDataSourceEndpoints() {
+        assertTrue(hasEndpoint(DimensionAdminController.class, "/norm/list")
+                        && hasEndpoint(DimensionAdminController.class, "/norm/save")
+                        && hasEndpoint(DimensionAdminController.class, "/norm/delete"),
+                "DimensionAdminController 应暴露 /api/admin/dimension/norm/{list,save,delete}");
+        assertTrue(hasEndpoint(DimensionAdminController.class, "/data-source/list")
+                        && hasEndpoint(DimensionAdminController.class, "/data-source/save")
+                        && hasEndpoint(DimensionAdminController.class, "/data-source/delete")
+                        && hasEndpoint(DimensionAdminController.class, "/data-source/detail"),
+                "DimensionAdminController 应暴露 /api/admin/dimension/data-source/{list,save,delete,detail}");
+    }
+
+    @Test
+    @DisplayName("SC-A-25 正常流(结构锚点):DimensionWeightController 暴露 save/list")
+    void dimensionWeightController_exposesSaveAndList() {
+        assertTrue(hasEndpoint(DimensionWeightController.class, "/save")
+                        && hasEndpoint(DimensionWeightController.class, "/list"),
+                "DimensionWeightController 应暴露 /api/admin/dimension-weight/{save,list}");
+    }
+
+    @Test
+    @DisplayName("SC-A-25/SC-A-26 正常流(结构锚点):AdminEnergyConfigController 暴露来源比例 + 每日上限")
+    void adminEnergyConfigController_exposesSourceAndDailyLimitEndpoints() {
+        assertTrue(hasEndpoint(AdminEnergyConfigController.class, "/source-list")
+                        && hasEndpoint(AdminEnergyConfigController.class, "/source-save")
+                        && hasEndpoint(AdminEnergyConfigController.class, "/source-delete"),
+                "AdminEnergyConfigController 应暴露 /api/admin/energy-config/source-{list,save,delete}");
+        assertTrue(hasEndpoint(AdminEnergyConfigController.class, "/daily-limits")
+                        && hasEndpoint(AdminEnergyConfigController.class, "/daily-limit-save"),
+                "AdminEnergyConfigController 应暴露 /api/admin/energy-config/daily-limit{,-save}");
+    }
+
+    // ==================== 异常流(TC-A-25-E):维度权重非法(负/超界/和≠100%)→ 400 ====================
+
+    @Test
+    @DisplayName("TC-A-25-E 异常:权重和≠100% → 400 拦截,不落库")
+    void weightSave_sumNot100_returns400() {
+        // 60 + 30 = 90 ≠ 100
+        Result<Void> r = weightController.save(weightParams(Arrays.asList(
+                weight("body", 60), weight("mind", 30))));
+        assertEquals(400, r.getCode(),
+                "RED[admin.dimension.config] 权重和≠100%(60+30=90)应 400 拦截(TC-A-25-E),"
+                        + "当前实现未校验、直接落库返回 " + r.getCode());
+    }
+
+    @Test
+    @DisplayName("TC-A-25-E 异常:权重为负 → 400 拦截")
+    void weightSave_negativeWeight_returns400() {
+        Result<Void> r = weightController.save(weightParams(
+                Collections.singletonList(weight("body", -10))));
+        assertEquals(400, r.getCode(),
+                "RED[admin.dimension.config] 负权重(-10)应 400 拦截(TC-A-25-E),"
+                        + "当前实现未校验,返回 " + r.getCode());
+    }
+
+    @Test
+    @DisplayName("TC-A-25-E 异常:权重超界(>100)→ 400 拦截")
+    void weightSave_overBoundWeight_returns400() {
+        Result<Void> r = weightController.save(weightParams(
+                Collections.singletonList(weight("body", 120))));
+        assertEquals(400, r.getCode(),
+                "RED[admin.dimension.config] 超界权重(120>100)应 400 拦截(TC-A-25-E),"
+                        + "当前实现未校验,返回 " + r.getCode());
+    }
+
+    // ==================== 异常流(TC-A-25-E):每日上限非法(负/超界)→ 400 ====================
+
+    @Test
+    @DisplayName("TC-A-25-E 异常:每日上限为负 → 400 拦截")
+    void dailyLimitSave_negativeLimit_returns400() {
+        DailyLimitSaveRequest req = new DailyLimitSaveRequest();
+        req.setLimits(Collections.singletonList(limit("body", -5)));
+        Result<String> r = configController.dailyLimitSave(req);
+        assertEquals(400, r.getCode(),
+                "RED[admin.dimension.config] 每日上限(-5)应 400 拦截(TC-A-25-E),"
+                        + "当前实现未校验,返回 " + r.getCode());
+    }
+
+    @Test
+    @DisplayName("TC-A-25-E 异常:每日上限超界 → 400 拦截")
+    void dailyLimitSave_overBoundLimit_returns400() {
+        DailyLimitSaveRequest req = new DailyLimitSaveRequest();
+        req.setLimits(Collections.singletonList(limit("body", 100000)));
+        Result<String> r = configController.dailyLimitSave(req);
+        assertEquals(400, r.getCode(),
+                "RED[admin.dimension.config] 每日上限(100000)超界应 400 拦截(TC-A-25-E),"
+                        + "当前实现未校验,返回 " + r.getCode());
+    }
+
+    // ==================== 异常流(TC-A-26-E):来源比例非法(负/超 1/和≠100)→ 400 ====================
+
+    @Test
+    @DisplayName("TC-A-26-E 异常:来源比例和≠100 → 400 拦截")
+    void sourceSave_ratioSumNot100_returns400() {
+        // 60 + 30 = 90 ≠ 100
+        Result<String> r = configController.sourceSave(ratioRequest(60, 30));
+        assertEquals(400, r.getCode(),
+                "RED[admin.dimension.config] 来源比例和≠100(60+30=90)应 400 拦截(TC-A-26-E),"
+                        + "当前实现未校验,返回 " + r.getCode());
+    }
+
+    @Test
+    @DisplayName("TC-A-26-E 异常:来源比例为负 → 400 拦截")
+    void sourceSave_negativeRatio_returns400() {
+        Result<String> r = configController.sourceSave(ratioRequest(-10, 0));
+        assertEquals(400, r.getCode(),
+                "RED[admin.dimension.config] 负比例(-10)应 400 拦截(TC-A-26-E),"
+                        + "当前实现未校验,返回 " + r.getCode());
+    }
+
+    @Test
+    @DisplayName("TC-A-26-E 异常:来源比例超 1(>100)→ 400 拦截")
+    void sourceSave_ratioOver100_returns400() {
+        Result<String> r = configController.sourceSave(ratioRequest(150, 0));
+        assertEquals(400, r.getCode(),
+                "RED[admin.dimension.config] 超界比例(150>100)应 400 拦截(TC-A-26-E),"
+                        + "当前实现未校验,返回 " + r.getCode());
+    }
+
+    // ==================== 边界流(TC-A-25-E):删除被引用维度 → 拒绝,不级联破坏存量 ====================
+
+    @Test
+    @DisplayName("TC-A-25-E 边界:删除被引用维度(含子节点)应被拒绝,不级联删除")
+    void deleteConfig_referencedDimension_rejectedNotSilentDelete() {
+        // 维度 id=1 被引用(存在子节点,selectCount=1);若未被拦截则删除会成功(deleteById=1)
+        when(dimensionConfigMapper.selectCount(any())).thenReturn(1L);
+        when(dimensionConfigMapper.deleteById(any())).thenReturn(1);
+
+        boolean result = dimensionConfigService.deleteConfig(1L);
+
+        assertFalse(result,
+                "RED[admin.dimension.config] 删除被引用维度(含子节点)应被拒绝(返回 false),"
+                        + "不得级联破坏存量(TC-A-25-E);当前实现未做引用检查,直接 removeById 返回 "
+                        + result);
+        // 契约要求:删除前必须先做「被引用」检查(selectCount),且被引用时不执行删除(deleteById 不被调用)
+        verify(dimensionConfigMapper).selectCount(any());
+        verify(dimensionConfigMapper, never()).deleteById(any());
+    }
+
+    // ==================== 边界流(TC-A-25-E):并发改同配置 → 乐观锁/版本控制 ====================
+
+    @Test
+    @DisplayName("TC-A-25-E 边界:ProductDimensionConfig 应含 version 字段(乐观锁,并发不互相覆盖)")
+    void productDimensionConfig_hasVersionOptimisticLock() {
+        assertTrue(hasField(ProductDimensionConfig.class, "version"),
+                "RED[admin.dimension.config] ProductDimensionConfig 应含 version 字段"
+                        + "(乐观锁版本控制,TC-A-25-E 并发改同配置不互相覆盖);当前实体无 version 字段");
+    }
+
+    // ==================== 权限流(TC-A-25-P / TC-A-26-P):非 admin 操作 403 ====================
+
+    @Test
+    @DisplayName("TC-A-25-P/TC-A-26-P 权限:DimensionConfigController 应带 @RequestAttribute(\"role\") 守卫")
+    void dimensionConfigController_hasRoleGuard_forNonAdmin403() {
+        assertTrue(hasRoleGuard(DimensionConfigController.class),
+                "RED[admin.dimension.config] DimensionConfigController 方法应带"
+                        + " @RequestAttribute(\"role\") 守卫,非 admin 操作 403(TC-A-25-P);"
+                        + "当前方法均无 role 参数");
+    }
+
+    @Test
+    @DisplayName("TC-A-25-P 权限:DimensionWeightController 应带 @RequestAttribute(\"role\") 守卫")
+    void dimensionWeightController_hasRoleGuard() {
+        assertTrue(hasRoleGuard(DimensionWeightController.class),
+                "RED[admin.dimension.config] DimensionWeightController 方法应带"
+                        + " @RequestAttribute(\"role\") 守卫,非 admin 操作 403(TC-A-25-P);"
+                        + "当前方法均无 role 参数");
+    }
+
+    @Test
+    @DisplayName("TC-A-26-P 权限:AdminEnergyConfigController 应带 @RequestAttribute(\"role\") 守卫")
+    void adminEnergyConfigController_hasRoleGuard() {
+        assertTrue(hasRoleGuard(AdminEnergyConfigController.class),
+                "RED[admin.dimension.config] AdminEnergyConfigController 方法应带"
+                        + " @RequestAttribute(\"role\") 守卫,非 admin 改全局 403(TC-A-26-P);"
+                        + "当前方法均无 role 参数");
+    }
+
+    // ==================== 辅助构造 ====================
+
+    private Map<String, Object> weightParams(List<Map<String, Object>> weights) {
+        Map<String, Object> params = new HashMap<>();
+        params.put("targetType", "member");
+        params.put("targetId", 50L);
+        params.put("weights", weights);
+        return params;
+    }
+
+    private Map<String, Object> weight(String dimension, int weight) {
+        Map<String, Object> m = new LinkedHashMap<>();
+        m.put("dimension", dimension);
+        m.put("weight", weight);
+        m.put("enabled", true);
+        return m;
+    }
+
+    private EnergySourceConfigSaveRequest ratioRequest(int ratio1, int ratio2) {
+        EnergySourceConfigSaveRequest req = new EnergySourceConfigSaveRequest();
+        req.setSourceType("task");
+        req.setSourceId(100L);
+        req.setSourceName("任务A");
+        List<DimensionRatio> ratios = new ArrayList<>();
+        ratios.add(ratio(5L, ratio1));
+        if (ratio2 != 0) {
+            ratios.add(ratio(3L, ratio2));
+        }
+        req.setRatios(ratios);
+        return req;
+    }
+
+    private DimensionRatio ratio(Long dimensionId, int ratio) {
+        DimensionRatio r = new DimensionRatio();
+        r.setDimensionId(dimensionId);
+        r.setRatio(ratio);
+        return r;
+    }
+
+    private DimensionDailyLimit limit(String code, Integer dailyLimit) {
+        DimensionDailyLimit l = new DimensionDailyLimit();
+        l.setDimensionCode(code);
+        l.setDailyLimit(dailyLimit);
+        Date now = new Date();
+        l.setCreatedAt(now);
+        l.setUpdatedAt(now);
+        return l;
+    }
+
+    // ==================== 反射辅助 ====================
+
+    /** 控制器是否暴露指定 POST 路径端点 */
+    private boolean hasEndpoint(Class<?> controller, String path) {
+        for (Method m : controller.getDeclaredMethods()) {
+            PostMapping pm = m.getAnnotation(PostMapping.class);
+            if (pm != null) {
+                for (String v : pm.value()) {
+                    if (path.equals(v)) {
+                        return true;
+                    }
+                }
+            }
+        }
+        return false;
+    }
+
+    /** 控制器是否含 @RequestAttribute("role") 参数守卫 */
+    private boolean hasRoleGuard(Class<?> controller) {
+        for (Method m : controller.getDeclaredMethods()) {
+            for (java.lang.reflect.Parameter p : m.getParameters()) {
+                RequestAttribute ra = p.getAnnotation(RequestAttribute.class);
+                if (ra != null && "role".equals(ra.value())) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    /** 实体是否含指定字段 */
+    private boolean hasField(Class<?> clazz, String fieldName) {
+        for (Field f : clazz.getDeclaredFields()) {
+            if (f.getName().equals(fieldName)) {
+                return true;
+            }
+        }
+        return false;
+    }
+}