Browse Source

test(tdd): S-cfc-184 teacher.assessment.record 契约级单测(TDD-RED:SC-T-07 必填缺失拦截/重复录入覆盖非新增两边界未落地,预期3红1绿)

E2E Test Bot 2 hours ago
parent
commit
64f8a3875f

+ 153 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TeacherAssessmentRecordContractTest.java

@@ -0,0 +1,153 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.entity.DanAssessmentResult;
+import com.etotem.cfc.mapper.DanAssessmentResultMapper;
+import com.etotem.cfc.service.AssessmentService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+/**
+ * 契约级单测:能力键 {@code teacher.assessment.record}(成长规划师域 EVD-10,SC-T-07 边界条款)。
+ *
+ * <p><b>需求 / 设计锚点</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-T-07「录入测评结果触发方案 ⭐」——
+ *       边界:<b>必填缺失拦截;重复录入覆盖非新增</b>。</li>
+ *   <li>设计:{@code docs/superpowers/specs/2026-11-07-planner-zone-contract.md} §2 SC-T-07 /
+ *       §3 建议能力键映射 {@code teacher.assessment.record}(绑定 SC-T-07 / T-11 / T-12);
+ *       {@code docs/superpowers/api/API_REFERENCE.md} §4.57 SC-T-07 端点载体
+ *       {@code DanAssessmentController /api/dan-assessment/result/record}。</li>
+ *   <li>实现载体:{@code AssessmentService#recordResult(DanAssessmentResult)}
+ *       ({@code cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentService.java})。</li>
+ * </ul>
+ *
+ * <p><b>被校验的契约边界(SC-T-07)</b></p>
+ * <ul>
+ *   <li><b>必填缺失拦截</b>:录入测评结果时,{@code memberId}/{@code familyMemberId} 与
+ *       {@code dimension} 为必填;任一缺失应拦截并<b>不落库</b>(不调用 insert)。</li>
+ *   <li><b>重复录入覆盖非新增</b>:对同一成员(member)+ 同一维度(dimension)重复录入时,应
+ *       <b>覆盖(updateById)</b>既有记录,而非再新增一行(第二次录入不得走 insert)。</li>
+ * </ul>
+ *
+ * <p><b>测试范围</b>:纯 Mockito + 反射注入,无 Spring 上下文 / 无 DB / 无外部服务依赖,
+ * 沿用已合入主链同批 RED 契约测试风格(TeamProfitHookContractTest / FamilyJoinValidationRulesContractTest)。
+ *
+ * <p><b>RED 预期</b>:当前 {@code AssessmentService.recordResult} 既无必填校验、也无重复覆盖逻辑
+ * (对任何入参一律 {@code danAssessmentResultMapper.insert(result)})。以下边界断言在实现补齐前
+ * 均应失败(红);GREEN 阶段由实现方在 {@code recordResult} 补上「必填校验拦截」与
+ * 「同成员+同维度重复录入覆盖(updateById 而非 insert)」后自动转绿。</p>
+ */
+class TeacherAssessmentRecordContractTest {
+
+    private AssessmentService service;
+
+    private DanAssessmentResultMapper resultMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new AssessmentService();
+        // 反射注入全部非 static / 非 final 字段为 mock,隔离 DB / 外部服务;
+        // recordResult 主链仅触及 resultMapper / reportParseService / growthRecordService /
+        // dimensionScoreService / planGenerator / userMapper(其余仅在 parent_upload 分支触及)。
+        injectAll(service);
+        resultMapper = (DanAssessmentResultMapper) readField(service, "danAssessmentResultMapper");
+    }
+
+    // ==================== SC-T-07 边界一:必填缺失拦截 ====================
+
+    @Test
+    @DisplayName("SC-T-07 边界:memberId/familyMemberId 缺失应拦截且不落库(必填缺失拦截)")
+    void recordResult_missingMember_应拦截不落库() {
+        // 无 member 归属(memberId 与 familyMemberId 均为 null)→ 契约要求拦截,不得 insert
+        DanAssessmentResult result = plannerEntryResult();
+        result.setMemberId(null);
+        result.setFamilyMemberId(null);
+
+        service.recordResult(result);
+
+        verify(resultMapper, never()).insert(any(DanAssessmentResult.class));
+    }
+
+    @Test
+    @DisplayName("SC-T-07 边界:dimension 缺失应拦截且不落库(必填缺失拦截)")
+    void recordResult_missingDimension_应拦截不落库() {
+        // dimension 为必填(八类报告归属维度);缺失应拦截,不得 insert
+        DanAssessmentResult result = plannerEntryResult();
+        result.setDimension(null);
+
+        service.recordResult(result);
+
+        verify(resultMapper, never()).insert(any(DanAssessmentResult.class));
+    }
+
+    // ==================== SC-T-07 边界二:重复录入覆盖非新增 ====================
+
+    @Test
+    @DisplayName("SC-T-07 边界:同成员+同维度重复录入应覆盖(updateById)而非新增第二行 insert")
+    void recordResult_duplicateMemberDimension_应覆盖而非新增() {
+        // 同一成员 + 同一维度两次录入 → 第二次应走 updateById 覆盖,insert 仅允许一次(首次)
+        DanAssessmentResult first = plannerEntryResult();   // memberId=55, dimension=mind
+        DanAssessmentResult second = plannerEntryResult();  // 同 memberId=55, dimension=mind
+
+        service.recordResult(first);
+        service.recordResult(second);
+
+        // 首次录入允许 insert;重复录入不得再新增(覆盖语义)
+        verify(resultMapper, times(1)).insert(any(DanAssessmentResult.class));
+        // 覆盖语义:第二次录入必须触发 updateById
+        verify(resultMapper, atLeastOnce()).updateById(any(DanAssessmentResult.class));
+    }
+
+    // ==================== 结构锚点:契约载体存在(防假 RED 对照) ====================
+
+    @Test
+    @DisplayName("结构锚点:AssessmentService.recordResult 契约载体应存在")
+    void recordResult_契约载体存在() throws Exception {
+        assertNotNull(service, "[teacher.assessment.record] AssessmentService 实例应可创建");
+        // recordResult(DanAssessmentResult) 方法应存在于 AssessmentService(端点 /result/record 的服务宿主)
+        java.lang.reflect.Method m = AssessmentService.class.getMethod("recordResult", DanAssessmentResult.class);
+        assertNotNull(m, "[teacher.assessment.record] AssessmentService#recordResult 方法应存在");
+    }
+
+    // ==================== 辅助构造 / 反射 ====================
+
+    /** 构造一条规划师录入(SOURCE_PLANNER_ENTRY)结果:memberId=55 / dimension=mind */
+    private DanAssessmentResult plannerEntryResult() {
+        DanAssessmentResult r = new DanAssessmentResult();
+        r.setMemberId(55L);
+        r.setFamilyMemberId(55L);
+        r.setDimension("mind");
+        r.setFocusScore(80);
+        r.setSource(DanAssessmentResult.SOURCE_PLANNER_ENTRY);
+        return r;
+    }
+
+    /** 反射注入全部非 static / 非 final 字段为对应类型 mock */
+    private static void injectAll(Object target) throws Exception {
+        for (Field f : target.getClass().getDeclaredFields()) {
+            if (Modifier.isStatic(f.getModifiers()) || Modifier.isFinal(f.getModifiers())) {
+                continue;
+            }
+            f.setAccessible(true);
+            f.set(target, mock(f.getType()));
+        }
+    }
+
+    private static Object readField(Object target, String name) throws Exception {
+        Field f = target.getClass().getDeclaredField(name);
+        f.setAccessible(true);
+        return f.get(target);
+    }
+}