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