|
|
@@ -0,0 +1,205 @@
|
|
|
+package com.etotem.cfc.controller.growth;
|
|
|
+
|
|
|
+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.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.mockito.ArgumentMatchers.eq;
|
|
|
+import static org.mockito.Mockito.mock;
|
|
|
+import static org.mockito.Mockito.never;
|
|
|
+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-217 / 能力键 {@code child.growth.archive} 契约级单测(TDD RED 阶段:契约新增、实现未落地,预期红)。
|
|
|
+ *
|
|
|
+ * <p>溯源·需求:`tests/user-scenarios/应用场景总览.md` SC-C-11(成长记录查看-孩子视角)、SC-C-20(成长档案查看/家长代导出)。
|
|
|
+ * <br>溯源·设计:`docs/superpowers/api/API_REFERENCE.md` §4.6 §4.61.9 G-11;
|
|
|
+ * `docs/superpowers/specs/2026-11-07-child-zone-contract.md` §5 G-11(跨切面:无归属校验)。
|
|
|
+ *
|
|
|
+ * <p>契约锚点(G-11):成长档案读端点 {@code POST /api/growth/record/child/{memberId}}、
|
|
|
+ * {@code POST /api/growth/record/child-records} 与 {@code POST /api/growth/record/{id}} 必须做
|
|
|
+ * 家庭归属校验(参考 {@code HealthPlanController /tasks} 的 familyId 校验口径)——任一登录用户不得
|
|
|
+ * 按 id 读取他人(跨家庭)的成长档案;越权访问应以业务码 <b>403</b> 拒绝,而不是返回他人数据。
|
|
|
+ *
|
|
|
+ * <p>RED 预期:{@link GrowthRecordController#getChildRecords(Long)} / {@code getRecordsByChild} /
|
|
|
+ * {@code getRecordDetail} 当前均无归属校验(方法签名未读取 familyId 请求属性),跨家庭读取会
|
|
|
+ * 直接返回 200+他人数据 → 本类「跨家庭拒绝」用例失败(红);同家庭正常路径用例为守住不误伤的
|
|
|
+ * 绿锚(当前无校验时可通过,GREEN 实现后仍须通过)。
|
|
|
+ *
|
|
|
+ * <p>测试隔离:MockMvc standalone + 仅 mock {@link GrowthRecordService},无 Spring 上下文 / 无 DB / 无外部服务。
|
|
|
+ */
|
|
|
+class ChildGrowthArchiveContractTest {
|
|
|
+
|
|
|
+ private static final Long PARENT_USER_ID = 1001L;
|
|
|
+ private static final Long MY_FAMILY_ID = 5001L;
|
|
|
+ private static final Long MY_MEMBER_ID = 2001L;
|
|
|
+ /** 跨家庭(他人家族)成员 id:不属于当前登录用户所在家庭 */
|
|
|
+ private static final Long FOREIGN_MEMBER_ID = 9001L;
|
|
|
+ private static final Long FOREIGN_RECORD_ID = 90001L;
|
|
|
+
|
|
|
+ private static final String MSG_MEMBER_FORBIDDEN = "无权访问该成员成长档案";
|
|
|
+ private static final String MSG_RECORD_FORBIDDEN = "无权访问该成长档案";
|
|
|
+
|
|
|
+ private GrowthRecordController controller;
|
|
|
+ private MockMvc mockMvc;
|
|
|
+ private GrowthRecordService growthRecordService;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ controller = new GrowthRecordController();
|
|
|
+ growthRecordService = mock(GrowthRecordService.class);
|
|
|
+ ReflectionTestUtils.setField(controller, "growthRecordService", growthRecordService);
|
|
|
+ // 归属校验实现(GREEN)需要注入的家族成员归属查询依赖不在本契约测试范围内,由 GREEN 阶段接线
|
|
|
+ mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
|
|
+ }
|
|
|
+
|
|
|
+ // ================================================================
|
|
|
+ // G-11 契约一:POST /api/growth/record/child/{memberId} —— 成员归属校验
|
|
|
+ // ================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("getChildRecords:跨家庭成员 - 必须 403 拒绝,不得返回他人档案(当前无校验 → 红)")
|
|
|
+ void childRecords_跨家庭成员_应拒绝读取_403() throws Exception {
|
|
|
+ // Given:他人家族成员即使存在档案,本用户也无权读取
|
|
|
+ List<GrowthRecord> foreignRecords = Collections.singletonList(
|
|
|
+ buildGrowthRecord(1L, FOREIGN_MEMBER_ID, "2026-06-15", "他人档案")
|
|
|
+ );
|
|
|
+ when(growthRecordService.getRecordsByChild(eq(FOREIGN_MEMBER_ID))).thenReturn(foreignRecords);
|
|
|
+
|
|
|
+ // When:携带本家庭 familyId/currentMemberId 请求属性(模拟 JwtInterceptor 注入)读取跨家庭成员档案
|
|
|
+ mockMvc.perform(post("/api/growth/record/child/" + FOREIGN_MEMBER_ID)
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
+ .requestAttr("userId", PARENT_USER_ID)
|
|
|
+ .requestAttr("familyId", MY_FAMILY_ID)
|
|
|
+ .requestAttr("currentMemberId", MY_MEMBER_ID))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(403))
|
|
|
+ .andExpect(jsonPath("$.message").value(MSG_MEMBER_FORBIDDEN));
|
|
|
+
|
|
|
+ // Then:归属门禁应先于 Service 查询生效,绝不允许按他人 memberId 放行
|
|
|
+ verify(growthRecordService, never()).getRecordsByChild(eq(FOREIGN_MEMBER_ID));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("getChildRecords:同家庭成员 - 正常返回档案列表(绿锚,防归属校验误伤)")
|
|
|
+ void childRecords_同家庭成员_正常返回_200() throws Exception {
|
|
|
+ List<GrowthRecord> myRecords = Arrays.asList(
|
|
|
+ buildGrowthRecord(1L, MY_MEMBER_ID, "2026-06-15", "我的档案1"),
|
|
|
+ buildGrowthRecord(2L, MY_MEMBER_ID, "2026-03-01", "我的档案2")
|
|
|
+ );
|
|
|
+ when(growthRecordService.getRecordsByChild(eq(MY_MEMBER_ID))).thenReturn(myRecords);
|
|
|
+
|
|
|
+ mockMvc.perform(post("/api/growth/record/child/" + MY_MEMBER_ID)
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
+ .requestAttr("userId", PARENT_USER_ID)
|
|
|
+ .requestAttr("familyId", MY_FAMILY_ID)
|
|
|
+ .requestAttr("currentMemberId", MY_MEMBER_ID))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(200))
|
|
|
+ .andExpect(jsonPath("$.data.length()").value(2))
|
|
|
+ .andExpect(jsonPath("$.data[0].childId").value(MY_MEMBER_ID));
|
|
|
+
|
|
|
+ verify(growthRecordService).getRecordsByChild(eq(MY_MEMBER_ID));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ================================================================
|
|
|
+ // G-11 契约二:POST /api/growth/record/child-records —— 裸 memberId body 同受归属校验
|
|
|
+ // ================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("getRecordsByChild(body):跨家庭成员 - 必须 403 拒绝(body 路径同受归属校验 → 红)")
|
|
|
+ void childRecordsByBody_跨家庭成员_应拒绝读取_403() throws Exception {
|
|
|
+ // Given:body 传裸 Long memberId(API 口径),目标成员属于其他家庭
|
|
|
+ List<GrowthRecord> foreignRecords = Collections.singletonList(
|
|
|
+ buildGrowthRecord(3L, FOREIGN_MEMBER_ID, "2026-06-15", "他人档案")
|
|
|
+ );
|
|
|
+ when(growthRecordService.getRecordsByChild(eq(FOREIGN_MEMBER_ID))).thenReturn(foreignRecords);
|
|
|
+
|
|
|
+ mockMvc.perform(post("/api/growth/record/child-records")
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
+ .content(String.valueOf(FOREIGN_MEMBER_ID))
|
|
|
+ .requestAttr("userId", PARENT_USER_ID)
|
|
|
+ .requestAttr("familyId", MY_FAMILY_ID)
|
|
|
+ .requestAttr("currentMemberId", MY_MEMBER_ID))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(403))
|
|
|
+ .andExpect(jsonPath("$.message").value(MSG_MEMBER_FORBIDDEN));
|
|
|
+
|
|
|
+ // Then:归属门禁应拦截,不允许跨家庭按 memberId 放行
|
|
|
+ verify(growthRecordService, never()).getRecordsByChild(eq(FOREIGN_MEMBER_ID));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ================================================================
|
|
|
+ // G-11 契约三:POST /api/growth/record/{id} —— 档案明细归属校验
|
|
|
+ // ================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("getRecordDetail:跨家庭档案 - 必须 403 拒绝,不得按 id 读他人档案(当前无校验 → 红)")
|
|
|
+ void recordDetail_跨家庭档案_应拒绝读取_403() throws Exception {
|
|
|
+ // Given:档案存在但属于他人家族成员(memberId=FOREIGN_MEMBER_ID)
|
|
|
+ GrowthRecord foreignRecord = buildGrowthRecord(FOREIGN_RECORD_ID, FOREIGN_MEMBER_ID, "2026-06-15", "他人档案详情");
|
|
|
+ when(growthRecordService.getRecordById(eq(FOREIGN_RECORD_ID))).thenReturn(foreignRecord);
|
|
|
+
|
|
|
+ mockMvc.perform(post("/api/growth/record/" + FOREIGN_RECORD_ID)
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
+ .requestAttr("userId", PARENT_USER_ID)
|
|
|
+ .requestAttr("familyId", MY_FAMILY_ID)
|
|
|
+ .requestAttr("currentMemberId", MY_MEMBER_ID))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(403))
|
|
|
+ .andExpect(jsonPath("$.message").value(MSG_RECORD_FORBIDDEN));
|
|
|
+
|
|
|
+ // Then:归属门禁应先于 Service 查询生效,绝不允许按他人档案 id 放行
|
|
|
+ verify(growthRecordService, never()).getRecordById(eq(FOREIGN_RECORD_ID));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("getRecordDetail:同家庭档案 - 正常返回明细(绿锚,防归属校验误伤)")
|
|
|
+ void recordDetail_同家庭档案_正常返回_200() throws Exception {
|
|
|
+ GrowthRecord myRecord = buildGrowthRecord(4L, MY_MEMBER_ID, "2026-06-15", "我的档案详情");
|
|
|
+ when(growthRecordService.getRecordById(eq(4L))).thenReturn(myRecord);
|
|
|
+
|
|
|
+ mockMvc.perform(post("/api/growth/record/4")
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
+ .requestAttr("userId", PARENT_USER_ID)
|
|
|
+ .requestAttr("familyId", MY_FAMILY_ID)
|
|
|
+ .requestAttr("currentMemberId", MY_MEMBER_ID))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(200))
|
|
|
+ .andExpect(jsonPath("$.data.childId").value(MY_MEMBER_ID))
|
|
|
+ .andExpect(jsonPath("$.data.recordTitle").value("我的档案详情"));
|
|
|
+
|
|
|
+ verify(growthRecordService).getRecordById(eq(4L));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ================================================================
|
|
|
+ // 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;
|
|
|
+ }
|
|
|
+}
|