Quellcode durchsuchen

test(contract): S-cfc-217 child.growth.archive RED 契约测试(G-11 成长档案归属校验 + G-05 家长代导出,5 红 2 绿锚)

Sisyphus vor 2 Stunden
Ursprung
Commit
3ad476ad7c

+ 205 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/growth/ChildGrowthArchiveContractTest.java

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

+ 126 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ChildGrowthArchiveExportContractTest.java

@@ -0,0 +1,126 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.AssessmentOrderMapper;
+import com.etotem.cfc.mapper.ChatMessageMapper;
+import com.etotem.cfc.mapper.DanAssessmentResultMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.HealthReportMapper;
+import com.etotem.cfc.mapper.PaymentOrderMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.AccountCleanupService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.Collections;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+/**
+ * S-cfc-217 / 能力键 {@code child.growth.archive} 家长代导出子契约(TDD RED 阶段:预期红)。
+ *
+ * <p>溯源·需求:`tests/user-scenarios/应用场景总览.md` SC-C-20(成长档案查看/家长代导出);
+ * 规则:「未成年人导出受限,需家长代办」。
+ * <br>溯源·设计:`docs/superpowers/api/API_REFERENCE.md` §4.61.9 G-05;
+ * `docs/superpowers/specs/2026-11-07-child-zone-contract.md` §3.4 §5 G-05。
+ *
+ * <p>契约锚点(G-05):{@code AccountCleanupService.exportUserData} 生成的个人数据导出包,
+ * 必须覆盖家庭内「不能登录用户」(原「孩子」)成员的<b>成长档案</b>——即导出包应包含一个
+ * {@code growthRecords} 区块,承载家庭各成员的成长记录({@code growth_records}),以支持家长
+ * 代导出指定成员的成长档案。当前实现仅导出 userInfo / children / assessmentResults / healthReports /
+ * tasks,<b>完全缺失成长档案区块</b> → 本类断言失败(红)。
+ *
+ * <p>测试隔离:纯 Mockito(@Mock 各 Mapper + @InjectMocks {@link AccountCleanupService}),
+ * 无 Spring 上下文 / 无 DB / 无外部服务。字符串/集合断言驱动,不依赖真实外部服务。
+ */
+class ChildGrowthArchiveExportContractTest {
+
+    private static final Long PARENT_USER_ID = 1001L;
+    private static final Long CHILD_MEMBER_ID = 2001L;
+    private static final Long FAMILY_ID = 5001L;
+
+    @Mock
+    private UserMapper userMapper;
+
+    @Mock
+    private DanAssessmentResultMapper danAssessmentResultMapper;
+
+    @Mock
+    private HealthReportMapper healthReportMapper;
+
+    @Mock
+    private ChatMessageMapper chatMessageMapper;
+
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Mock
+    private AssessmentOrderMapper assessmentOrderMapper;
+
+    @Mock
+    private PaymentOrderMapper paymentOrderMapper;
+
+    @Mock
+    private TaskMapper taskMapper;
+
+    @InjectMocks
+    private AccountCleanupService service;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    @Test
+    @DisplayName("exportUserData:家长导出包应包含家庭成员的成长档案区块(当前缺失 → 红)")
+    void exportUserData_家长导出_应包含成员成长档案() {
+        // Given:家长账号,家庭内有一个不能登录用户(原「孩子」)成员,且无其它外部依赖干扰
+        User parent = new User();
+        parent.setId(PARENT_USER_ID);
+        parent.setFamilyId(FAMILY_ID);
+        when(userMapper.selectById(PARENT_USER_ID)).thenReturn(parent);
+
+        FamilyMember child = new FamilyMember();
+        child.setId(CHILD_MEMBER_ID);
+        child.setFamilyId(FAMILY_ID);
+        when(familyMemberMapper.selectList(any())).thenReturn(Collections.singletonList(child));
+
+        // When:家长导出个人数据(成长档案代导出载体)
+        Map<String, Object> data = service.exportUserData(PARENT_USER_ID);
+
+        // Then:导出包必须包含 growthRecords 区块以承载成员成长档案(SC-C-20 / G-05)
+        assertTrue(data.containsKey("growthRecords"),
+                "导出包应包含 growthRecords 区块(成长档案),当前实现完全缺失(G-05)");
+        assertNotNull(data.get("growthRecords"), "growthRecords 区块不应为 null");
+    }
+
+    @Test
+    @DisplayName("exportUserData:无成长记录时 growthRecords 区块为稳定空集合(契约完整性 → 红)")
+    void exportUserData_无成长记录_档案区块为空集合() {
+        // Given:家长账号 + 家庭内一个成员,成长档案为空
+        User parent = new User();
+        parent.setId(PARENT_USER_ID);
+        parent.setFamilyId(FAMILY_ID);
+        when(userMapper.selectById(PARENT_USER_ID)).thenReturn(parent);
+        when(familyMemberMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        // When:家长导出个人数据
+        Map<String, Object> data = service.exportUserData(PARENT_USER_ID);
+
+        // Then:growthRecords 区块始终存在且为空集合(不是缺失、不是 null)
+        assertTrue(data.get("growthRecords") instanceof java.util.Collection,
+                "导出包应包含 growthRecords 区块,且无记录时为空集合(当前缺失,G-05)");
+        assertTrue(((java.util.Collection<?>) data.get("growthRecords")).isEmpty(),
+                "无成长记录时应为空集合");
+    }
+}