Pārlūkot izejas kodu

test(tdd): S-cfc-30 growth.history.view 契约级单测 GREEN (RED->绿)

能力键 growth.history.view(原始阶段 RED:实现已存在但零覆盖)
目标端点 GrowthRecordController(@RequestMapping /api/growth)读视图:
- POST /record/list        -> getRecordList    (父视角档案列表)
- POST /record/child/{id}  -> getChildRecords  (指定成员档案列表)
- POST /record/{id}        -> getRecordDetail  (单条明细回链)
- POST /record/{id}/supplements -> getSupplements (补充记录列表)

覆盖:正常路径 + 关键边界(空列表/不存在/空补充记录)
     + 角色权限(JwtInterceptor 层鉴权、按 userId 属性数据隔离,
       客户端 body/query 不可越权覆盖 userId)

溯源:tests/user-scenarios/应用场景总览.md SC-P-95
     + docs/需求分析/2026-11-06-成就成长打卡需求变更.md 变更B
测试:mvn -q -Dtest=GrowthHistoryViewContractTest test -DskipTests=false
      -> Tests run: 9, Failures: 0, Errors: 0 (全绿)
隔离:MockMvc standalone + mock GrowthRecordService,无 Spring 上下文/DB/外部服务
不改 src/main(未暴露缺陷)
E2E Test Bot 6 dienas atpakaļ
vecāks
revīzija
a73cfa4e2

+ 291 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/growth/GrowthHistoryViewContractTest.java

