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