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

test(S-cfc-146): health.report.detail 契约级单测 — 正常流/异常流/内容契约 13 例(实现已存在,测试全绿)

Sisyphus 1 день назад
Родитель
Сommit
0824ebcf89
1 измененных файлов с 374 добавлено и 0 удалено
  1. 374 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/HealthReportDetailContractTest.java

+ 374 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/HealthReportDetailContractTest.java

@@ -0,0 +1,374 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.HealthReportController;
+import com.etotem.cfc.dto.ParsedReportPayload;
+import com.etotem.cfc.entity.HealthDiseaseRisk;
+import com.etotem.cfc.entity.HealthGutFlora;
+import com.etotem.cfc.entity.HealthIndicator;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.mapper.HealthDiseaseRiskMapper;
+import com.etotem.cfc.mapper.HealthGutFloraMapper;
+import com.etotem.cfc.mapper.HealthIndicatorMapper;
+import com.etotem.cfc.mapper.HealthReportMapper;
+import com.etotem.cfc.service.HealthReportService;
+import com.etotem.cfc.service.ReportBlockService;
+import com.etotem.cfc.service.ReportMemberPermissionService;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+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 java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 能力键 {@code health.report.detail} 契约级单测(S-cfc-146,TDD-RED 阶段)。
+ *
+ * <p><b>契约溯源</b></p>
+ * <ul>
+ *   <li>需求:docs/tdd/SRS.md «三、未覆盖控制器» HealthReportController(59 方法,0 覆盖)</li>
+ *   <li>设计:{@link HealthReportController#getReportDetail}
+ *       {@code @PostMapping("/report/detail")}(前缀 /api/health)+ {@link HealthReportService#getReportDetail}</li>
+ *   <li>契约:docs/superpowers/api/API_REFERENCE.md §4.59.2(SC-C-06 / SC-X-02)——
+ *       {@code POST /api/health/report/detail} 请求 {@code {reportId}};
+ *       响应 {@code Result<Map>}:报告详情(含指标明细 + 菌群数据 + 疾病风险评估);
+ *       权限校验(本人/管理员/被授权成员),无权返回「无权查看该报告」</li>
+ *   <li>消费侧:cfc-frontend/pages/health/gut-flora-detail.vue loadData() 读取
+ *       {@code report / indicators / gutFlora / diseaseRisks / reportInterpretation / payload.summary}</li>
+ * </ul>
+ *
+ * <p><b>覆盖流</b>(纯 Mockito + 反射,无 Spring 上下文 / 无 DB / 无外部服务,
+ * 风格对齐同批 {@code TaskCompleteScC02ContractTest} 与 {@code HealthReportAuditContractTest}):</p>
+ * <ul>
+ *   <li><b>控制器正常流</b>:管理员可见全部报告;本人可见无归属报告(subjectId=null);被授权成员可见;报告自带
+ *       family_id 且 {@code familyId} 属性缺省时以报告家庭为权限作用域;</li>
+ *   <li><b>控制器异常流</b>:reportId 缺失/非数字 → 「reportId不能为空」;报告不存在 → 「报告不存在」;
+ *       无归属报告且非本人非管理员 → 「无权查看该报告」;有归属但无授权关系 → 「无权查看该报告」;
+ *       报告无家庭且 {@code familyId} 缺省 → 「无权查看该报告」;</li>
+ *   <li><b>Service 内容契约</b>:响应 Map 必须含 {@code report / indicators / gutFlora / diseaseRisks / blocks};
+ *       有肠道年龄/类型时生成 {@code reportInterpretation.gutAgeAssessment};
+ *       有 {@code payload_json} 时解析出嵌套 {@code payload}。</li>
+ * </ul>
+ */
+class HealthReportDetailContractTest {
+
+    private static final Long FAMILY_ID = 1L;
+    private static final Long VIEWER_USER_ID = 7L;
+    private static final Long SUBJECT_MEMBER_ID = 100L;
+
+    private HealthReportController controller;
+    private HealthReportService service;
+
+    /** controller 层转发的 mock(与 service 层真实实例隔离) */
+    private HealthReportService healthReportServiceMock;
+    private ReportMemberPermissionService permissionServiceMock;
+
+    // service 层真实实例的 mock 依赖
+    private HealthReportMapper healthReportMapper;
+    private HealthIndicatorMapper healthIndicatorMapper;
+    private HealthGutFloraMapper healthGutFloraMapper;
+    private HealthDiseaseRiskMapper healthDiseaseRiskMapper;
+    private ReportBlockService reportBlockService;
+    private com.fasterxml.jackson.databind.ObjectMapper objectMapper;
+
+    @BeforeAll
+    static void initMybatisPlusTableInfo() {
+        // 纯 Mockito 下 LambdaQueryWrapper 需要 TableInfo 缓存(与 HealthReportAuditContractTest 一致)
+        MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
+        TableInfoHelper.initTableInfo(assistant, HealthReport.class);
+        TableInfoHelper.initTableInfo(assistant, HealthIndicator.class);
+        TableInfoHelper.initTableInfo(assistant, HealthGutFlora.class);
+        TableInfoHelper.initTableInfo(assistant, HealthDiseaseRisk.class);
+    }
+
+    @BeforeEach
+    void setUp() throws Exception {
+        // ============ service 层:真实 HealthReportService + mock 依赖 ============
+        service = new HealthReportService();
+        injectAllDependencies(service);
+        healthReportMapper = (HealthReportMapper) readField(service, "healthReportMapper");
+        healthIndicatorMapper = (HealthIndicatorMapper) readField(service, "healthIndicatorMapper");
+        healthGutFloraMapper = (HealthGutFloraMapper) readField(service, "healthGutFloraMapper");
+        healthDiseaseRiskMapper = (HealthDiseaseRiskMapper) readField(service, "healthDiseaseRiskMapper");
+        reportBlockService = (ReportBlockService) readField(service, "reportBlockService");
+        objectMapper = (com.fasterxml.jackson.databind.ObjectMapper) readField(service, "objectMapper");
+
+        // ============ controller 层:真实 HealthReportController + 仅注入 getReportDetail 依赖 ============
+        // 注意:不用全字段反射注入——控制器含 @Value String 字段(uploadBaseDir/langgraphBaseUrl),
+        // Mockito 无法 mock final 类 String;只注入该 handler 触及的两个 @Resource 依赖即可。
+        controller = new HealthReportController();
+        healthReportServiceMock = mock(HealthReportService.class);
+        permissionServiceMock = mock(ReportMemberPermissionService.class);
+        inject(controller, "healthReportService", healthReportServiceMock);
+        inject(controller, "reportMemberPermissionService", permissionServiceMock);
+    }
+
+    // ==================== 控制器契约 · 正常流(API_REFERENCE §4.59.2) ====================
+
+    @Test
+    @DisplayName("正常流-1:管理员/被授权成员查看报告 → Result.success(完整详情 Map)")
+    void controller_正常流_管理员查看报告_返回完整详情() {
+        when(healthReportServiceMock.getReportById(1001L))
+                .thenReturn(report(1001L, SUBJECT_MEMBER_ID, 999L, FAMILY_ID));
+        when(permissionServiceMock.canViewMemberReports(VIEWER_USER_ID, SUBJECT_MEMBER_ID, FAMILY_ID))
+                .thenReturn(true);
+        Map<String, Object> detail = detailMap();
+        when(healthReportServiceMock.getReportDetail(1001L)).thenReturn(detail);
+
+        Result<Map<String, Object>> result = controller.getReportDetail(paramsOf(1001L), VIEWER_USER_ID, FAMILY_ID);
+
+        assertEquals(Integer.valueOf(200), result.getCode(), "契约:查看有权限的报告应返回 code=200");
+        assertEquals("success", result.getMessage(), "契约:成功响应 message=success");
+        assertNotNull(result.getData(), "契约:data 必须为报告详情 Map");
+        assertSame(detail, result.getData(), "契约:data 必须是 Service 组装的完整详情");
+        verify(healthReportServiceMock).getReportDetail(1001L);
+    }
+
+    @Test
+    @DisplayName("正常流-2:本人查看无归属报告(subjectId=null)→ 放行,不触发家庭管理员判定")
+    void controller_正常流_本人查看无归属报告_返回详情() {
+        when(healthReportServiceMock.getReportById(1002L))
+                .thenReturn(report(1002L, null, VIEWER_USER_ID, FAMILY_ID));
+        when(healthReportServiceMock.getReportDetail(1002L)).thenReturn(detailMap());
+
+        Result<Map<String, Object>> result = controller.getReportDetail(paramsOf(1002L), VIEWER_USER_ID, FAMILY_ID);
+
+        assertEquals(Integer.valueOf(200), result.getCode(), "契约:本人查看自己上传的无归属报告应放行");
+        verify(healthReportServiceMock).getReportDetail(1002L);
+        verify(permissionServiceMock, never()).isFamilyAdmin(anyLong(), anyLong());
+    }
+
+    @Test
+    @DisplayName("正常流-3:被授权成员查看 → canViewMemberReports=true 放行")
+    void controller_正常流_被授权成员查看_返回详情() {
+        when(healthReportServiceMock.getReportById(1003L))
+                .thenReturn(report(1003L, SUBJECT_MEMBER_ID, 999L, FAMILY_ID));
+        when(permissionServiceMock.canViewMemberReports(VIEWER_USER_ID, SUBJECT_MEMBER_ID, FAMILY_ID))
+                .thenReturn(true);
+        when(healthReportServiceMock.getReportDetail(1003L)).thenReturn(detailMap());
+
+        Result<Map<String, Object>> result = controller.getReportDetail(paramsOf(1003L), VIEWER_USER_ID, FAMILY_ID);
+
+        assertEquals(Integer.valueOf(200), result.getCode(), "契约:被授权成员应可见该报告详情");
+        verify(permissionServiceMock).canViewMemberReports(VIEWER_USER_ID, SUBJECT_MEMBER_ID, FAMILY_ID);
+    }
+
+    @Test
+    @DisplayName("正常流-4:报告自带 family_id、familyId 属性缺省 → 以报告家庭为权限作用域")
+    void controller_正常流_报告自带familyId_属性缺省_以报告家庭为作用域() {
+        Long reportFamily = 5L;
+        when(healthReportServiceMock.getReportById(1004L))
+                .thenReturn(report(1004L, SUBJECT_MEMBER_ID, 999L, reportFamily));
+        when(permissionServiceMock.canViewMemberReports(VIEWER_USER_ID, SUBJECT_MEMBER_ID, reportFamily))
+                .thenReturn(true);
+        when(healthReportServiceMock.getReportDetail(1004L)).thenReturn(detailMap());
+
+        // @RequestAttribute familyId = null(如跨家庭 token / 取不到上下文)→ 回退报告自身 family_id
+        Result<Map<String, Object>> result = controller.getReportDetail(paramsOf(1004L), VIEWER_USER_ID, null);
+
+        assertEquals(Integer.valueOf(200), result.getCode(), "契约:报告自带 family_id 时应以其为作用域放行");
+        verify(permissionServiceMock).canViewMemberReports(VIEWER_USER_ID, SUBJECT_MEMBER_ID, reportFamily);
+    }
+
+    // ==================== 控制器契约 · 异常流 ====================
+
+    @Test
+    @DisplayName("异常流-1:reportId 缺失 → 「reportId不能为空」,不触达 Service")
+    void controller_异常流_reportId缺失_返回reportId不能为空() {
+        Result<Map<String, Object>> result = controller.getReportDetail(new HashMap<String, Object>(), VIEWER_USER_ID, FAMILY_ID);
+
+        assertEquals("reportId不能为空", result.getMessage(), "契约:未传 reportId 必须提示,不得静默查询");
+        verify(healthReportServiceMock, never()).getReportById(anyLong());
+        verify(healthReportServiceMock, never()).getReportDetail(anyLong());
+    }
+
+    @Test
+    @DisplayName("异常流-2:reportId 非数字 → 视为未传,返回「reportId不能为空」")
+    void controller_异常流_reportId非数字_视为未传() {
+        Result<Map<String, Object>> result = controller.getReportDetail(paramsOf("abc"), VIEWER_USER_ID, FAMILY_ID);
+
+        assertEquals("reportId不能为空", result.getMessage(), "契约:非数字 reportId 应安全解析为未传");
+        verify(healthReportServiceMock, never()).getReportById(anyLong());
+    }
+
+    @Test
+    @DisplayName("异常流-3:报告不存在 → 「报告不存在」,不组装详情")
+    void controller_异常流_报告不存在_返回报告不存在() {
+        when(healthReportServiceMock.getReportById(9999L)).thenReturn(null);
+
+        Result<Map<String, Object>> result = controller.getReportDetail(paramsOf(9999L), VIEWER_USER_ID, FAMILY_ID);
+
+        assertEquals("报告不存在", result.getMessage(), "契约:报告不存在必须明确提示");
+        verify(healthReportServiceMock, never()).getReportDetail(anyLong());
+    }
+
+    @Test
+    @DisplayName("异常流-4:无归属报告且非本人非管理员 → 「无权查看该报告」")
+    void controller_异常流_无归属报告非本人非管理员_无权查看() {
+        when(healthReportServiceMock.getReportById(1005L))
+                .thenReturn(report(1005L, null, 99L, FAMILY_ID)); // 上传者 99 ≠ 查看者 7
+        when(permissionServiceMock.isFamilyAdmin(VIEWER_USER_ID, FAMILY_ID)).thenReturn(false);
+
+        Result<Map<String, Object>> result = controller.getReportDetail(paramsOf(1005L), VIEWER_USER_ID, FAMILY_ID);
+
+        assertEquals("无权查看该报告", result.getMessage(), "契约:无归属报告非本人非管理员必须拒绝");
+        verify(healthReportServiceMock, never()).getReportDetail(anyLong());
+    }
+
+    @Test
+    @DisplayName("异常流-5:有归属但无授权关系 → 「无权查看该报告」")
+    void controller_异常流_有归属无授权关系_无权查看() {
+        when(healthReportServiceMock.getReportById(1006L))
+                .thenReturn(report(1006L, SUBJECT_MEMBER_ID, 99L, FAMILY_ID));
+        when(permissionServiceMock.canViewMemberReports(VIEWER_USER_ID, SUBJECT_MEMBER_ID, FAMILY_ID))
+                .thenReturn(false);
+
+        Result<Map<String, Object>> result = controller.getReportDetail(paramsOf(1006L), VIEWER_USER_ID, FAMILY_ID);
+
+        assertEquals("无权查看该报告", result.getMessage(), "契约:无本人/管理员/被授权关系必须拒绝");
+        verify(healthReportServiceMock, never()).getReportDetail(anyLong());
+    }
+
+    @Test
+    @DisplayName("异常流-6:报告无家庭且 familyId 属性缺省 → 「无权查看该报告」(无作用域即拒绝)")
+    void controller_异常流_报告无家庭且属性缺省_无权查看() {
+        when(healthReportServiceMock.getReportById(1007L))
+                .thenReturn(report(1007L, SUBJECT_MEMBER_ID, VIEWER_USER_ID, null));
+
+        Result<Map<String, Object>> result = controller.getReportDetail(paramsOf(1007L), VIEWER_USER_ID, null);
+
+        assertEquals("无权查看该报告", result.getMessage(), "契约:无家庭作用域必须拒绝,不泄露数据");
+        verify(healthReportServiceMock, never()).getReportDetail(anyLong());
+    }
+
+    // ==================== Service 内容契约(响应 Map 组成) ====================
+
+    @Test
+    @DisplayName("内容契约-1:详情 Map 必须含 report/indicators/gutFlora/diseaseRisks/blocks")
+    void service_返回报告_指标明细_菌群数据_疾病风险_展示块() {
+        HealthReport report = report(1001L, SUBJECT_MEMBER_ID, VIEWER_USER_ID, FAMILY_ID);
+        when(healthReportMapper.selectById(1001L)).thenReturn(report);
+        when(healthIndicatorMapper.selectList(any())).thenReturn(Collections.singletonList(new HealthIndicator()));
+        when(healthGutFloraMapper.selectList(any())).thenReturn(Collections.singletonList(new HealthGutFlora()));
+        when(healthDiseaseRiskMapper.selectList(any())).thenReturn(Collections.singletonList(new HealthDiseaseRisk()));
+        when(reportBlockService.getBlocks(report.getReportType(), 1001L)).thenReturn(new ArrayList<>());
+
+        Map<String, Object> detail = service.getReportDetail(1001L);
+
+        assertNotNull(detail.get("report"), "契约:详情必须包含 report 实体");
+        assertNotNull(detail.get("indicators"), "契约:详情必须包含指标明细 indicators");
+        assertEquals(1, ((List<?>) detail.get("indicators")).size(), "指标明细应原样透传");
+        assertNotNull(detail.get("gutFlora"), "契约:详情必须包含菌群数据 gutFlora");
+        assertEquals(1, ((List<?>) detail.get("gutFlora")).size(), "菌群数据应原样透传");
+        assertNotNull(detail.get("diseaseRisks"), "契约:详情必须包含疾病风险评估 diseaseRisks");
+        assertEquals(1, ((List<?>) detail.get("diseaseRisks")).size(), "疾病风险应原样透传");
+        assertNotNull(detail.get("blocks"), "契约:详情必须包含通用展示块 blocks");
+    }
+
+    @Test
+    @DisplayName("内容契约-2:有肠道年龄/类型 → 生成 reportInterpretation.gutAgeAssessment")
+    void service_有肠道年龄_生成报告解读() {
+        HealthReport report = report(1001L, SUBJECT_MEMBER_ID, VIEWER_USER_ID, FAMILY_ID);
+        report.setGutAge("28");
+        report.setGutType("平衡型");
+        when(healthReportMapper.selectById(1001L)).thenReturn(report);
+
+        Map<String, Object> detail = service.getReportDetail(1001L);
+
+        @SuppressWarnings("unchecked")
+        Map<String, Object> interp = (Map<String, Object>) detail.get("reportInterpretation");
+        assertNotNull(interp, "契约:有肠道年龄/类型时必须生成 reportInterpretation");
+        String text = (String) interp.get("gutAgeAssessment");
+        assertNotNull(text, "契约:reportInterpretation.gutAgeAssessment 不得为空");
+        assertTrue(text.contains("28"), "解读文本应包含肠道年龄, 实际: " + text);
+    }
+
+    @Test
+    @DisplayName("内容契约-3:有 payload_json → 解析出嵌套 payload(前端取 payload.summary 补全 gender/age)")
+    void service_有payloadJson_解析为payload() throws Exception {
+        HealthReport report = report(1001L, SUBJECT_MEMBER_ID, VIEWER_USER_ID, FAMILY_ID);
+        report.setPayloadJson("{\"summary\":{\"personName\":\"小李\"}}");
+        when(healthReportMapper.selectById(1001L)).thenReturn(report);
+
+        ParsedReportPayload.Payload payload = new ParsedReportPayload.Payload();
+        ParsedReportPayload.Summary summary = new ParsedReportPayload.Summary();
+        summary.setPersonName("小李");
+        payload.setSummary(summary);
+        when(objectMapper.readValue(anyString(), eq(ParsedReportPayload.Payload.class))).thenReturn(payload);
+
+        Map<String, Object> detail = service.getReportDetail(1001L);
+
+        assertSame(payload, detail.get("payload"), "契约:payload_json 必须解析为嵌套 payload 结构");
+    }
+
+    // ==================== 辅助构造 ====================
+
+    private HealthReport report(Long id, Long subjectId, Long uploaderUserId, Long familyId) {
+        HealthReport r = new HealthReport();
+        r.setId(id);
+        r.setSubjectId(subjectId);
+        r.setUserId(uploaderUserId);
+        r.setFamilyId(familyId);
+        r.setReportType("gut_flora");
+        return r;
+    }
+
+    private Map<String, Object> paramsOf(Object reportId) {
+        Map<String, Object> p = new HashMap<>();
+        p.put("reportId", reportId);
+        return p;
+    }
+
+    private Map<String, Object> detailMap() {
+        Map<String, Object> d = new HashMap<>();
+        d.put("report", new HealthReport());
+        d.put("indicators", new ArrayList<>());
+        d.put("gutFlora", new ArrayList<>());
+        d.put("diseaseRisks", new ArrayList<>());
+        return d;
+    }
+
+    private Object readField(Object target, String name) throws Exception {
+        Field f = target.getClass().getDeclaredField(name);
+        f.setAccessible(true);
+        return f.get(target);
+    }
+
+    private static void inject(Object target, String field, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(field);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    private static void injectAllDependencies(Object target) throws Exception {
+        for (Field f : target.getClass().getDeclaredFields()) {
+            if (Modifier.isStatic(f.getModifiers())) {
+                continue; // 跳过 log 等 static final
+            }
+            f.setAccessible(true);
+            f.set(target, mock(f.getType()));
+        }
+    }
+}