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