소스 검색

Merge remote-tracking branch 'origin/cfclub' into task/S-cfc-311

Sisyphus 1 일 전
부모
커밋
e6d8be0ccc

+ 183 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/EnergyFiveDimViewContractTest.java

@@ -0,0 +1,183 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.entity.EnergyBalance;
+import com.etotem.cfc.entity.EnergyDimension;
+import com.etotem.cfc.mapper.EnergyBalanceMapper;
+import com.etotem.cfc.mapper.EnergyDimensionMapper;
+import com.etotem.cfc.service.EnergyService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+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.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * SC-P-17 energy.fiveDim.view 契约测试(五维能量百分比分配与校验)。
+ *
+ * <p><b>契约锚点(溯源)</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-P-17(查看五维能量/健康指数:
+ *       身/心/智/行/富分配总和=100%,比例配置错误≠100% 报警,无数据空态不 500);</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.5 能量与积分
+ *       {@code /api/energy/overview}(能量概览)、{@code /api/energy/dimension}(五维详情);</li>
+ *   <li>模块:{@code EnergyController} / {@code EnergyService}。</li>
+ * </ul>
+ *
+ * <p><b>契约行为(RED 目标)</b></p>
+ * <ol>
+ *   <li><b>分配总和=100%</b>:能量概览返回五维「百分比分配」,各维 {@code allocationPercent} 之和必须精确为 100;
+ *       当前 {@code EnergyService#getOverview} 仅返回绝对余额与 {@code totalEnergy},未计算百分比 → 缺失(红)。</li>
+ *   <li><b>比例配置错误≠100% 报警</b>:当维度配置权重之和 ≠ 100% 时,概览必须携带告警标记
+ *       (如 {@code allocationNormalized=false} + {@code allocationWarning} 文案),禁止静默错算;
+ *       当前实现无任何配置校验/告警 → 缺失(红)。</li>
+ *   <li><b>空数据不 500</b>:无维度配置 / 无能量记录时返回稳定空态结构(空 dimensions、totalEnergy=0、
+ *       空 allocation),不得抛异常;当前对空维度已返回空列表,但契约要求新增的 {@code allocation} 空态字段缺失 → 红。</li>
+ * </ol>
+ *
+ * <p><b>测试风格</b>:纯 Mockito / 反射注入 / 无 Spring / 无 DB / 无外部服务,与
+ * {@code EnergyOverviewContractTest}(energy.viewOverview)同批 RED 风格一致。</p>
+ */
+class EnergyFiveDimViewContractTest extends EnergyContractTestBase {
+
+    private EnergyService service;
+    private EnergyDimensionMapper dimensionMapper;
+    private EnergyBalanceMapper balanceMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new EnergyService();
+        dimensionMapper = mock(EnergyDimensionMapper.class);
+        balanceMapper = mock(EnergyBalanceMapper.class);
+        inject(service, "energyDimensionMapper", dimensionMapper);
+        inject(service, "energyBalanceMapper", balanceMapper);
+        // 其余依赖在本类不触达,但为防 NPE 一并注入空桩
+        inject(service, "energySourceConfigMapper", mock(com.etotem.cfc.mapper.EnergySourceConfigMapper.class));
+        inject(service, "energyBehaviorConfigMapper", mock(com.etotem.cfc.mapper.EnergyBehaviorConfigMapper.class));
+        inject(service, "energyLogMapper", mock(com.etotem.cfc.mapper.EnergyLogMapper.class));
+        inject(service, "wishMapper", mock(com.etotem.cfc.mapper.WishMapper.class));
+        inject(service, "familyMemberMapper", mock(com.etotem.cfc.mapper.FamilyMemberMapper.class));
+        inject(service, "notificationDispatchService", mock(com.etotem.cfc.service.NotificationDispatchService.class));
+
+        initTable(EnergyBalance.class);
+        initTable(EnergyDimension.class);
+    }
+
+    private void seedDims(EnergyDimension... dims) {
+        when(dimensionMapper.selectList(any())).thenReturn(Arrays.asList(dims));
+    }
+
+    /** balance 查询:固定返回指定 balance(null=无记录),简化断言 */
+    private void seedBalance(Integer balance) {
+        if (balance == null) {
+            when(balanceMapper.selectOne(any())).thenReturn(null);
+        } else {
+            when(balanceMapper.selectOne(any())).thenAnswer(inv -> {
+                EnergyBalance b = new EnergyBalance();
+                b.setBalance(balance);
+                b.setTotalEarned(balance);
+                b.setTotalSpent(0);
+                return b;
+            });
+        }
+    }
+
+    // ==================== 契约1:分配总和 = 100% ====================
+
+    @Test
+    @DisplayName("SC-P-17 契约:五维百分比分配之和必须精确为 100")
+    void 契约_五维百分比分配_之和等于100() {
+        seedDims(fiveDims());
+        seedBalance(30); // 五维各 30 → 绝对总和 150,但契约要求展示为 100% 分配
+
+        Map<String, Object> data = service.getOverview(50L);
+
+        assertNotNull(data.get("allocation"), "契约缺失:概览应返回五维百分比分配 allocation");
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> allocation =
+                (List<Map<String, Object>>) data.get("allocation");
+        assertEquals(5, allocation.size(), "契约缺失:allocation 应含 5 个维度");
+        double sum = 0;
+        for (Map<String, Object> a : allocation) {
+            assertTrue(a.containsKey("code"), "allocation 项应含维度 code");
+            assertTrue(a.containsKey("percent"), "allocation 项应含 percent");
+            sum += ((Number) a.get("percent")).doubleValue();
+        }
+        assertEquals(100, sum, 0.0001, "契约失败:五维分配百分比之和必须精确为 100%");
+    }
+
+    // ==================== 契约2:比例配置错误 ≠ 100% 报警 ====================
+
+    @Test
+    @DisplayName("SC-P-17 契约:配置权重和≠100% 时必须携带告警标记,禁止静默错算")
+    void 契约_比例配置错误_触发告警() {
+        seedDims(fiveDims());
+        seedBalance(30);
+
+        Map<String, Object> data = service.getOverview(50L);
+
+        assertTrue(data.containsKey("allocationNormalized"),
+                "契约缺失:配置权重和≠100% 时概览应返回 allocationNormalized 标记");
+        // 配置权重和 ≠ 100% → normalized = false(或携带告警文案)
+        assertTrue(data.containsKey("allocationWarning"),
+                "契约缺失:配置权重和≠100% 时概览应返回 allocationWarning 告警文案");
+    }
+
+    @Test
+    @DisplayName("SC-P-17 契约:配置权重和=100% 时无告警")
+    void 契约_配置合法_无告警() {
+        seedDims(fiveDims());
+        seedBalance(30);
+
+        Map<String, Object> data = service.getOverview(50L);
+
+        assertTrue(data.containsKey("allocationNormalized"));
+        assertTrue(data.containsKey("allocationWarning"));
+    }
+
+    // ==================== 契约3:空数据不 500,稳定空态 ====================
+
+    @Test
+    @DisplayName("SC-P-17 契约:无维度配置 → 空 allocation + 空 dimensions + totalEnergy 0,不抛异常")
+    void 契约_无维度配置_稳定空态() {
+        when(dimensionMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        Map<String, Object> data = service.getOverview(50L);
+
+        assertEquals(0, data.get("totalEnergy"));
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> dims = (List<Map<String, Object>>) data.get("dimensions");
+        assertEquals(0, dims.size(), "应返回空维度列表");
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> allocation =
+                (List<Map<String, Object>>) data.get("allocation");
+        assertNotNull(allocation, "契约缺失:空态也应返回 allocation 字段(空列表),避免前端 500");
+        assertEquals(0, allocation.size(), "契约缺失:空态 allocation 应为空列表");
+    }
+
+    @Test
+    @DisplayName("SC-P-17 契约:有维度但无能量记录 → 各维 percent=0,总和为 0 或 100(空态语义),不抛异常")
+    void 契约_无能量记录_不崩溃() {
+        seedDims(fiveDims());
+        seedBalance(null);
+
+        Map<String, Object> data = service.getOverview(50L);
+
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> allocation =
+                (List<Map<String, Object>>) data.get("allocation");
+        assertNotNull(allocation, "契约缺失:无能量时也应返回 allocation(空态),避免除零/500");
+        for (Map<String, Object> a : allocation) {
+            assertTrue(a.containsKey("percent"), "allocation 项应含 percent");
+        }
+    }
+}

+ 314 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ReportUploadParseContractTest.java

@@ -0,0 +1,314 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.controller.HealthReportController;
+import com.etotem.cfc.dto.ParsedReportPayload;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.entity.HealthReportDraft;
+import com.etotem.cfc.service.DimensionScoreService;
+import com.etotem.cfc.service.FoodRecommendService;
+import com.etotem.cfc.service.FoodService;
+import com.etotem.cfc.service.HealthPlanService;
+import com.etotem.cfc.service.HealthReportDraftService;
+import com.etotem.cfc.service.HealthReportService;
+import com.etotem.cfc.service.NormalizeIndicatorPipeline;
+import com.etotem.cfc.service.ReportBlockAssembler;
+import com.etotem.cfc.service.ReportBlockService;
+import com.etotem.cfc.service.ReportCollectService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+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.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:报告上传/解析/确认全链路(能力键 {@code report.upload.parse},TDD-RED 派发 S-cfc-267)。
+ *
+ * <p><b>契约锚点</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-P-20 / SC-P-21 / SC-P-22 / SC-P-23
+ *       与 {@code tests/test-cases/parent.md} TC-P-20~23(含 -E 异常变体);
+ *       {@code docs/tdd/orchestration/sc-key-map.md} L134-137 将 SC-P-20~23 统一映射到 {@code report.upload.parse}(RED 未跑通)。</li>
+ *   <li>设计:{@code HealthReportController} 两阶段入库四端点
+ *       {@code uploadOnly}(POST /api/health/report/upload-only)→
+ *       {@code parseByDraftId}(POST /api/health/report/parse)→
+ *       {@code confirmDraft}(POST /api/health/report/confirm)→
+ *       {@code discardDraft}(POST /api/health/report/discard);
+ *       异步采集链路 {@code ReportCollectService#collect}。</li>
+ *   <li>契约:{@code docs/superpowers/api/API_REFERENCE.md} §4.10 / §4.13 / §4.30 / §4.59.1 / §4.59.2。</li>
+ * </ul>
+ *
+ * <p><b>测试范围</b>:仅 Mockito + 反射直接调用控制器 / 服务方法(纯单测,无 Spring / 无 DB / 无外部服务),
+ * 与同批 RED 契约测试(TeamProfitHook / FamilyJoinValidationRules / HealthReportCreate /
+ * AdminReportParserManage)风格一致。</p>
+ *
+ * <p><b>实现现状提示(编排侧转登记用)</b>:能力键 {@code report.upload.parse} 所映射的四端点
+ * (upload-only / parse / confirm / discard)与两阶段入库主流程<b>已落地</b>(src/main),
+ * {@code GREEN-baseline} 用例断言端点存在,预期绿;编排侧应据此复核密钥登记。
+ * 本批契约测试的 <b>RED 三处缺口</b>聚焦在端点已存在、但契约行为缺失的分支:</p>
+ * <ol>
+ *   <li><b>RED-1(SC-P-23 并发确认幂等缺失)</b>:{@code confirmDraft} 从未调用
+ *       {@code HealthReportDraftService#tryClaimInsertSlot}(该原子抢占仅用于异步
+ *       {@code ReportCollectService#collect} L269);两阶段确认并发双触发会重复入库 + 重复刷新评分。
+ *       契约要求:同一 pending 草稿的并发/重复确认只应正式入库一次。</li>
+ *   <li><b>RED-2(SC-P-21 未知类型进未知聚类缺失)</b>:异步采集链路
+ *       {@code ReportCollectService} 未注入 {@code ReportUnknownClusterMapper} / {@code ReportUnknownUploadMapper},
+ *       解析失败/未知格式的报告只被 {@code markCollectFailed} 记为失败,未按契约进入「未知聚类」人工标注池。
+ *       ({@code recordUnknown} 能力仅存在于 {@code ReportParserDispatchService},且该服务在采集链路上是死代码。)</li>
+ *   <li><b>RED-3(SC-P-22 异常值不入库缺失)</b>:{@code ReportCollectService#harmReportPayloadValid}
+ *       仅校验 summary/indicators/gutFlora 是否为空,未拦截负数/越界等异常值(如菌群多样性 {@code diversityScore=-1}),
+ *       契约要求异常值报告拒绝入库并提示「数据异常,暂不入库」(TC-P-22-E)。</li>
+ * </ol>
+ *
+ * <p>RED 预期:RED-1 / RED-2 / RED-3 三个用例在实现补齐前应失败(红);
+ * {@code GREEN-baseline}(端点存在)与 {@code control}(空载荷校验)预期绿,用于证明测试探针本身接通。</p>
+ */
+class ReportUploadParseContractTest {
+
+    private static final long USER_ID = 1L;
+    private static final long DRAFT_ID = 500L;
+    private static final long CREATED_REPORT_ID = 900L;
+
+    @Mock
+    private HealthReportService healthReportService;
+
+    @Mock
+    private HealthReportDraftService healthReportDraftService;
+
+    @Mock
+    private NormalizeIndicatorPipeline normalizeIndicatorPipeline;
+
+    @Mock
+    private ReportBlockService reportBlockService;
+
+    @Mock
+    private ReportBlockAssembler reportBlockAssembler;
+
+    @Mock
+    private HealthPlanService healthPlanService;
+
+    @Mock
+    private DimensionScoreService dimensionScoreService;
+
+    @Mock
+    private FoodRecommendService foodRecommendService;
+
+    @Mock
+    private FoodService foodService;
+
+    private HealthReportController controller;
+
+    /** 真实 ObjectMapper:confirmDraft 需在 payload 对象 ↔ JSON 间往返 */
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    @BeforeEach
+    void setUp() throws Exception {
+        MockitoAnnotations.openMocks(this);
+        controller = new HealthReportController();
+        // 仅注入 confirmDraft 主链路使用的依赖;其余 @Resource 字段保持 null(反射注入,与同批 REF 风格一致)
+        injectField(controller, "healthReportService", healthReportService);
+        injectField(controller, "healthReportDraftService", healthReportDraftService);
+        injectField(controller, "objectMapper", objectMapper);
+        injectField(controller, "normalizeIndicatorPipeline", normalizeIndicatorPipeline);
+        injectField(controller, "reportBlockService", reportBlockService);
+        injectField(controller, "reportBlockAssembler", reportBlockAssembler);
+        injectField(controller, "healthPlanService", healthPlanService);
+        injectField(controller, "dimensionScoreService", dimensionScoreService);
+        injectField(controller, "foodRecommendService", foodRecommendService);
+        injectField(controller, "foodService", foodService);
+    }
+
+    // ------------------------------------------------------------------
+    // RED-1:SC-P-23 并发确认幂等(confirmDraft 缺 tryClaimInsertSlot 原子抢占)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("RED-1 (SC-P-23):同一 pending 草稿并发/重复确认 → 正式入库仅应发生一次(createReport 恰好 1 次)")
+    void confirmDraft_concurrentDoubleConfirm_insertsOnlyOnce() throws Exception {
+        HealthReportDraft draft = pendingGutFloraDraft(DRAFT_ID, USER_ID);
+        when(healthReportDraftService.getActiveDraft(DRAFT_ID)).thenReturn(draft);
+        when(healthReportService.resolveSubjectToMemberId(any(), any())).thenReturn(null);
+        HealthReport created = new HealthReport();
+        created.setId(CREATED_REPORT_ID);
+        when(healthReportService.createReport(any(), any(), any(), any())).thenReturn(created);
+        when(healthReportService.getReportDetail(any())).thenReturn(new HashMap<String, Object>());
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("draftId", DRAFT_ID); // 无 subjectId / 无 payload → 读 draft.payloadJson
+
+        // 模拟并发双确认:两次 confirmDraft 在 markConfirmed 生效前都能读到「活跃草稿」
+        controller.confirmDraft("pdf", params, USER_ID);
+        controller.confirmDraft("pdf", params, USER_ID);
+
+        // 契约:tryClaimInsertSlot 原子抢占应使重复确认只入库一次。
+        // 当前实现 confirmDraft 从未调用 tryClaimInsertSlot,两次确认都会 createReport(重复入库),本用例应红(RED)。
+        verify(healthReportService, times(1))
+                .createReport(any(HealthReport.class), any(), any(), any());
+        verify(healthReportDraftService, times(2))
+                .getActiveDraft(DRAFT_ID); // 确认两次确认都真实进入了入口(非被首例短路)
+    }
+
+    // ------------------------------------------------------------------
+    // RED-2:SC-P-21 未知类型进未知聚类(ReportCollectService 缺未知聚类 Mapper 注入)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("RED-2 (SC-P-21):异步采集链路应注入未知聚类 Mapper,使解析失败/未知格式报告进入未知聚类")
+    void collectService_wiresUnknownClusterMappers() throws Exception {
+        Class<?> collectService = Class.forName("com.etotem.cfc.service.ReportCollectService");
+        Class<?> clusterMapper = Class.forName("com.etotem.cfc.mapper.ReportUnknownClusterMapper");
+        Class<?> uploadMapper = Class.forName("com.etotem.cfc.mapper.ReportUnknownUploadMapper");
+
+        Field unknownCluster = findDeclaredField(collectService, "unknownClusterMapper");
+        Field unknownUpload = findDeclaredField(collectService, "unknownUploadMapper");
+
+        // 契约:解析失败 / 未知格式的报告应进入「未知聚类」人工标注池,
+        // 需要 ReportCollectService 注入 ReportUnknownClusterMapper + ReportUnknownUploadMapper。
+        // 当前实现采集链路只 markCollectFailed、未注入这两个 Mapper(recordUnknown 仅存在于
+        // 采集链路上的死代码 ReportParserDispatchService),故两处断言均应失败(RED)。
+        assertNotNull(unknownCluster,
+                "[report.upload.parse] RED:ReportCollectService 应注入 ReportUnknownClusterMapper(未知类型报告进未知聚类,SC-P-21)");
+        assertEquals(clusterMapper, unknownCluster.getType(),
+                "[report.upload.parse] RED:unknownClusterMapper 字段类型应为 ReportUnknownClusterMapper");
+        assertNotNull(unknownUpload,
+                "[report.upload.parse] RED:ReportCollectService 应注入 ReportUnknownUploadMapper(未知上传记录,SC-P-21)");
+        assertEquals(uploadMapper, unknownUpload.getType(),
+                "[report.upload.parse] RED:unknownUploadMapper 字段类型应为 ReportUnknownUploadMapper");
+    }
+
+    // ------------------------------------------------------------------
+    // RED-3:SC-P-22 异常值不入库(harmReportPayloadValid 缺负数/越界拦截)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("RED-3 (SC-P-22):菌群多样性 diversityScore=-1 属异常值 → 应拒绝「数据异常,暂不入库」")
+    void harmReportPayloadValid_rejectsNegativeDiversityScore() throws Exception {
+        ReportCollectService service = new ReportCollectService();
+        ParsedReportPayload.Payload payload = new ParsedReportPayload.Payload();
+        ParsedReportPayload.Summary summary = new ParsedReportPayload.Summary();
+        summary.setDiversityScore(-1); // 负数越界:契约要求拦截
+        payload.setSummary(summary);
+
+        // 契约(TC-P-22-E):异常值报告应拒绝入库并提示「数据异常,暂不入库」。
+        // 当前实现 harmReportPayloadValid 仅判 summary/indicators/gutFlora 是否为空,
+        // 有 summary 即放行(不校验取值合法性),故本用例应红(RED)。
+        assertThrows(IllegalArgumentException.class,
+                () -> callHarmReportPayloadValid(service, payload),
+                "[report.upload.parse] RED:diversityScore=-1 应被拒(数据异常,暂不入库);"
+                        + "当前实现仅判空未校验异常值,实际放行未抛异常");
+    }
+
+    @Test
+    @DisplayName("control (TC-P-22 反向对照):空载荷(无 summary/indicators/gutFlora)→ 现有校验应抛异常(证明探针接通)")
+    void harmReportPayloadValid_emptyPayload_throws_control() throws Exception {
+        ReportCollectService service = new ReportCollectService();
+        ParsedReportPayload.Payload emptyPayload = new ParsedReportPayload.Payload(); // 全空
+
+        // 反向对照:现有校验对「完全空载荷」确实会抛异常 → 证明反射探针已正确接通私有方法。
+        // 该用例预期绿,仅用于区分 RED-3 是「取值校验缺失」而非「探针未接通」。
+        assertThrows(IllegalArgumentException.class,
+                () -> callHarmReportPayloadValid(service, emptyPayload),
+                "control:空载荷应触发现有校验抛异常(证明探针接通)");
+    }
+
+    // ------------------------------------------------------------------
+    // GREEN-baseline:两阶段入库四端点存在(编排侧复核 report.upload.parse 密钥登记用)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("GREEN-baseline:upload-only / parse / confirm / discard 四端点已落地(端点本身,预期绿)")
+    void controller_exposesUploadParseConfirmDiscardEndpoints() throws Exception {
+        Class<?> clazz = HealthReportController.class;
+
+        assertNotNull(findDeclaredMethod(clazz, "uploadOnly",
+                        MultipartFile.class, String.class, Long.class, Long.class, Long.class),
+                "[report.upload.parse] 端点 upload-only(POST /api/health/report/upload-only)应已落地");
+        assertNotNull(findDeclaredMethod(clazz, "parseByDraftId",
+                        Map.class, Long.class, Long.class),
+                "[report.upload.parse] 端点 parse(POST /api/health/report/parse)应已落地");
+        assertNotNull(findDeclaredMethod(clazz, "confirmDraft",
+                        String.class, Map.class, Long.class),
+                "[report.upload.parse] 端点 confirm(POST /api/health/report/confirm)应已落地");
+        assertNotNull(findDeclaredMethod(clazz, "discardDraft",
+                        String.class, Map.class, Long.class),
+                "[report.upload.parse] 端点 discard(POST /api/health/report/discard)应已落地");
+    }
+
+    // ------------------------------------------------------------------
+    // 辅助
+    // ------------------------------------------------------------------
+
+    /** 构造一个「活跃待确认」的肠道菌群草稿(payloadJson 为合法最小 JSON)。 */
+    private HealthReportDraft pendingGutFloraDraft(Long draftId, Long userId) throws Exception {
+        HealthReportDraft draft = new HealthReportDraft();
+        draft.setId(draftId);
+        draft.setUserId(userId);
+        draft.setFamilyId(null); // 走最少下游分支
+        draft.setReportType("gut_flora");
+        draft.setFileUrl("/tmp/mock-report.pdf");
+
+        ParsedReportPayload.Payload payload = new ParsedReportPayload.Payload();
+        ParsedReportPayload.Summary summary = new ParsedReportPayload.Summary();
+        summary.setOverallScore(88);
+        payload.setSummary(summary);
+        draft.setPayloadJson(objectMapper.writeValueAsString(payload));
+        return draft;
+    }
+
+    /** 反射调用 ReportCollectService#harmReportPayloadValid(私有),并还原被 InvocationTargetException 包裹的 cause。 */
+    private void callHarmReportPayloadValid(ReportCollectService service,
+                                            ParsedReportPayload.Payload payload) throws Exception {
+        Method m = ReportCollectService.class.getDeclaredMethod(
+                "harmReportPayloadValid", ParsedReportPayload.Payload.class);
+        m.setAccessible(true);
+        try {
+            m.invoke(service, payload);
+        } catch (InvocationTargetException e) {
+            Throwable cause = e.getCause();
+            if (cause instanceof RuntimeException) {
+                throw (RuntimeException) cause;
+            }
+            if (cause instanceof Error) {
+                throw (Error) cause;
+            }
+            throw new RuntimeException(cause);
+        }
+    }
+
+    private Field findDeclaredField(Class<?> clazz, String name) {
+        try {
+            return clazz.getDeclaredField(name);
+        } catch (NoSuchFieldException e) {
+            return null;
+        }
+    }
+
+    private Method findDeclaredMethod(Class<?> clazz, String name, Class<?>... paramTypes) {
+        try {
+            return clazz.getDeclaredMethod(name, paramTypes);
+        } catch (NoSuchMethodException e) {
+            return null;
+        }
+    }
+
+    private void injectField(Object target, String fieldName, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(fieldName);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+}