Преглед изворни кода

merge task/S-cfc-267 (report.upload.parse RED 契约测试) into cfclub

zxyj-langgraph пре 1 дан
родитељ
комит
1f18cb65a1

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