Просмотр исходного кода

merge: 合入远端 cfclub 进度后推送 S-cfc-151 契约测试

Sisyphus Agent 23 часов назад
Родитель
Сommit
932a43bc82

+ 285 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/DanAssessmentRecordCreateContractTest.java

@@ -0,0 +1,285 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.DanAssessmentController;
+import com.etotem.cfc.entity.AssessmentMaterial;
+import com.etotem.cfc.entity.AssessmentRecord;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.mapper.AssessmentRecordMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.service.AssessmentService;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.lang.reflect.Field;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:DAN 测评记录创建(能力键 {@code dan.assessment.recordCreate},单元 dan.assessment)。
+ *
+ * <p><b>需求/设计锚点</b></p>
+ * <ul>
+ *   <li>需求:{@code docs/tdd/SRS.md}「三、未覆盖控制器」——
+ *       {@code DanAssessmentController}(29 方法,0 覆盖);</li>
+ *   <li>设计:{@code DanAssessmentController#createRecord} ——
+ *       {@code @PostMapping("/record/create")}(源文件
+ *       {@code cfc-backend/src/main/java/com/etotem/cfc/controller/DanAssessmentController.java})
+ *       → {@code AssessmentService#createRecord(Long familyId, Long memberId, Long materialId)};</li>
+ *   <li>契约:{@code docs/superpowers/api/API_REFERENCE.md} §4.10 测评({@code /api/dan-assessment/*},
+ *       SC-T-07 端点范围)+ {@code docs/flows/dan-assessment-flow.md} 端点明细——
+ *       {@code POST /api/dan-assessment/record/create}:请求 {@code {familyId, childId, materialId}},
+ *       响应 {@code AssessmentRecord}。</li>
+ * </ul>
+ *
+ * <p><b>被校验的契约行为</b></p>
+ * <ul>
+ *   <li><b>正常流(已落地 → 绿锁)</b>:未加入家庭({@code familyId=null})→
+ *       {@code Result.noFamily("请先创建或加入家庭")}(code 5001)且不触碰 service;合法参数 →
+ *       创建 {@code status=pending} 记录、写入 {@code createdAt}、{@code familyId/memberId/materialId}
+ *       落位并落库;</li>
+ *   <li><b>边界流(未落地 → RED)</b>:<b>必填参数守卫缺失</b>——{@code materialId} 为空、
+ *       {@code memberId} 为空时应拒绝创建且不落库;<b>材料有效性校验缺失</b>——{@code materialId}
+ *       指向不存在的测评材料时应拒绝且不落库(当前 {@code createRecord} 对非法输入直接落库
+ *       {@code null} 字段记录);</li>
+ *   <li><b>权限流(未落地 → RED)</b>:<b>家庭归属校验缺失</b>——{@code memberId} 不属于
+ *       {@code familyId}(跨家庭越权构造)时应拒绝且不落库(当前 {@code createRecord} 无任何
+ *       成员-家庭归属校验,可直接为他人家庭成员创建测评记录,参照 SC-T-13 数据隔离精神)。</li>
+ * </ul>
+ *
+ * <p><b>测试范围</b>:纯 Mockito + 反射,无 Spring 上下文 / 无 DB / 无外部服务依赖。</p>
+ *
+ * <p><b>RED 预期</b>:边界流 3 例 + 权限流 1 例失败(红),断言原文含
+ * {@code RED[dan.assessment.recordCreate]};正常流 2 例为现状回归(绿),作为测试脚手架有效性对照。
+ * GREEN 阶段由实现方在 {@code AssessmentService.createRecord} 前置:
+ * ① {@code materialId}/{@code memberId} 非空守卫;② 材料存在且启用校验(经 baseMapper/材料 mapper);
+ * ③ 成员-家庭归属校验(经 {@code familyMemberMapper},参照 SC-T-13)后,红用例自动转绿。</p>
+ */
+class DanAssessmentRecordCreateContractTest {
+
+    /** 契约端点:DAN 测评记录创建 */
+    private static final String ENDPOINT = "/api/dan-assessment/record/create";
+
+    private static final Long FAMILY_ID = 100L;
+    private static final Long MEMBER_ID = 200L;
+    private static final Long MATERIAL_ID = 5L;
+    /** 不属于 FAMILY_ID 的他人家庭成员 id(越权构造) */
+    private static final Long FOREIGN_MEMBER_ID = 999L;
+    private static final Long OTHER_FAMILY_ID = 999L;
+
+    // ==================== 正常流(已落地):service 创建默认值 ====================
+
+    @Test
+    @DisplayName("正常流-1:createRecord 合法参数 → status=pending、createdAt 写入、字段落位并落库")
+    void createRecord_validParams_defaultsPending_andPersisted() throws Exception {
+        AssessmentService svc = new AssessmentService();
+        AssessmentRecordMapper mapper = mock(AssessmentRecordMapper.class);
+        inject(svc, "recordMapper", mapper);
+        // GREEN 前瞻:stub 材料有效 + 成员属家庭,使 GREEN 后正常流仍绿(当前实现未调用这些校验,红阶段不触达)。
+        stubHappyValidation(svc);
+
+        AssessmentRecord saved = svc.createRecord(FAMILY_ID, MEMBER_ID, MATERIAL_ID);
+
+        assertEquals("pending", saved.getStatus(),
+                "[dan.assessment.recordCreate] 创建测评记录后 status 应为 pending(待测评)");
+        assertNotNull(saved.getCreatedAt(),
+                "[dan.assessment.recordCreate] createdAt 应默认写入");
+        assertEquals(FAMILY_ID, saved.getFamilyId(), "记录应归属当前家庭");
+        assertEquals(MEMBER_ID, saved.getMemberId(), "记录应归属被测成员");
+        assertEquals(MATERIAL_ID, saved.getMaterialId(), "记录应绑定测评材料");
+
+        ArgumentCaptor<AssessmentRecord> cap = ArgumentCaptor.forClass(AssessmentRecord.class);
+        verify(mapper).insert(cap.capture());
+        assertEquals(FAMILY_ID, cap.getValue().getFamilyId(), "落库记录 familyId 应一致");
+        assertEquals(MEMBER_ID, cap.getValue().getMemberId(), "落库记录 memberId 应一致");
+        assertEquals(MATERIAL_ID, cap.getValue().getMaterialId(), "落库记录 materialId 应一致");
+        assertEquals("pending", cap.getValue().getStatus(), "落库记录 status 应一致");
+    }
+
+    // ==================== 正常流(已落地):controller 未加入家庭守卫 ====================
+
+    @Test
+    @DisplayName("正常流-2:controller familyId 缺失 → Result.noFamily(5001) 且不触碰 service")
+    void controller_noFamily_returnsNoFamily_andNeverDelegates() throws Exception {
+        DanAssessmentController controller = new DanAssessmentController();
+        AssessmentService svc = mock(AssessmentService.class);
+        inject(controller, "assessmentService", svc);
+
+        Result<?> r = controller.createRecord(validBody(), null, MEMBER_ID);
+
+        assertEquals(Integer.valueOf(5001), r.getCode(),
+                "[dan.assessment.recordCreate] 未加入家庭(familyId=null)应返回 noFamily code 5001");
+        assertEquals("请先创建或加入家庭", r.getMessage(),
+                "[dan.assessment.recordCreate] noFamily 提示文案应为「请先创建或加入家庭」");
+        verify(svc, never()).createRecord(any(), any(), any());
+    }
+
+    // ==================== 边界流(未落地 → RED):必填参数守卫 ====================
+
+    @Test
+    @DisplayName("边界流-1(RED):materialId 为空 → 应拒绝创建且不落库")
+    void createRecord_materialIdNull_shouldReject_andNeverInsert() throws Exception {
+        AssessmentService svc = new AssessmentService();
+        AssessmentRecordMapper mapper = mock(AssessmentRecordMapper.class);
+        inject(svc, "recordMapper", mapper);
+        when(mapper.insert(any(AssessmentRecord.class))).thenReturn(1);
+
+        // 契约:materialId 是创建测评记录的必要输入(请求 {familyId, childId, materialId});
+        // 缺失应拒绝(抛异常或返回错误),且不得落库。
+        rejectOrRun(() -> svc.createRecord(FAMILY_ID, MEMBER_ID, null));
+
+        assertRejectedWithoutPersist(mapper,
+                "materialId 为空时应拒绝创建且不落库(materialId 必填守卫缺失,"
+                        + "契约不允许创建 materialId=null 的测评记录)。");
+    }
+
+    @Test
+    @DisplayName("边界流-2(RED):memberId 为空 → 应拒绝创建且不落库")
+    void createRecord_memberIdNull_shouldReject_andNeverInsert() throws Exception {
+        AssessmentService svc = new AssessmentService();
+        AssessmentRecordMapper mapper = mock(AssessmentRecordMapper.class);
+        inject(svc, "recordMapper", mapper);
+        when(mapper.insert(any(AssessmentRecord.class))).thenReturn(1);
+
+        // 契约:memberId(等价历史 childId)是创建测评记录的必要输入;缺失应拒绝且不得落库。
+        rejectOrRun(() -> svc.createRecord(FAMILY_ID, null, MATERIAL_ID));
+
+        assertRejectedWithoutPersist(mapper,
+                "memberId 为空时应拒绝创建且不落库(memberId 必填守卫缺失,"
+                        + "契约要求测评记录必须归属明确家庭成员)。");
+    }
+
+    // ==================== 边界流(未落地 → RED):材料有效性校验 ====================
+
+    @Test
+    @DisplayName("边界流-3(RED):materialId 指向不存在的材料 → 应拒绝创建且不落库")
+    void createRecord_materialNotExist_shouldReject_andNeverInsert() throws Exception {
+        AssessmentService svc = new AssessmentService();
+        AssessmentRecordMapper mapper = mock(AssessmentRecordMapper.class);
+        inject(svc, "recordMapper", mapper);
+        when(mapper.insert(any(AssessmentRecord.class))).thenReturn(1);
+
+        // 材料查询(ServiceImpl.baseMapper 或材料 mapper):返回不存在 → 契约要求拒绝。
+        inject(svc, "baseMapper", mockMaterialBaseReturning(null));
+
+        rejectOrRun(() -> svc.createRecord(FAMILY_ID, MEMBER_ID, MATERIAL_ID));
+
+        assertRejectedWithoutPersist(mapper,
+                "materialId 指向不存在的测评材料时应拒绝创建且不落库(材料有效性校验缺失,"
+                        + "契约要求创建记录必须基于存在的测评材料)。");
+    }
+
+    // ==================== 权限流(未落地 → RED):成员-家庭归属校验 ====================
+
+    @Test
+    @DisplayName("权限流-1(RED):memberId 不属于该家庭 → 应拒绝创建且不落库(防跨家庭越权)")
+    void createRecord_memberNotInFamily_shouldReject_andNeverInsert() throws Exception {
+        AssessmentService svc = new AssessmentService();
+        AssessmentRecordMapper mapper = mock(AssessmentRecordMapper.class);
+        inject(svc, "recordMapper", mapper);
+        when(mapper.insert(any(AssessmentRecord.class))).thenReturn(1);
+
+        // 成员-家庭归属:memberId 属于其他家庭(familyId=999L)→ 契约要求拒绝(SC-T-13 数据隔离)。
+        FamilyMember foreign = new FamilyMember();
+        foreign.setId(FOREIGN_MEMBER_ID);
+        foreign.setFamilyId(OTHER_FAMILY_ID);
+        FamilyMemberMapper familyMemberMapper = mock(FamilyMemberMapper.class);
+        when(familyMemberMapper.selectById(any())).thenReturn(foreign);
+        when(familyMemberMapper.selectCount(any())).thenReturn(0L);
+        when(familyMemberMapper.selectList(any())).thenReturn(Collections.emptyList());
+        inject(svc, "familyMemberMapper", familyMemberMapper);
+
+        rejectOrRun(() -> svc.createRecord(FAMILY_ID, FOREIGN_MEMBER_ID, MATERIAL_ID));
+
+        assertRejectedWithoutPersist(mapper,
+                "memberId 不属于 familyId 时应拒绝创建且不落库(成员-家庭归属校验缺失,"
+                        + "契约只允许为本人家庭成员创建测评记录,跨家庭越权构造应被拒绝,参照 SC-T-13)。");
+    }
+
+    // ==================== 辅助 ====================
+
+    /** 契约请求体(memberId 语义,实体 @JsonProperty("childId") 序列化别名) */
+    private Map<String, Object> validBody() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("memberId", MEMBER_ID);
+        body.put("materialId", MATERIAL_ID);
+        return body;
+    }
+
+    /** GREEN 前瞻:stub 材料有效 + 成员属家庭(正常流对照用例在 GREEN 后仍绿)。 */
+    private void stubHappyValidation(AssessmentService svc) throws Exception {
+        AssessmentMaterial active = new AssessmentMaterial();
+        active.setId(MATERIAL_ID);
+        active.setStatus("active");
+        inject(svc, "baseMapper", mockMaterialBaseReturning(active));
+
+        FamilyMember self = new FamilyMember();
+        self.setId(MEMBER_ID);
+        self.setFamilyId(FAMILY_ID);
+        FamilyMemberMapper familyMemberMapper = mock(FamilyMemberMapper.class);
+        when(familyMemberMapper.selectById(any())).thenReturn(self);
+        when(familyMemberMapper.selectCount(any())).thenReturn(1L);
+        when(familyMemberMapper.selectList(any())).thenReturn(Collections.singletonList(self));
+        inject(svc, "familyMemberMapper", familyMemberMapper);
+    }
+
+    /** 材料 baseMapper mock:selectById 返回指定材料(null=不存在)。 */
+    private Object mockMaterialBaseReturning(AssessmentMaterial material) {
+        com.etotem.cfc.mapper.AssessmentMaterialMapper base =
+                mock(com.etotem.cfc.mapper.AssessmentMaterialMapper.class);
+        when(base.selectById(any())).thenReturn(material);
+        return base;
+    }
+
+    /** 调用目标动作;若 GREEN 以抛异常形态拒绝则吞掉(视为已拒绝),以 insert 未被调用为主断言。 */
+    private void rejectOrRun(Runnable action) {
+        try {
+            action.run();
+        } catch (RuntimeException expected) {
+            // GREEN 候选形态:非法输入抛参数/业务异常 → 视为已拒绝;最终以 insert 未被调用判定。
+        }
+    }
+
+    /**
+     * 契约断言:非法输入后不得落库。若 recordMapper.insert 仍被调用(当前实现未拦截),
+     * 抛带 {@code RED[dan.assessment.recordCreate]} 契约文案的失败(包装 Mockito 原始断言)。
+     */
+    private void assertRejectedWithoutPersist(AssessmentRecordMapper mapper, String contractReason) {
+        try {
+            verify(mapper, never()).insert(any(AssessmentRecord.class));
+        } catch (AssertionError e) {
+            throw new AssertionError(
+                    "RED[dan.assessment.recordCreate] " + contractReason
+                            + " 实际 recordMapper.insert 仍被调用(非法输入未被拦截,契约行为缺失)。"
+                            + "原始断言: " + e.getMessage(), e);
+        }
+    }
+
+    /** 反射注入字段值(沿类层次向上查找;字段缺失即抛异常 → 用例失败)。 */
+    private static void inject(Object target, String fieldName, Object value) throws Exception {
+        Class<?> cls = target.getClass();
+        Field f = null;
+        while (cls != null && f == null) {
+            try {
+                f = cls.getDeclaredField(fieldName);
+            } catch (NoSuchFieldException e) {
+                cls = cls.getSuperclass();
+            }
+        }
+        if (f == null) {
+            throw new NoSuchFieldException(target.getClass().getName() + "#" + fieldName);
+        }
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+}