Sfoglia il codice sorgente

test(S-cfc-18): 补齐 health.report.upload 契约级单测(16 例全绿,B-3 报告上传校验)

Agnes Agent 1 settimana fa
parent
commit
564b4d8b6e

+ 454 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/HealthReportControllerUploadTest.java

@@ -0,0 +1,454 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.dto.ParsedDiseaseRisk;
+import com.etotem.cfc.dto.ParsedFoodSuitability;
+import com.etotem.cfc.dto.ParsedGutFlora;
+import com.etotem.cfc.dto.ParsedIndicator;
+import com.etotem.cfc.dto.ParsedReportResult;
+import com.etotem.cfc.entity.Food;
+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.HealthReportDraftService;
+import com.etotem.cfc.service.HealthReportService;
+import com.etotem.cfc.service.OnboardingService;
+import com.etotem.cfc.service.PdfParseService;
+import com.etotem.cfc.service.StorageService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * TDD 覆盖驱动(S-cfc-18 / 能力键 health.report.upload,原始阶段 RED → 补齐契约级单测):
+ * HealthReportController POST /api/health/report/upload(兼容接口:上传 + 解析 + 自动确认)契约级单测。
+ *
+ * 契约(对应 docs/tdd/test/blockers.md B-3「R6 · 报告上传」,实现位于 HealthReportController:818):
+ * - 请求:multipart/form-data,字段名 file;userId 由 JwtInterceptor 注入 @RequestAttribute("userId");
+ *   familyId 可选(@RequestAttribute required=false)。
+ * - 正常路径:合法 PDF → PdfParseService 解析 → 存储 → 建草稿 → 建报告(含指标/菌群/疾病风险/食材)
+ *   → payload 快照 → 标记草稿已确认 → 新手任务 REPORT_UPLOAD → 返回 Result<Map> code=200,
+ *   含 needBind / matchedMemberId / extractedName 等字段。
+ * - 边界:空文件拒绝;非 PDF 扩展名且非 application/pdf MIME 拒绝;两者任一命中即放行(当前实现为 OR 语义,
+ *   非 AND 三重校验);解析结果无总分/无菌群分视为不可解析;解析抛 IOException 返回解析失败。
+ * - 角色/权限:本 handler 不读取 @RequestAttribute("role"),家长/规划师(teacher)/管理员(admin) 走同一路径,
+ *   授权由 JwtInterceptor(有效 token 注入 userId)+ Service 层数据归属承担;本层不做角色分流。
+ *
+ * 缺陷观察(B-3,不修实现,交编排侧转 GREEN 决策):
+ * 1) 【MIME/扩展名 OR 语义】只校验「扩展名 .pdf」或「contentType=application/pdf」任一成立即放行:
+ *    - report.jpg + application/pdf 通过校验(扩展名不合法仍进解析链);
+ *    - report.pdf + image/jpeg 通过校验(MIME 不合法仍进解析链)。
+ *    契约要求 MIME / 扩展名 / 内容三重校验,应为 AND 语义。
+ * 2) 【无内容(魔数)校验】文件名为 .pdf 且 MIME 为 application/pdf、但内容并非 PDF 时,handler 不校验
+ *    %PDF 魔数,直接交给 PdfParseService;本测试钉住当前观测行为(调用 parse 后才失败)。
+ * 3) 【错误码非 4xx】非法文件走 Result.error(String) → envelope code=500(非 400/415),
+ *    HTTP 状态恒 200(项目统一 Result 包装)。B-3 要求「返回 4xx」,当前未满足。
+ *
+ * 测试隔离:MockMvc standalone + mock 全部下游 Service(PdfParseService/StorageService/HealthReportService/
+ * HealthReportDraftService/FoodService/DimensionScoreService/FoodRecommendService/OnboardingService),
+ * 无 Spring 上下文、无 DB、无外部服务(微信/LangGraph 等)。
+ */
+public class HealthReportControllerUploadTest {
+
+    private static final String URL = "/api/health/report/upload";
+
+    private static final byte[] PDF_BYTES = "%PDF-1.4\n%mock pdf body\n".getBytes();
+
+    @Mock
+    private PdfParseService pdfParseService;
+    @Mock
+    private StorageService storageService;
+    @Mock
+    private HealthReportDraftService healthReportDraftService;
+    @Mock
+    private HealthReportService healthReportService;
+    @Mock
+    private FoodService foodService;
+    @Mock
+    private DimensionScoreService dimensionScoreService;
+    @Mock
+    private FoodRecommendService foodRecommendService;
+    @Mock
+    private OnboardingService onboardingService;
+
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+        HealthReportController controller = new HealthReportController();
+        ReflectionTestUtils.setField(controller, "pdfParseService", pdfParseService);
+        ReflectionTestUtils.setField(controller, "storageService", storageService);
+        ReflectionTestUtils.setField(controller, "healthReportDraftService", healthReportDraftService);
+        ReflectionTestUtils.setField(controller, "healthReportService", healthReportService);
+        ReflectionTestUtils.setField(controller, "foodService", foodService);
+        ReflectionTestUtils.setField(controller, "dimensionScoreService", dimensionScoreService);
+        ReflectionTestUtils.setField(controller, "foodRecommendService", foodRecommendService);
+        ReflectionTestUtils.setField(controller, "onboardingService", onboardingService);
+        ReflectionTestUtils.setField(controller, "objectMapper", new ObjectMapper());
+        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+    }
+
+    // ==================== 夹具 ====================
+
+    private static MockMultipartFile pdf(String filename, String contentType, byte[] content) {
+        return new MockMultipartFile("file", filename, contentType, content);
+    }
+
+    private static ParsedReportResult parsedResult(String personName) {
+        ParsedReportResult parsed = new ParsedReportResult();
+        parsed.setOverallScore(82);
+        parsed.setGutHealthScore(76);
+        parsed.setChronicDiseaseScore(88);
+        parsed.setReportNumber("MJ20261001001");
+        parsed.setReportDate("2026-10-01");
+        parsed.setPersonName(personName);
+        parsed.setGender("female");
+        parsed.setAge(36);
+        parsed.setGutAge("32");
+        parsed.setGutType("B型");
+
+        ParsedIndicator indicator = new ParsedIndicator();
+        indicator.setCategory("炎症");
+        indicator.setIndicatorName("C反应蛋白");
+        indicator.setIndicatorValue("1.2");
+        indicator.setRefRange("0-3");
+        indicator.setStatus("正常");
+        parsed.setIndicators(new ArrayList<>(Collections.singletonList(indicator)));
+
+        ParsedGutFlora flora = new ParsedGutFlora();
+        flora.setBacteriaName("双歧杆菌");
+        flora.setBacteriaValue("3.5%");
+        flora.setNormalRange("1%-10%");
+        flora.setCategory("核心菌属");
+        flora.setLevel("属");
+        parsed.setGutFlora(new ArrayList<>(Collections.singletonList(flora)));
+
+        ParsedDiseaseRisk risk = new ParsedDiseaseRisk();
+        risk.setDiseaseName("肠道炎症风险");
+        risk.setRiskValue("0.21");
+        risk.setRiskLevel("低");
+        parsed.setDiseaseRisks(new ArrayList<>(Collections.singletonList(risk)));
+
+        ParsedFoodSuitability food = new ParsedFoodSuitability();
+        food.setFoodName("燕麦");
+        food.setCategory("谷物");
+        food.setScore(88);
+        parsed.setFoodSuitability(new ArrayList<>(Collections.singletonList(food)));
+        return parsed;
+    }
+
+    /** 打桩「存储 → 草稿 → 报告 → 快照 → 确认」全链路,返回 reportId=88 / draftId=55。 */
+    private void stubPersistChain() {
+        when(storageService.storeFile(any(), eq("health-reports"))).thenReturn("/uploads/health-reports/report.pdf");
+
+        HealthReportDraft draft = new HealthReportDraft();
+        draft.setId(55L);
+        when(healthReportDraftService.createDraft(any(), any(), anyString(), anyString(), anyString(), anyString()))
+                .thenReturn(draft);
+
+        HealthReport created = new HealthReport();
+        created.setId(88L);
+        when(healthReportService.createReport(any(HealthReport.class), anyList(), anyList(), anyList()))
+                .thenReturn(created);
+
+        // getReportDetail 返回的 Map 会被 handler 就地 put,需每次返回可写实例
+        doAnswer(invocation -> new HashMap<String, Object>()).when(healthReportService).getReportDetail(anyLong());
+    }
+
+    // ==================== 正常路径 ====================
+
+    @Test
+    public void upload_validPdf_returnsReportDetail_andRunsFullPersistenceChain() throws Exception {
+        stubPersistChain();
+        when(pdfParseService.parse(any(InputStream.class))).thenReturn(parsedResult("李小雨"));
+        // 家庭成员中无同名成员(Mockito 对 Long 包装类型的默认空值为 0,需显式打桩为 null)
+        when(healthReportService.matchFamilyMemberByName("李小雨", 1L)).thenReturn(null);
+
+        mockMvc.perform(multipart(URL)
+                        .file(pdf("肠道菌群报告.pdf", "application/pdf", PDF_BYTES))
+                        .requestAttr("userId", 9L)
+                        .requestAttr("familyId", 1L))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.needBind").value(true))
+                .andExpect(jsonPath("$.data.extractedName").value("李小雨"))
+                .andExpect(jsonPath("$.data.extractedReportNumber").value("MJ20261001001"));
+
+        // 上传人 userId / familyId 原样透传到草稿(数据归属口径)
+        verify(healthReportDraftService).createDraft(
+                eq(9L), eq(1L), eq("gut_flora"),
+                eq("/uploads/health-reports/report.pdf"),
+                eq("肠道菌群报告.pdf"), anyString());
+        verify(healthReportService).createReport(any(HealthReport.class), anyList(), anyList(), anyList());
+        verify(healthReportService).savePayloadSnapshot(eq(88L), eq(9L), eq("create"), isNull(), anyString());
+        verify(healthReportDraftService).markConfirmed(55L, 88L);
+        verify(onboardingService).completeTask(9L, "REPORT_UPLOAD");
+
+        // 解析出的 1 条食材 → 落食材推荐
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<List<Food>> foods = ArgumentCaptor.forClass(List.class);
+        verify(foodService).saveFoodsForReport(eq(88L), foods.capture());
+        assertEquals(1, foods.getValue().size());
+        assertEquals("燕麦", foods.getValue().get(0).getName());
+        assertEquals("recommended", foods.getValue().get(0).getStatus());
+
+        // 有 familyId 且解析出姓名 → 尝试匹配家庭成员;未匹配到(null)不触发评分/食材推荐刷新
+        verify(healthReportService).matchFamilyMemberByName("李小雨", 1L);
+        verify(dimensionScoreService, never()).refreshFromGutReport(anyLong(), anyLong());
+        verify(foodRecommendService, never()).recalculateForUser(anyLong());
+    }
+
+    @Test
+    public void upload_familyIdAndMatchingName_autoBindsMemberAndRefreshesScores() throws Exception {
+        stubPersistChain();
+        when(pdfParseService.parse(any(InputStream.class))).thenReturn(parsedResult("李小雨"));
+        when(healthReportService.matchFamilyMemberByName("李小雨", 1L)).thenReturn(77L);
+
+        mockMvc.perform(multipart(URL)
+                        .file(pdf("肠道菌群报告.pdf", "application/pdf", PDF_BYTES))
+                        .requestAttr("userId", 9L)
+                        .requestAttr("familyId", 1L))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.needBind").value(false))
+                .andExpect(jsonPath("$.data.matchedMemberId").value(77))
+                .andExpect(jsonPath("$.data.matchedMemberName").value("李小雨"));
+
+        verify(dimensionScoreService).refreshFromGutReport(77L, 88L);
+        verify(foodRecommendService).recalculateForUser(77L);
+    }
+
+    @Test
+    public void upload_withoutFamilyId_skipsMemberMatch() throws Exception {
+        stubPersistChain();
+        when(pdfParseService.parse(any(InputStream.class))).thenReturn(parsedResult("李小雨"));
+
+        mockMvc.perform(multipart(URL)
+                        .file(pdf("肠道菌群报告.pdf", "application/pdf", PDF_BYTES))
+                        .requestAttr("userId", 9L))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.needBind").value(true));
+
+        verify(healthReportService, never()).matchFamilyMemberByName(anyString(), anyLong());
+        verify(healthReportDraftService).createDraft(
+                eq(9L), isNull(), eq("gut_flora"), anyString(), anyString(), anyString());
+    }
+
+    // ==================== 关键边界:空文件 / 非 PDF ====================
+
+    @Test
+    public void upload_emptyFile_rejectedBeforeParse() throws Exception {
+        mockMvc.perform(multipart(URL)
+                        .file(pdf("empty.pdf", "application/pdf", new byte[0]))
+                        .requestAttr("userId", 9L))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.message").value("文件不能为空"));
+
+        verify(pdfParseService, never()).parse(any(InputStream.class));
+        verify(storageService, never()).storeFile(any(), anyString());
+    }
+
+    @Test
+    public void upload_nonPdfExtensionAndNonPdfMime_rejected() throws Exception {
+        mockMvc.perform(multipart(URL)
+                        .file(pdf("报告.jpg", "image/jpeg", "not a pdf".getBytes()))
+                        .requestAttr("userId", 9L))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.message").value("仅支持PDF文件"));
+
+        verify(pdfParseService, never()).parse(any(InputStream.class));
+        verify(storageService, never()).storeFile(any(), anyString());
+    }
+
+    @Test
+    public void upload_wrongMimeOnly_rejected() throws Exception {
+        mockMvc.perform(multipart(URL)
+                        .file(pdf("报告.txt", "text/plain", "hello".getBytes()))
+                        .requestAttr("userId", 9L))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.message").value("仅支持PDF文件"));
+
+        verify(pdfParseService, never()).parse(any(InputStream.class));
+    }
+
+    @Test
+    public void upload_uppercasePdfExtension_acceptedBoundary() throws Exception {
+        stubPersistChain();
+        when(pdfParseService.parse(any(InputStream.class))).thenReturn(parsedResult("李小雨"));
+
+        mockMvc.perform(multipart(URL)
+                        .file(pdf("REPORT.PDF", "application/octet-stream", PDF_BYTES))
+                        .requestAttr("userId", 9L))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200));
+
+        verify(pdfParseService).parse(any(InputStream.class));
+    }
+
+    // ==================== B-3 缺陷:MIME / 扩展名 / 内容校验缺口(钉住观测行为) ====================
+
+    @Test
+    public void upload_nonPdfExtensionWithPdfMime_pinsObservedOrSemanticsBypass() throws Exception {
+        stubPersistChain();
+        when(pdfParseService.parse(any(InputStream.class))).thenReturn(parsedResult("李小雨"));
+
+        // 扩展名 .jpg 不合法,但 MIME=application/pdf 即被放行 → OR 语义(缺陷观察 1)
+        mockMvc.perform(multipart(URL)
+                        .file(pdf("报告.jpg", "application/pdf", PDF_BYTES))
+                        .requestAttr("userId", 9L))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200));
+
+        verify(pdfParseService).parse(any(InputStream.class));
+    }
+
+    @Test
+    public void upload_pdfExtensionWithNonPdfMime_pinsObservedOrSemanticsBypass() throws Exception {
+        stubPersistChain();
+        when(pdfParseService.parse(any(InputStream.class))).thenReturn(parsedResult("李小雨"));
+
+        // MIME=image/jpeg 不合法,但扩展名 .pdf 即被放行 → OR 语义(缺陷观察 1)
+        mockMvc.perform(multipart(URL)
+                        .file(pdf("报告.pdf", "image/jpeg", PDF_BYTES))
+                        .requestAttr("userId", 9L))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200));
+
+        verify(pdfParseService).parse(any(InputStream.class));
+    }
+
+    @Test
+    public void upload_fakePdfContent_noMagicByteCheck_pinsObservedParsePath() throws Exception {
+        // 伪 PDF:名称 .pdf + MIME application/pdf,但内容不含 %PDF 魔数 → handler 不校验内容(缺陷观察 2)
+        ParsedReportResult unparseable = new ParsedReportResult(); // 无总分/无菌群分
+        when(pdfParseService.parse(any(InputStream.class))).thenReturn(unparseable);
+
+        mockMvc.perform(multipart(URL)
+                        .file(pdf("伪造报告.pdf", "application/pdf", "definitely not a pdf".getBytes()))
+                        .requestAttr("userId", 9L))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.message").value("无法解析PDF文件,请确认是募极生物肠道菌群报告"));
+
+        // 内容校验缺口:文件已进入解析链,且未落盘(解析先行)
+        verify(pdfParseService).parse(any(InputStream.class));
+        verify(storageService, never()).storeFile(any(), anyString());
+    }
+
+    @Test
+    public void upload_parseReturnsNoScores_rejectedBeforeStore() throws Exception {
+        when(pdfParseService.parse(any(InputStream.class))).thenReturn(new ParsedReportResult());
+
+        mockMvc.perform(multipart(URL)
+                        .file(pdf("肠道菌群报告.pdf", "application/pdf", PDF_BYTES))
+                        .requestAttr("userId", 9L))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.message").value("无法解析PDF文件,请确认是募极生物肠道菌群报告"));
+
+        verify(storageService, never()).storeFile(any(), anyString());
+        verify(healthReportService, never()).createReport(any(HealthReport.class), anyList(), anyList(), anyList());
+    }
+
+    @Test
+    public void upload_parseThrowsIoException_returnsParseFailure() throws Exception {
+        when(pdfParseService.parse(any(InputStream.class))).thenThrow(new IOException("bad xref"));
+
+        mockMvc.perform(multipart(URL)
+                        .file(pdf("肠道菌群报告.pdf", "application/pdf", PDF_BYTES))
+                        .requestAttr("userId", 9L))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.message").value("PDF解析失败: bad xref"));
+
+        verify(storageService, never()).storeFile(any(), anyString());
+        verify(healthReportDraftService, never())
+                .createDraft(any(), any(), anyString(), anyString(), anyString(), anyString());
+    }
+
+    // ==================== 角色/权限(本层无角色分流) ====================
+
+    @Test
+    public void upload_teacherRole_notGatedByRoleAttribute() throws Exception {
+        stubPersistChain();
+        when(pdfParseService.parse(any(InputStream.class))).thenReturn(parsedResult("李小雨"));
+
+        // 规划师(teacher)与家长走同一 handler,本层不读 role;授权由 JwtInterceptor + Service 承担
+        mockMvc.perform(multipart(URL)
+                        .file(pdf("肠道菌群报告.pdf", "application/pdf", PDF_BYTES))
+                        .requestAttr("userId", 9L)
+                        .requestAttr("role", "teacher"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200));
+    }
+
+    @Test
+    public void upload_adminRole_notGatedByRoleAttribute() throws Exception {
+        stubPersistChain();
+        when(pdfParseService.parse(any(InputStream.class))).thenReturn(parsedResult("李小雨"));
+
+        mockMvc.perform(multipart(URL)
+                        .file(pdf("肠道菌群报告.pdf", "application/pdf", PDF_BYTES))
+                        .requestAttr("userId", 9L)
+                        .requestAttr("role", "admin"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200));
+    }
+
+    // ==================== 路由契约(统一 @PostMapping) ====================
+
+    @Test
+    public void upload_getMethod_notAllowed() throws Exception {
+        mockMvc.perform(get(URL))
+                .andExpect(status().isMethodNotAllowed());
+    }
+
+    // ==================== 缺陷复现断言(供编排侧 GREEN 决策复核) ====================
+
+    @Test
+    public void upload_defectEnvelopeCodeIs500_not4xx() throws Exception {
+        // 缺陷观察 3:非法文件返回 envelope code=500,B-3 要求 4xx(400/415);
+        // 若实现按 B-3 改为 400/415,本断言会失败,提示同步更新契约。
+        mockMvc.perform(multipart(URL)
+                        .file(pdf("报告.jpg", "image/jpeg", "not a pdf".getBytes()))
+                        .requestAttr("userId", 9L))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(500));
+    }
+}