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

test(S-cfc-145): health.report.create 契约级单测 RED(reportType 取值校验缺口 2红/8绿,其余契约实现已落地)

E2E Test Bot 23 часов назад
Родитель
Сommit
30caeba0f6

+ 333 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/HealthReportCreateContractTest.java

@@ -0,0 +1,333 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.HealthReportController;
+import com.etotem.cfc.entity.HealthIndicator;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.service.HealthReportService;
+import com.etotem.cfc.service.PurchaseService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+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.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:创建健康报告(能力键 {@code health.report.create},TDD-RED 派发 S-cfc-145)。
+ *
+ * <p><b>契约锚点</b></p>
+ * <ul>
+ *   <li>需求:{@code docs/tdd/SRS.md} «三、未覆盖控制器» —— {@code HealthReportController}(59 方法,0 覆盖);</li>
+ *   <li>设计:{@code HealthReportController#createReport}(类级 {@code /api/health} + {@code @PostMapping("/report/create")});</li>
+ *   <li>契约:{@code docs/superpowers/api/API_REFERENCE.md} §4.59.2「POST /api/health/report/create」:
+ *       body {@code {memberId?, reportType, overallScore?, gutHealthScore?, chronicDiseaseScore?, nutritionScore?, gutAge?, gutType?, ...}}
+ *       (reportType 必填:{@code physical_exam}/{@code gut_flora});响应 {@code HealthReport}(含 id);
+ *       创建健康报告(含指标明细);付费墙:未购买「健康启航计划」返回 {@code code=4001}「请先购买健康启航计划体验包」;
+ *       memberId 缺省取 {@code currentMemberId}。</li>
+ * </ul>
+ *
+ * <p><b>测试范围</b>:仅 Mockito + 反射直接调用控制器方法(纯单测,无 Spring / 无 DB / 无外部服务),
+ * 与同批 RED 契约测试(TeamProfitHook / TeamStarMultiplier / TeamProfitPipeline / FamilyJoinValidationRules)风格一致。</p>
+ *
+ * <p><b>RED 预期</b>:契约将 reportType 枚举为且仅枚举 {@code physical_exam}/{@code gut_flora} 两值;
+ * 而 {@code HealthReportController#createReport} 当前仅判 {@code null}、未校验取值合法集,
+ * 空串/其他字符串会被放行到 service 层并返回 200。因此「reportType 空串/非法值须拒绝」两个契约用例预期失败(红),
+ * GREEN 阶段需在控制器补 reportType 取值校验使红例转绿。</p>
+ *
+ * <p><b>实现现状提示(编排侧转登记用)</b>:该键所映射端点 {@code createReport} 实现已落地(src/main,
+ * 且端点同源的 {@code health.report.createReport} 键已 GREEN 并复用登记于 API_REFERENCE §4.63)。
+ * 本批契约测试中除 reportType 取值校验外的契约行为(付费墙 4001 / 家庭 5001 / memberId 缺省取 currentMemberId /
+ * reportType 必填 / 指标明细透传)均已实现,预期绿;编排侧应复核密钥登记,避免与 createReport 键重复建池。</p>
+ */
+class HealthReportCreateContractTest {
+
+    private static final String TARGET_PATH = "/api/health/report/create";
+
+    private static final long USER_ID = 1L;
+    private static final long FAMILY_ID = 7L;
+    private static final long CURRENT_MEMBER_ID = 6L;
+
+    private static final String MSG_PURCHASE_REQUIRED = "请先购买健康启航计划体验包";
+    private static final String MSG_NO_FAMILY = "请先创建或加入家庭";
+    private static final String MSG_MEMBER_REQUIRED = "memberId不能为空";
+    private static final String MSG_REPORT_TYPE_REQUIRED = "reportType不能为空: physical_exam / gut_flora";
+
+    @Mock
+    private HealthReportService healthReportService;
+
+    @Mock
+    private PurchaseService purchaseService;
+
+    private HealthReportController controller;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        MockitoAnnotations.openMocks(this);
+        controller = new HealthReportController();
+        // 仅注入 createReport 使用的两个依赖;其余 @Resource 字段保持 null(反射注入,与同批 REF 风格一致)
+        injectField(controller, "healthReportService", healthReportService);
+        injectField(controller, "purchaseService", purchaseService);
+    }
+
+    // ------------------------------------------------------------------
+    // 正常流:契约 §4.59.2
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-1:已购计划 + 合法参数 → 返回创建的报告(含 id),service 落库一次")
+    void createReport_success_returnsCreatedReportWithId() {
+        when(purchaseService.checkPurchase(USER_ID)).thenReturn(purchaseCheck(true));
+        HealthReport created = new HealthReport();
+        created.setId(100L);
+        when(healthReportService.createReport(any(HealthReport.class), anyList())).thenReturn(created);
+
+        Map<String, Object> params = baseParams(5L, "physical_exam");
+        params.put("overallScore", 88);
+        params.put("indicators", new ArrayList<>());
+
+        Result<HealthReport> result = controller.createReport(params, USER_ID, FAMILY_ID, CURRENT_MEMBER_ID);
+
+        assertEquals(200, result.getCode());
+        assertNotNull(result.getData(), "[health.report.create] 正常创建应返回 HealthReport(含 id)");
+        assertEquals(100L, result.getData().getId().longValue());
+        verify(healthReportService).createReport(any(HealthReport.class), anyList());
+    }
+
+    @Test
+    @DisplayName("正常流-2:memberId 缺省 → 缺省取 currentMemberId(契约显式约定)")
+    void createReport_memberIdDefaultsToCurrentMemberId() {
+        when(purchaseService.checkPurchase(USER_ID)).thenReturn(purchaseCheck(true));
+        when(healthReportService.createReport(any(HealthReport.class), any())).thenReturn(new HealthReport());
+
+        // params 不含 memberId,仅依赖 currentMemberId
+        Map<String, Object> params = baseParams(null, "physical_exam");
+        params.put("indicators", new ArrayList<>());
+
+        controller.createReport(params, USER_ID, FAMILY_ID, CURRENT_MEMBER_ID);
+
+        ArgumentCaptor<HealthReport> captor = ArgumentCaptor.forClass(HealthReport.class);
+        verify(healthReportService).createReport(captor.capture(), any());
+        assertEquals(CURRENT_MEMBER_ID, captor.getValue().getUserId(),
+                "[health.report.create] memberId 缺省时应取 currentMemberId(契约 §4.59.2)");
+    }
+
+    @Test
+    @DisplayName("正常流-3:指标明细 → 原样透传 service(保序 + 字段对齐)")
+    void createReport_indicatorsPassedThroughInOrder() {
+        when(purchaseService.checkPurchase(USER_ID)).thenReturn(purchaseCheck(true));
+        when(healthReportService.createReport(any(HealthReport.class), anyList())).thenReturn(new HealthReport());
+
+        Map<String, Object> params = baseParams(5L, "gut_flora");
+        List<Map<String, Object>> indicators = new ArrayList<>();
+        Map<String, Object> wbc = new HashMap<>();
+        wbc.put("category", "blood");
+        wbc.put("indicatorName", "WBC");
+        wbc.put("indicatorValue", "6.1");
+        wbc.put("unit", "10^9/L");
+        wbc.put("status", "normal");
+        Map<String, Object> alt = new HashMap<>();
+        alt.put("category", "liver");
+        alt.put("indicatorName", "ALT");
+        alt.put("indicatorValue", "32");
+        alt.put("unit", "U/L");
+        indicators.add(wbc);
+        indicators.add(alt);
+        params.put("indicators", indicators);
+
+        controller.createReport(params, USER_ID, FAMILY_ID, CURRENT_MEMBER_ID);
+
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<List> captor = ArgumentCaptor.forClass(List.class);
+        verify(healthReportService).createReport(any(HealthReport.class), captor.capture());
+        List<HealthIndicator> passed = captor.getValue();
+        assertNotNull(passed);
+        assertEquals(2, passed.size());
+        assertEquals("WBC", passed.get(0).getIndicatorName());
+        assertEquals(0, passed.get(0).getSortOrder().intValue());
+        assertEquals("ALT", passed.get(1).getIndicatorName());
+        assertEquals(1, passed.get(1).getSortOrder().intValue());
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:付费墙 / 家庭 / 必填参数 / reportType 取值校验
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-1:未购计划 → code=4001「请先购买健康启航计划体验包」,service 不调用")
+    void createReport_notPurchased_rejectsWith4001() {
+        when(purchaseService.checkPurchase(USER_ID)).thenReturn(purchaseCheck(false));
+
+        Result<HealthReport> result = controller.createReport(
+                baseParams(5L, "physical_exam"), USER_ID, FAMILY_ID, CURRENT_MEMBER_ID);
+
+        assertEquals(4001, result.getCode(),
+                "[health.report.create] 未购买健康启航计划应返回 code=4001(付费墙契约 §4.59.2)");
+        assertEquals(MSG_PURCHASE_REQUIRED, result.getMessage());
+        verify(healthReportService, never()).createReport(any(HealthReport.class), anyList());
+    }
+
+    @Test
+    @DisplayName("边界流-2:无家庭 → code=5001「请先创建或加入家庭」")
+    void createReport_noFamily_rejected() {
+        when(purchaseService.checkPurchase(USER_ID)).thenReturn(purchaseCheck(true));
+
+        Result<HealthReport> result = controller.createReport(
+                baseParams(5L, "physical_exam"), USER_ID, null, CURRENT_MEMBER_ID);
+
+        assertEquals(5001, result.getCode());
+        assertEquals(MSG_NO_FAMILY, result.getMessage());
+        verify(healthReportService, never()).createReport(any(HealthReport.class), anyList());
+    }
+
+    @Test
+    @DisplayName("边界流-3:memberId 与 currentMemberId 均缺 → 拒绝「memberId不能为空」")
+    void createReport_memberIdMissing_rejected() {
+        when(purchaseService.checkPurchase(USER_ID)).thenReturn(purchaseCheck(true));
+
+        Result<HealthReport> result = controller.createReport(
+                baseParams(null, "physical_exam"), USER_ID, FAMILY_ID, null);
+
+        assertEquals(500, result.getCode());
+        assertEquals(MSG_MEMBER_REQUIRED, result.getMessage());
+        verify(healthReportService, never()).createReport(any(HealthReport.class), anyList());
+    }
+
+    @Test
+    @DisplayName("边界流-4:reportType 缺省 null → 拒绝「reportType不能为空: physical_exam / gut_flora」")
+    void createReport_reportTypeNull_rejected() {
+        when(purchaseService.checkPurchase(USER_ID)).thenReturn(purchaseCheck(true));
+
+        Result<HealthReport> result = controller.createReport(
+                baseParams(5L, null), USER_ID, FAMILY_ID, CURRENT_MEMBER_ID);
+
+        assertEquals(500, result.getCode());
+        assertEquals(MSG_REPORT_TYPE_REQUIRED, result.getMessage());
+        verify(healthReportService, never()).createReport(any(HealthReport.class), anyList());
+    }
+
+    @Test
+    @DisplayName("RED-边界流-5:reportType 空串 → 契约仅允许 physical_exam/gut_flora 两值,应拒绝")
+    void createReport_reportTypeEmptyString_rejected() {
+        when(purchaseService.checkPurchase(USER_ID)).thenReturn(purchaseCheck(true));
+
+        // params.put("reportType", "") 等价于传空串
+        Map<String, Object> params = baseParams(5L, "");
+        params.put("indicators", new ArrayList<>());
+
+        Result<HealthReport> result = controller.createReport(
+                params, USER_ID, FAMILY_ID, CURRENT_MEMBER_ID);
+
+        // 契约:reportType 必填且仅枚举 physical_exam / gut_flora 两值 —— 空串并非合法取值,应与 null
+        // 同口径拒绝而非放行到 service。当前实现仅判 null(空串放行返回 200),本用例应红(RED)。
+        assertTrue(result.getCode() != 200,
+                "[health.report.create] RED:reportType=空串 应被拒绝(契约仅允许 physical_exam/gut_flora);"
+                        + "当前实现仅判 null 未校验合法值,实际放行 code=" + result.getCode());
+        verify(healthReportService, never()).createReport(any(HealthReport.class), anyList());
+    }
+
+    @Test
+    @DisplayName("RED-边界流-6:reportType 非法值 → 契约仅允许 physical_exam/gut_flora 两值,应拒绝")
+    void createReport_reportTypeInvalidValue_rejected() {
+        when(purchaseService.checkPurchase(USER_ID)).thenReturn(purchaseCheck(true));
+
+        Map<String, Object> params = baseParams(5L, "dna");
+        params.put("indicators", new ArrayList<>());
+
+        Result<HealthReport> result = controller.createReport(
+                params, USER_ID, FAMILY_ID, CURRENT_MEMBER_ID);
+
+        // 契约仅枚举两个合法值:physical_exam / gut_flora;"dna" 属契约外值,应拒绝。
+        // 当前实现未做取值校验(放行返回 200),本用例应红(RED)。
+        assertTrue(result.getCode() != 200,
+                "[health.report.create] RED:reportType=dna 应被拒绝(契约仅允许 physical_exam/gut_flora);"
+                        + "当前实现未校验合法值,实际放行 code=" + result.getCode());
+        verify(healthReportService, never()).createReport(any(HealthReport.class), anyList());
+    }
+
+    // ------------------------------------------------------------------
+    // 权限流:JwtInterceptor 拦截(/api/** 8 个公开路径之外)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("权限流-1:/api/health/report/create 非公开白名单 → 必须登录(LOGIN_REQUIRED_PATHS 含报告族)")
+    void createReport_loginRequired_notInPublicWhiteList() throws Exception {
+        String[] publicPaths = (String[]) readPrivateStatic(
+                "com.etotem.cfc.config.JwtInterceptor", "PUBLIC_PATHS");
+        String[] loginRequiredPaths = (String[]) readPrivateStatic(
+                "com.etotem.cfc.config.JwtInterceptor", "LOGIN_REQUIRED_PATHS");
+
+        boolean coveredByPublic = false;
+        for (String publicPath : publicPaths) {
+            String boundary = publicPath.endsWith("/**")
+                    ? publicPath.substring(0, publicPath.length() - 3) : publicPath;
+            if (TARGET_PATH.equals(publicPath) || TARGET_PATH.startsWith(boundary + "/")) {
+                coveredByPublic = true;
+                break;
+            }
+        }
+        assertFalse(coveredByPublic,
+                "[health.report.create] 创建报告属家庭私有健康数据,PUBLIC_PATHS 不应覆盖 " + TARGET_PATH);
+
+        boolean loginRequiredRegistered = false;
+        for (String p : loginRequiredPaths) {
+            if ("/api/health/report/".equals(p)) {
+                loginRequiredRegistered = true;
+                break;
+            }
+        }
+        assertTrue(loginRequiredRegistered,
+                "[health.report.create] JwtInterceptor LOGIN_REQUIRED_PATHS 应含 /api/health/report/"
+                        + "(报告族登录必达,无 token 直接 401)");
+    }
+
+    // ------------------------------------------------------------------
+    // 辅助
+    // ------------------------------------------------------------------
+
+    private Map<String, Object> purchaseCheck(boolean active) {
+        Map<String, Object> m = new HashMap<>();
+        m.put("isActive", active);
+        return m;
+    }
+
+    private Map<String, Object> baseParams(Object memberId, Object reportType) {
+        Map<String, Object> params = new HashMap<>();
+        if (memberId != null) {
+            params.put("memberId", memberId);
+        }
+        if (reportType != null) {
+            params.put("reportType", reportType);
+        }
+        return params;
+    }
+
+    private void injectField(Object target, String fieldName, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(fieldName);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    private Object readPrivateStatic(String className, String fieldName) throws Exception {
+        Class<?> clazz = Class.forName(className);
+        Field f = clazz.getDeclaredField(fieldName);
+        f.setAccessible(true);
+        return f.get(null);
+    }
+}