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