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

test(S-cfc-7): 补齐 dimension.registry.getTree 契约级单测

黑盒 @SpringBootTest + MockMvc HTTP 契约测试,仅依赖 cfclub 已有类(JwtConfig/Jackson),
不假定 impl 包路径(实现类在 feature 分支)。endpoint 未合入前(ba4ac94d7)验证 RED:
9 用例全 FAIL 404。

impl 随后合入主线(57e162242)后,测试在主线上复跑暴露两个实缺陷(本测试不改 src/main):
1. 启动时序缺陷:DimensionCodeMapper.reload()(@PostConstruct) 在 DatabaseInitializer.run()(CommandLineRunner)
   迁移445(legacy_alias 列)之前执行 → 全新/旧 schema 库 Unknown column legacy_alias → 全量
   @SpringBootTest 上下文加载失败(9/9 RED,非 404)。
2. buildTree 缺陷:按 source_system 过滤后只收集 parentId==null 根;sourceSystem=health 时
   body 根(source=energy)被排除 → 7 健康子维成孤儿返回空数组,与契约不符(见用例 Javadoc)。

用例覆盖:无参全树(5根)、body 7 健康子维、契约字段、sourceSystem 空串/未知值/health、
匿名/teacher/admin 可达性。legacyAlias 按契约断言为字符串(JSON数组)。
Sisyphus 1 неделя назад
Родитель
Сommit
e0ae1e459c

+ 279 - 0
cfc-backend/src/test/java/com/etotem/cfc/integration/controller/DimensionRegistryTreeTest.java