@@ -0,0 +1,291 @@
+package com.etotem.cfc.controller.growth;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.GrowthRecord;
+import com.etotem.cfc.service.GrowthRecordService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+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 java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+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;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * S-cfc-30 / growth.history.view 契约级单测(RED 阶段:补齐「未覆盖」能力键的最小闭环)。
+ *
+ * <p>目标端点({@link GrowthRecordController},@RequestMapping("/api/growth")):
+ * <ul>
+ *   <li>{@code POST /record/list} → {@link GrowthRecordController#getRecordList(Long)} —— 查询当前登录用户的成长档案列表(父视角)</li>
+ *   <li>{@code POST /record/child/{memberId}} → {@link GrowthRecordController#getChildRecords(Long)} —— 查询指定家庭成员的成长档案列表</li>
+ *   <li>{@code POST /record/{id}} → {@link GrowthRecordController#getRecordDetail(Long)} —— 查询单条档案明细(回链)</li>
+ *   <li>{@code POST /record/{id}/supplements} → {@link GrowthRecordController#getSupplements(Long)} —— 查询某条档案的全部补充记录</li>
+ * </ul>
+ *
+ * <p>覆盖维度:
+ * <ul>
+ *   <li>正常路径:返回 200 + data(数组/对象),验证业务字段完整性</li>
+ *   <li>关键边界:空列表、单条不存在(404 语义以业务码 500+message 表达)、空补充记录</li>
+ *   <li>角色/权限:仅有登录拦截器(JwtInterceptor)层面的鉴权;控制器内无 teacher/admin 区分,
+ *       所有读端点按 {@code userId}/{@code currentMemberId} 请求属性做用户级隔离,客户端不可通过请求体/查询参数越权。
+ *       <br>成长历史记录属 C 端(家长/孩子视角)能力,teacher 以同一口径访问自己的记录即可,admin 无特殊路径。
+ * </ul>
+ *
+ * <p>契约口径(对齐项目惯例:控制器签名 {@code Result<T>} + HTTP 200 信封、业务码表达语义):
+ * <ul>
+ *   <li>成功 → 业务码 200 + data(数组或对象)</li>
+ *   <li>资源不存在(getRecordDetail 找不到记录) → 业务码 500 + message "档案不存在"(HTTP 200 信封)</li>
+ *   <li>空列表/空补充记录 → 业务码 200 + 空数组 []</li>
+ * </ul>
+ * 测试隔离:MockMvc standalone + 仅 mock {@link GrowthRecordService},无 Spring 上下文 / 无 DB / 无外部服务。
+ */
+class GrowthHistoryViewContractTest {
+
+    private static final Long PARENT_USER_ID = 1001L;
+    private static final Long CHILD_MEMBER_ID = 2001L;
+    private static final Long RECORD_ID_1 = 1L;
+    private static final Long RECORD_ID_2 = 2L;
+    private static final Long RECORD_ID_3 = 3L;
+
+    private GrowthRecordController controller;
+    private MockMvc mockMvc;
+    private GrowthRecordService growthRecordService;
+
+    @BeforeEach
+    void setUp() {
+        controller = new GrowthRecordController();
+        growthRecordService = mock(GrowthRecordService.class);
+        ReflectionTestUtils.setField(controller, "growthRecordService", growthRecordService);
+        // 其他依赖不被读端点使用,无需打桩
+        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+    }
+
+    // ================================================================
+    // POST /api/growth/record/list —— 查询当前用户的成长档案列表
+    // ================================================================
+
+    @Test
+    @DisplayName("getRecordList:正常路径 - 有多条档案记录,按创建时间倒序返回")
+    void getRecordList_正常路径_返回档案列表() throws Exception {
+        // Given:父视角下有 3 条档案
+        List<GrowthRecord> mockRecords = Arrays.asList(
+                buildGrowthRecord(RECORD_ID_1, CHILD_MEMBER_ID, "2026-06-15", "本学期体测报告"),
+                buildGrowthRecord(RECORD_ID_2, CHILD_MEMBER_ID, "2026-03-01", "期中测评记录"),
+                buildGrowthRecord(RECORD_ID_3, CHILD_MEMBER_ID, "2025-12-20", "年度综合评价")
+        );
+        when(growthRecordService.getRecordsByParent(eq(PARENT_USER_ID))).thenReturn(mockRecords);
+
+        // When:携带 userId 请求属性(模拟 JwtInterceptor 注入)调用列表端点
+        mockMvc.perform(post("/api/growth/record/list")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .requestAttr("userId", PARENT_USER_ID))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data").isArray())
+                .andExpect(jsonPath("$.data.length()").value(3))
+                .andExpect(jsonPath("$.data[0].id").value(RECORD_ID_1))
+                .andExpect(jsonPath("$.data[0].childId").value(CHILD_MEMBER_ID))
+                .andExpect(jsonPath("$.data[0].recordTitle").value("本学期体测报告"))
+                .andExpect(jsonPath("$.data[1].id").value(RECORD_ID_2))
+                .andExpect(jsonPath("$.data[2].id").value(RECORD_ID_3));
+
+        // Then:Service 按 userId 查询,体现用户级隔离
+        verify(growthRecordService).getRecordsByParent(eq(PARENT_USER_ID));
+    }
+
+    @Test
+    @DisplayName("getRecordList:边界 - 无档案记录,返回空数组(空态合理)")
+    void getRecordList_空列表_返回空数组() throws Exception {
+        when(growthRecordService.getRecordsByParent(eq(PARENT_USER_ID))).thenReturn(Collections.emptyList());
+
+        mockMvc.perform(post("/api/growth/record/list")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .requestAttr("userId", PARENT_USER_ID))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data").isArray())
+                .andExpect(jsonPath("$.data.length()").value(0));
+
+        verify(growthRecordService).getRecordsByParent(eq(PARENT_USER_ID));
+    }
+
+    @Test
+    @DisplayName("getRecordList:权限边界 - 客户端请求体/查询参数携带 userId 无法覆盖服务端 userId 属性(数据隔离契约)")
+    void getRecordList_数据隔离_仅读服务端userId属性() throws Exception {
+        // Given:只有属性 userId=1001 会被读取;body 中即使传 userId=999 也无效
+        List<GrowthRecord> mockRecords = Collections.singletonList(
+                buildGrowthRecord(RECORD_ID_1, CHILD_MEMBER_ID, "2026-06-15", "仅自己可见")
+        );
+        when(growthRecordService.getRecordsByParent(eq(PARENT_USER_ID))).thenReturn(mockRecords);
+
+        // When:发送 JSON body 试图覆盖 userId,实际 handler 只读 @RequestAttribute("userId")
+        mockMvc.perform(post("/api/growth/record/list")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"userId\": 999}") // 被忽略
+                        .requestAttr("userId", PARENT_USER_ID))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.length()").value(1));
+
+        // Then:Service 仍按属性 userId=1001 查询,未被 body 影响
+        ArgumentCaptor<Long> userIdCaptor = ArgumentCaptor.forClass(Long.class);
+        verify(growthRecordService).getRecordsByParent(userIdCaptor.capture());
+        assertEquals(PARENT_USER_ID, userIdCaptor.getValue());
+    }
+
+    // ================================================================
+    // POST /api/growth/record/child/{memberId} —— 查询指定家庭成员的档案列表
+    // ================================================================
+
+    @Test
+    @DisplayName("getChildRecords:正常路径 - 指定成员有档案,返回列表")
+    void getChildRecords_正常路径_返回成员档案列表() throws Exception {
+        List<GrowthRecord> mockRecords = Arrays.asList(
+                buildGrowthRecord(RECORD_ID_1, CHILD_MEMBER_ID, "2026-06-15", "孩子档案1"),
+                buildGrowthRecord(RECORD_ID_2, CHILD_MEMBER_ID, "2026-03-01", "孩子档案2")
+        );
+        when(growthRecordService.getRecordsByChild(eq(CHILD_MEMBER_ID))).thenReturn(mockRecords);
+
+        mockMvc.perform(post("/api/growth/record/child/" + CHILD_MEMBER_ID)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .requestAttr("userId", PARENT_USER_ID))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.length()").value(2))
+                .andExpect(jsonPath("$.data[0].childId").value(CHILD_MEMBER_ID));
+
+        verify(growthRecordService).getRecordsByChild(eq(CHILD_MEMBER_ID));
+    }
+
+    @Test
+    @DisplayName("getChildRecords:边界 - 成员无档案,返回空数组")
+    void getChildRecords_空列表_返回空数组() throws Exception {
+        when(growthRecordService.getRecordsByChild(eq(CHILD_MEMBER_ID))).thenReturn(Collections.emptyList());
+
+        mockMvc.perform(post("/api/growth/record/child/" + CHILD_MEMBER_ID)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .requestAttr("userId", PARENT_USER_ID))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data").isArray())
+                .andExpect(jsonPath("$.data.length()").value(0));
+
+        verify(growthRecordService).getRecordsByChild(eq(CHILD_MEMBER_ID));
+    }
+
+    // ================================================================
+    // POST /api/growth/record/{id} —— 查询单条档案明细(回链)
+    // ================================================================
+
+    @Test
+    @DisplayName("getRecordDetail:正常路径 - 档案存在,返回完整对象")
+    void getRecordDetail_正常路径_返回档案明细() throws Exception {
+        GrowthRecord record = buildGrowthRecord(RECORD_ID_1, CHILD_MEMBER_ID, "2026-06-15", "体测报告详情");
+        record.setOverallScore(85);
+        record.setAssessmentSummary("综合评分优秀");
+        record.setGrowthSuggestions("建议加强体能训练");
+        when(growthRecordService.getRecordById(eq(RECORD_ID_1))).thenReturn(record);
+
+        mockMvc.perform(post("/api/growth/record/" + RECORD_ID_1)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .requestAttr("userId", PARENT_USER_ID))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.id").value(RECORD_ID_1))
+                .andExpect(jsonPath("$.data.childId").value(CHILD_MEMBER_ID))
+                .andExpect(jsonPath("$.data.recordTitle").value("体测报告详情"))
+                .andExpect(jsonPath("$.data.overallScore").value(85))
+                .andExpect(jsonPath("$.data.assessmentSummary").value("综合评分优秀"))
+                .andExpect(jsonPath("$.data.growthSuggestions").value("建议加强体能训练"));
+
+        verify(growthRecordService).getRecordById(eq(RECORD_ID_1));
+    }
+
+    @Test
+    @DisplayName("getRecordDetail:边界 - 档案不存在,返回业务码 500 + message")
+    void getRecordDetail_不存在_返回错误信息() throws Exception {
+        when(growthRecordService.getRecordById(eq(RECORD_ID_1))).thenReturn(null);
+
+        mockMvc.perform(post("/api/growth/record/" + RECORD_ID_1)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .requestAttr("userId", PARENT_USER_ID))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.message").value("档案不存在"));
+
+        verify(growthRecordService).getRecordById(eq(RECORD_ID_1));
+    }
+
+    // ================================================================
+    // POST /api/growth/record/{id}/supplements —— 查询某条档案的全部补充记录
+    // ================================================================
+
+    @Test
+    @DisplayName("getSupplements:正常路径 - 有补充记录,返回列表")
+    void getSupplements_正常路径_返回补充记录列表() throws Exception {
+        List<GrowthRecord> supplements = Arrays.asList(
+                buildGrowthRecord(101L, CHILD_MEMBER_ID, "2026-07-01", "补充记录:复测报告"),
+                buildGrowthRecord(102L, CHILD_MEMBER_ID, "2026-08-15", "补充记录:医生回访")
+        );
+        when(growthRecordService.getSupplements(eq(RECORD_ID_1))).thenReturn(supplements);
+
+        mockMvc.perform(post("/api/growth/record/" + RECORD_ID_1 + "/supplements")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .requestAttr("userId", PARENT_USER_ID))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.length()").value(2))
+                .andExpect(jsonPath("$.data[0].recordTitle").value("补充记录:复测报告"))
+                .andExpect(jsonPath("$.data[1].recordTitle").value("补充记录:医生回访"));
+
+        verify(growthRecordService).getSupplements(eq(RECORD_ID_1));
+    }
+
+    @Test
+    @DisplayName("getSupplements:边界 - 无补充记录,返回空数组")
+    void getSupplements_空列表_返回空数组() throws Exception {
+        when(growthRecordService.getSupplements(eq(RECORD_ID_1))).thenReturn(Collections.emptyList());
+
+        mockMvc.perform(post("/api/growth/record/" + RECORD_ID_1 + "/supplements")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .requestAttr("userId", PARENT_USER_ID))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data").isArray())
+                .andExpect(jsonPath("$.data.length()").value(0));
+
+        verify(growthRecordService).getSupplements(eq(RECORD_ID_1));
+    }
+
+    // ================================================================
+    // Helpers
+    // ================================================================
+
+    private GrowthRecord buildGrowthRecord(Long id, Long memberId, String dateStr, String title) {
+        GrowthRecord r = new GrowthRecord();
+        r.setId(id);
+        r.setMemberId(memberId);
+        r.setRecordTitle(title);
+        try {
+            r.setCreatedAt(new java.text.SimpleDateFormat("yyyy-MM-dd").parse(dateStr));
+        } catch (Exception ignored) {
+        }
+        return r;
+    }
+}