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