@@ -0,0 +1,279 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.config.JwtConfig;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+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.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * TDD 覆盖驱动(S-cfc-7 / 能力键 dimension.registry.getTree)—— 契约级黑盒单测。
+ *
+ * <p>契约来源:docs/superpowers/specs/2026-11-03-unified-dimension-system-design.md §5.1
+ * {@code GET /api/dimension-registry/tree?sourceSystem=(可选) → Result<List<DimensionNode>>}。
+ * DimensionNode 字段:{@code id, code, name, parentId, nodeKind, sourceSystem, legacyAlias, wuxingRuleRef, children}。</p>
+ *
+ * <p>实现说明:控制器/服务/DTO 原仅在 feature/unified-dimension-p0 分支;
+ * 本测试为纯 HTTP 黑盒契约测试(不 import 任何 impl 类、不假定包路径)。
+ * 在 impl 合入前的 cfclub 主线(ba4ac94d7)上证得 RED:9 用例全 FAIL(404)。
+ * impl 随后合入主线(57e162242,迁移445-447 + seed),本测试改用真实接口校验:
+ * 除已知缺陷用例外应转 GREEN。</p>
+ *
+ * <p>角色/权限口径:注册中心为只读统一取数入口,controller 无 {@code @RequestAttribute("role"/"roles")}
+ * 校验;路径既不在 JwtInterceptor PUBLIC_PATHS 也不在 LOGIN_REQUIRED_PATHS,
+ * 匿名请求(userId=-1)被放行到控制器。故权限契约 = 匿名/教师/管理员均可 200 读取。</p>
+ *
+ * <p>已知缺陷记录(impl 合入后已复现,见 Javadoc 用例 tree_filterByHealth_returnsHealthSubtree):
+ * {@code DimensionRegistryService.buildTree(sourceSystem)} 先按 source_system 过滤节点再收集 parentId==null 的根;
+ * 当过滤来源为子维来源(sourceSystem=health,body 根 source_system=energy)时,
+ * 健康子节点因父节点被过滤而成为"孤儿",均非根 → 返回空数组。
+ * getTree("health") 契约上应返回健康维度子树(body.growth 等 7 节点)。
+ * 本测试按契约断言非空 7 节点,暴露该缺陷;缺陷修复属 src/main 侧,本测试不改实现。</p>
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+public class DimensionRegistryTreeTest {
+
+    private static final String TREE_URL = "/api/dimension-registry/tree";
+
+    /** 统一维度树 5 根(设计 §2.1,权威 code) */
+    private static final List<String> ROOT_CODES = Arrays.asList("body", "mind", "wisdom", "action", "wealth");
+
+    /** body 下 7 个健康子维(设计 §2.3,legacy_alias 保留旧名) */
+    private static final List<String> BODY_CHILD_CODES = Arrays.asList(
+            "body.growth", "body.sleep", "body.vision", "body.immunity",
+            "body.nutrition", "body.gut", "body.exercise");
+
+    @Autowired
+    private MockMvc mockMvc;
+
+    @Autowired
+    private ObjectMapper objectMapper;
+
+    @Resource
+    private JwtConfig jwtConfig;
+
+    // ============ 正常路径 ============
+
+    @Test
+    @DisplayName("tree 无参:200 + code=200 + data 为数组,且含 5 根")
+    void tree_noFilter_returnsUnifiedFiveRoots() throws Exception {
+        MvcResult result = mockMvc.perform(get(TREE_URL)
+                        .accept(MediaType.APPLICATION_JSON))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data").isArray())
+                .andExpect(jsonPath("$.data.length()").value(ROOT_CODES.size()))
+                .andReturn();
+
+        Set<String> codes = new HashSet<>();
+        for (JsonNode root : objectMapper.readTree(result.getResponse().getContentAsString()).get("data")) {
+            codes.add(root.get("code").asText());
+        }
+        assertEquals(new HashSet<>(ROOT_CODES), codes, "顶层根应为统一 5 根");
+    }
+
+    @Test
+    @DisplayName("tree 无参:body 根含 7 个健康子维(body.growth..body.exercise)")
+    void tree_noFilter_bodyRootHasSevenHealthChildren() throws Exception {
+        MvcResult result = mockMvc.perform(get(TREE_URL)
+                        .accept(MediaType.APPLICATION_JSON))
+                .andExpect(status().isOk())
+                .andReturn();
+
+        JsonNode body = findNodeByCode(parseTree(result), "body");
+        assertNotNull(body, "应存在 body 根节点");
+        assertTrue(body.get("children").isArray(), "body.children 应为数组");
+        assertEquals(BODY_CHILD_CODES.size(), body.get("children").size(),
+                "body 子维数量应为 7(设计 §2.3)");
+
+        Set<String> childCodes = new HashSet<>();
+        Set<String> childSources = new HashSet<>();
+        Set<String> childKinds = new HashSet<>();
+        for (JsonNode child : body.get("children")) {
+            childCodes.add(child.get("code").asText());
+            if (child.hasNonNull("sourceSystem")) {
+                childSources.add(child.get("sourceSystem").asText());
+            }
+            if (child.hasNonNull("nodeKind")) {
+                childKinds.add(child.get("nodeKind").asText());
+            }
+        }
+        assertEquals(new HashSet<>(BODY_CHILD_CODES), childCodes, "body 子维 code");
+        assertEquals(Collections.singleton("health"), childSources, "body 子维 sourceSystem 应为 health");
+        assertEquals(Collections.singleton("sub"), childKinds, "body 子维 nodeKind 应为 sub");
+    }
+
+    @Test
+    @DisplayName("tree 无参:全部节点具备 DimensionNode 契约字段(id/code/name/parentId/nodeKind/sourceSystem/legacyAlias/wuxingRuleRef/children)")
+    void tree_noFilter_allNodesExposeContractFields() throws Exception {
+        MvcResult result = mockMvc.perform(get(TREE_URL)
+                        .accept(MediaType.APPLICATION_JSON))
+                .andExpect(status().isOk())
+                .andReturn();
+
+        List<JsonNode> all = new ArrayList<>();
+        for (JsonNode root : parseTree(result)) {
+            collectNodes(root, all);
+        }
+        assertTrue(all.size() >= ROOT_CODES.size(), "树节点数应 >= 5");
+        for (JsonNode n : all) {
+            assertTrue(n.has("id"), "缺 id: " + n);
+            assertTrue(n.hasNonNull("code"), "缺 code: " + n);
+            assertTrue(n.hasNonNull("name"), "缺 name: " + n);
+            assertTrue(n.has("parentId"), "缺 parentId: " + n);
+            assertTrue(n.hasNonNull("nodeKind"), "缺 nodeKind: " + n);
+            assertTrue(n.hasNonNull("sourceSystem"), "缺 sourceSystem: " + n);
+            // legacyAlias 为 JSON 数组字符串(设计 §5.1/§6: String legacyAlias // JSON数组),响应中非数组
+            assertTrue(n.has("legacyAlias"), "缺 legacyAlias: " + n);
+            if (n.hasNonNull("legacyAlias")) {
+                assertTrue(n.get("legacyAlias").isTextual(), "legacyAlias 应为字符串(JSON数组): " + n);
+            }
+            assertTrue(n.has("wuxingRuleRef"), "缺 wuxingRuleRef: " + n);
+            // children 为可选数组(设计 §5.1: children?),叶子节点为 null
+            assertTrue(n.has("children"), "缺 children: " + n);
+            if (n.hasNonNull("children")) {
+                assertTrue(n.get("children").isArray(), "children 应为数组: " + n);
+            }
+        }
+    }
+
+    // ============ 关键边界 ============
+
+    @Test
+    @DisplayName("边界:sourceSystem 传空字符串 → 等价无过滤,仍返回 5 根")
+    void tree_emptySourceSystem_treatedAsNoFilter() throws Exception {
+        mockMvc.perform(get(TREE_URL)
+                        .param("sourceSystem", "")
+                        .accept(MediaType.APPLICATION_JSON))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.length()").value(ROOT_CODES.size()));
+    }
+
+    @Test
+    @DisplayName("边界:sourceSystem 传不存在的来源 → 200 + 空数组")
+    void tree_unknownSourceSystem_returnsEmptyArray() throws Exception {
+        mockMvc.perform(get(TREE_URL)
+                        .param("sourceSystem", "__no_such_source__")
+                        .accept(MediaType.APPLICATION_JSON))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data").isArray())
+                .andExpect(jsonPath("$.data.length()").value(0));
+    }
+
+    @Test
+    @DisplayName("边界:sourceSystem=health → 返回健康维度子树(含 body.growth 等 7 节点)")
+    void tree_filterByHealth_returnsHealthSubtree() throws Exception {
+        MvcResult result = mockMvc.perform(get(TREE_URL)
+                        .param("sourceSystem", "health")
+                        .accept(MediaType.APPLICATION_JSON))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data").isArray())
+                .andReturn();
+
+        List<JsonNode> all = new ArrayList<>();
+        for (JsonNode root : parseTree(result)) {
+            collectNodes(root, all);
+        }
+        // 契约:health 过滤应暴露健康来源子维(设计 §2.1 source_system 标记 + §2.3 7 body 维 health)
+        assertEquals(BODY_CHILD_CODES.size(), all.size(), "health 过滤应返回 7 个健康子维节点");
+        for (JsonNode n : all) {
+            assertEquals("health", n.get("sourceSystem").asText(), "health 过滤后所有节点 sourceSystem 应为 health");
+        }
+        Set<String> codes = new HashSet<>();
+        for (JsonNode n : all) {
+            codes.add(n.get("code").asText());
+        }
+        assertEquals(new HashSet<>(BODY_CHILD_CODES), codes, "health 过滤应返回 body.growth..body.exercise");
+    }
+
+    // ============ 角色/权限 ============
+
+    @Test
+    @DisplayName("权限:匿名(无 token)可读 200(只读统一取数入口,非登录必需)")
+    void tree_anonymous_noToken_allowed() throws Exception {
+        mockMvc.perform(get(TREE_URL)
+                        .accept(MediaType.APPLICATION_JSON))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200));
+    }
+
+    @Test
+    @DisplayName("权限:teacher 角色 token 可读 200")
+    void tree_teacherToken_allowed() throws Exception {
+        String token = jwtConfig.generateToken(90001L, Collections.singletonList("teacher"));
+        mockMvc.perform(get(TREE_URL)
+                        .header("Authorization", "Bearer " + token)
+                        .accept(MediaType.APPLICATION_JSON))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200));
+    }
+
+    @Test
+    @DisplayName("权限:admin 角色 token 可读 200")
+    void tree_adminToken_allowed() throws Exception {
+        String token = jwtConfig.generateToken(90002L, Collections.singletonList("admin"));
+        mockMvc.perform(get(TREE_URL)
+                        .header("Authorization", "Bearer " + token)
+                        .accept(MediaType.APPLICATION_JSON))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200));
+    }
+
+    // ============ helpers ============
+
+    private JsonNode[] parseTree(MvcResult result) throws Exception {
+        JsonNode data = objectMapper.readTree(result.getResponse().getContentAsString()).get("data");
+        JsonNode[] arr = new JsonNode[data.size()];
+        for (int i = 0; i < data.size(); i++) {
+            arr[i] = data.get(i);
+        }
+        return arr;
+    }
+
+    private JsonNode findNodeByCode(JsonNode[] roots, String code) {
+        List<JsonNode> all = new ArrayList<>();
+        for (JsonNode root : roots) {
+            collectNodes(root, all);
+        }
+        for (JsonNode n : all) {
+            if (code.equals(n.path("code").asText())) {
+                return n;
+            }
+        }
+        return null;
+    }
+
+    private void collectNodes(JsonNode node, List<JsonNode> out) {
+        out.add(node);
+        if (node.has("children") && node.get("children").isArray()) {
+            for (JsonNode child : node.get("children")) {
+                collectNodes(child, out);
+            }
+        }
+    }
+}