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