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

test(S-cfc-215): child.energy.view 契约RED单测 3红(SC-C-06 /overview+logs 越权拦截 + SC-C-07 draft未录入不展示)

Sisyphus Agent 2 часов назад
Родитель
Сommit
d96a19cad3

+ 187 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ChildEnergyViewContractTest.java

@@ -0,0 +1,187 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.DanAssessmentController;
+import com.etotem.cfc.controller.EnergyController;
+import com.etotem.cfc.entity.DanAssessmentResult;
+import com.etotem.cfc.entity.EnergyLog;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.service.AssessmentService;
+import com.etotem.cfc.service.EnergyService;
+import com.etotem.cfc.service.ReportMemberPermissionService;
+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.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 能力键 {@code child.energy.view}(任务 S-cfc-215,TDD-RED 阶段)契约级单测。
+ *
+ * <p><b>需求/设计溯源</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-C-06(查看能量与报告,只读)+
+ *       SC-C-07(查看 DAN 测评报告)——{@code docs/tdd/orchestration/sc-key-map.md} 中
+ *       S-cfc-215 为 1:N,同时覆盖 SC-C-06 与 SC-C-07;能力键 {@code child.energy.view}。</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.10(测评)+ 能力键映射
+ *       {@code docs/superpowers/specs/2026-11-07-child-zone-contract.md} §4(SC-C-06 标注
+ *       「五维能量只读(需补成员级可见性)」)。</li>
+ * </ul>
+ *
+ * <p><b>被校验的契约行为(TDD-RED 锚点)</b></p>
+ * <ul>
+ *   <li><b>SC-C-06</b>:{@link EnergyController} {@code /api/energy/overview} 与
+ *       {@code /api/energy/logs} 必须<b>只读</b>且<b>越权拦截</b>——请求查看<b>非本家庭</b>
+ *       成员的成员级能量/流水时,必须拒绝(越权 403,看不到其他成员隐私),
+ *       而非直接返回目标成员数据。</li>
+ *   <li><b>SC-C-07</b>:{@link DanAssessmentController} {@code /api/dan-assessment/result/{id}}
+ *       对<b>结果未录入</b>({@code status=draft},上传后待确认/未完成)的报告必须
+ *       <b>不展示</b>(返回 {@code data=null}),而非把半成品记录返回给前端。</li>
+ * </ul>
+ *
+ * <p><b>实现现状(本分支 src/main 核实,2026-11-13)</b></p>
+ * <ul>
+ *   <li>{@code EnergyController.getEnergyOverview / getEnergyLogs}:仅按请求参数
+ *       {@code memberId} 直接调 {@code energyService.getOverview/getLogs},<b>无任何成员级
+ *       可见性/越权校验</b>——跨家庭成员访问不会被拦截(越权拦截缺失)。</li>
+ *   <li>{@code DanAssessmentController.getResult}:{@code result != null} 即
+ *       {@code return Result.success(result)},<b>未按 {@code status} 过滤未录入(draft)</b>
+ *       报告(结果未录入不展示缺失)。</li>
+ * </ul>
+ *
+ * <p><b>测试范围</b>:纯契约级单测,仅 Mockito(无 Spring 上下文 / 无 DB / 无真实微信,
+ * 无外部服务依赖)。直接实例化 Controller 并注入 mock 依赖,断言可观察的契约行为。</p>
+ *
+ * <p><b>RED 预期</b>:上述两处契约缺口未落地——跨家庭越权用例实际返回 200(成功),
+ * 未录入(draft)用例实际返回半成品 data,全部断言失败(红),失败原文为
+ * 「RED[child.energy.view] …」。GREEN 阶段实现越权拦截与 draft 过滤后自动转绿。</p>
+ *
+ * <p><b>权限流</b>:本能力权限流即核心契约——SC-C-06 的「越权拦截」正是权限维度,
+ * 无「权限流不适用」情况。</p>
+ */
+class ChildEnergyViewContractTest {
+
+    // ==================== 契约 fixture ====================
+
+    /** 调用方所属家庭。 */
+    private static final Long CALLER_FAMILY_ID = 100L;
+    /** 调用方当前成员(自身)。 */
+    private static final Long CALLER_MEMBER_ID = 5L;
+    /** 登录账号 userId(JWT 身份)。 */
+    private static final Long USER_ID = 88L;
+    /** 越权目标:不属于调用方家庭的另一成员。 */
+    private static final Long FOREIGN_MEMBER_ID = 999L;
+    /** DAN 报告结果 id。 */
+    private static final Long RESULT_ID = 101L;
+
+    @Mock
+    private EnergyService energyService;
+
+    @Mock
+    private AssessmentService assessmentService;
+
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Mock
+    private ReportMemberPermissionService reportMemberPermissionService;
+
+    @InjectMocks
+    private EnergyController energyController;
+
+    @InjectMocks
+    private DanAssessmentController danAssessmentController;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    // ==================== SC-C-06:EnergyController /overview /logs 只读·越权拦截 ====================
+
+    @Test
+    @DisplayName("SC-C-06:/api/energy/overview 对跨家庭成员访问应越权拦截(403),不返回他人能量")
+    void energyOverview_跨家庭查看他人能量_应越权拦截403() {
+        // 契约现场:调用方 familyId=100 请求 memberId=999(不在本家庭)——必须看不到其他成员隐私
+        Map<String, Object> foreignData = new HashMap<>();
+        foreignData.put("memberId", FOREIGN_MEMBER_ID);
+        foreignData.put("overallScore", 95);
+        when(energyService.getOverview(FOREIGN_MEMBER_ID)).thenReturn(foreignData);
+
+        Result<Map<String, Object>> r = energyController.getEnergyOverview(
+                params(FOREIGN_MEMBER_ID), CALLER_FAMILY_ID, CALLER_MEMBER_ID);
+
+        // 契约:越权拦截(403 forbidden),而非成功返回他人能量数据
+        assertEquals(Integer.valueOf(403), r.getCode(),
+                "RED[child.energy.view] SC-C-06:/api/energy/overview 对跨家庭成员访问应越权拦截(403),"
+                        + "实际 code=" + r.getCode() + " message=" + r.getMessage());
+        // 只读:查看过程不得触发能量发放/扣除等写副作用
+        verify(energyService, never()).grantEnergy(anyLong(), anyLong(), anyLong(), any());
+        verify(energyService, never()).deductForWish(anyLong(), anyLong(), anyLong());
+    }
+
+    @Test
+    @DisplayName("SC-C-06:/api/energy/logs 对跨家庭成员访问应越权拦截(403),不返回他人流水")
+    void energyLogs_跨家庭查看他人流水_应越权拦截403() {
+        // 契约现场:调用方 familyId=100 请求 memberId=999(不在本家庭)——必须看不到其他成员隐私
+        Page<EnergyLog> foreignLogs = new Page<>(1, 10);
+        when(energyService.getLogs(FOREIGN_MEMBER_ID, null, 1, 10)).thenReturn(foreignLogs);
+
+        Result<Page<EnergyLog>> r = energyController.getEnergyLogs(
+                params(FOREIGN_MEMBER_ID), CALLER_FAMILY_ID, CALLER_MEMBER_ID);
+
+        // 契约:越权拦截(403 forbidden),而非成功返回他人能量流水
+        assertEquals(Integer.valueOf(403), r.getCode(),
+                "RED[child.energy.view] SC-C-06:/api/energy/logs 对跨家庭成员访问应越权拦截(403),"
+                        + "实际 code=" + r.getCode() + " message=" + r.getMessage());
+        // 只读:查看过程不得触发能量发放/扣除等写副作用
+        verify(energyService, never()).grantEnergy(anyLong(), anyLong(), anyLong(), any());
+        verify(energyService, never()).deductForWish(anyLong(), anyLong(), anyLong());
+    }
+
+    // ==================== SC-C-07:DanAssessmentController /result/{id} 结果未录入不展示 ====================
+
+    @Test
+    @DisplayName("SC-C-07:/api/dan-assessment/result/{id} 对 status=draft(结果未录入)应不展示(data=null)")
+    void danResult_未录入draft_不应展示() {
+        // 契约现场:报告已上传但结果未录入(status=draft,待确认半成品)——必须不展示
+        DanAssessmentResult draft = new DanAssessmentResult();
+        draft.setId(RESULT_ID);
+        draft.setMemberId(CALLER_MEMBER_ID);
+        draft.setStatus("draft"); // 未录入/待确认
+        when(assessmentService.getByAppointmentId(RESULT_ID)).thenReturn(draft);
+        // 权限分支:本人可见(避免因权限拒绝而非「不展示」失败)
+        when(reportMemberPermissionService.canViewMemberReports(
+                USER_ID, CALLER_MEMBER_ID, CALLER_FAMILY_ID)).thenReturn(true);
+
+        Result<DanAssessmentResult> r = danAssessmentController.getResult(
+                RESULT_ID, USER_ID, CALLER_FAMILY_ID);
+
+        // 契约:结果未录入 → 不展示(data=null),而非把 draft 半成品返回给前端
+        assertNull(r.getData(),
+                "RED[child.energy.view] SC-C-07:/api/dan-assessment/result/{id} 对 status=draft(结果未录入)"
+                        + "的报告应不展示(data=null),实际 code=" + r.getCode()
+                        + " data.status=" + (r.getData() == null ? null : r.getData().getStatus()));
+    }
+
+    // ==================== 辅助 ====================
+
+    private Map<String, Object> params(Long memberId) {
+        Map<String, Object> m = new HashMap<>();
+        m.put("memberId", memberId);
+        return m;
+    }
+}