|
|
@@ -0,0 +1,333 @@
|
|
|
+package com.etotem.cfc.unit;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
|
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.dto.DanReportEditDTO;
|
|
|
+import com.etotem.cfc.dto.DanReportUploadVO;
|
|
|
+import com.etotem.cfc.entity.DanAssessmentResult;
|
|
|
+import com.etotem.cfc.entity.DanReportUpload;
|
|
|
+import com.etotem.cfc.entity.FamilyMember;
|
|
|
+import com.etotem.cfc.entity.UserIntent;
|
|
|
+import com.etotem.cfc.mapper.DanAssessmentResultMapper;
|
|
|
+import com.etotem.cfc.mapper.DanReportUploadMapper;
|
|
|
+import com.etotem.cfc.mapper.FamilyMemberMapper;
|
|
|
+import com.etotem.cfc.mapper.UserIntentMapper;
|
|
|
+import com.etotem.cfc.service.DanReportParseService;
|
|
|
+import com.etotem.cfc.service.DanReportUploadService;
|
|
|
+import com.etotem.cfc.service.DimensionScoreService;
|
|
|
+import com.etotem.cfc.service.FileStorageService;
|
|
|
+import com.etotem.cfc.service.HealthPlanService;
|
|
|
+import com.etotem.cfc.service.NotificationDispatchService;
|
|
|
+import com.etotem.cfc.service.ReportBlockAssembler;
|
|
|
+import com.etotem.cfc.service.ReportBlockService;
|
|
|
+import com.etotem.cfc.service.ReportSummaryService;
|
|
|
+import com.etotem.cfc.service.StorageService;
|
|
|
+import org.apache.ibatis.builder.MapperBuilderAssistant;
|
|
|
+import org.junit.jupiter.api.BeforeAll;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.springframework.mock.web.MockMultipartFile;
|
|
|
+import org.springframework.web.multipart.MultipartFile;
|
|
|
+
|
|
|
+import java.lang.reflect.Field;
|
|
|
+import java.lang.reflect.Modifier;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.HashSet;
|
|
|
+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.assertNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.ArgumentMatchers.anyString;
|
|
|
+import static org.mockito.ArgumentMatchers.eq;
|
|
|
+import static org.mockito.ArgumentMatchers.nullable;
|
|
|
+import static org.mockito.Mockito.mock;
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+
|
|
|
+/**
|
|
|
+ * SC-T-11 / SC-T-12 / SC-C-07 / SC-P-39 — DAN 报告上传/确认/查询/对比契约测试
|
|
|
+ *
|
|
|
+ * <p>纯 Mockito(无 Spring / 无 DB)。覆盖三类流:</p>
|
|
|
+ * <ul>
|
|
|
+ * <li><b>正常流</b>:上传并解析 PDF(校验维度白名单、落草稿、同步解析、返回预览);按成员/家庭查询列表;
|
|
|
+ * 两版上传对比(数值 delta + 新增/删除标记);确认入库(含五维能量刷新与方案生成降级);</li>
|
|
|
+ * <li><b>异常流</b>:空文件 → 「请选择文件」;非法 dimension → 参数错误;文件保存失败 → 错误;解析失败 → 错误码;
|
|
|
+ * compareUploads 跨成员/缺参 → 拒绝;</li>
|
|
|
+ * <li><b>权限流</b>:普通成员 listByFamily 按 visibleMemberIds 过滤(管理员传 null 不过滤)。</li>
|
|
|
+ * </ul>
|
|
|
+ */
|
|
|
+class DanReportUploadContractTest {
|
|
|
+
|
|
|
+ private DanReportUploadService service;
|
|
|
+
|
|
|
+ private DanReportUploadMapper uploadMapper;
|
|
|
+ private DanAssessmentResultMapper resultMapper;
|
|
|
+ private FamilyMemberMapper familyMemberMapper;
|
|
|
+ private UserIntentMapper userIntentMapper;
|
|
|
+ private ReportSummaryService reportSummaryService;
|
|
|
+ private DanReportParseService danReportParseService;
|
|
|
+ private FileStorageService fileStorageService;
|
|
|
+ private StorageService storageService;
|
|
|
+ private DimensionScoreService dimensionScoreService;
|
|
|
+ private ReportBlockService reportBlockService;
|
|
|
+ private ReportBlockAssembler reportBlockAssembler;
|
|
|
+ private NotificationDispatchService notificationDispatchService;
|
|
|
+ private HealthPlanService healthPlanService;
|
|
|
+
|
|
|
+ @BeforeAll
|
|
|
+ static void initMybatisPlusTableInfo() {
|
|
|
+ MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
|
|
|
+ TableInfoHelper.initTableInfo(assistant, DanReportUpload.class);
|
|
|
+ TableInfoHelper.initTableInfo(assistant, DanAssessmentResult.class);
|
|
|
+ TableInfoHelper.initTableInfo(assistant, FamilyMember.class);
|
|
|
+ TableInfoHelper.initTableInfo(assistant, UserIntent.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() throws Exception {
|
|
|
+ service = new DanReportUploadService();
|
|
|
+ injectAll(service);
|
|
|
+ uploadMapper = (DanReportUploadMapper) readField(service, "uploadMapper");
|
|
|
+ resultMapper = (DanAssessmentResultMapper) readField(service, "resultMapper");
|
|
|
+ familyMemberMapper = (FamilyMemberMapper) readField(service, "familyMemberMapper");
|
|
|
+ userIntentMapper = (UserIntentMapper) readField(service, "userIntentMapper");
|
|
|
+ reportSummaryService = (ReportSummaryService) readField(service, "reportSummaryService");
|
|
|
+ danReportParseService = (DanReportParseService) readField(service, "danReportParseService");
|
|
|
+ fileStorageService = (FileStorageService) readField(service, "fileStorageService");
|
|
|
+ storageService = (StorageService) readField(service, "storageService");
|
|
|
+ dimensionScoreService = (DimensionScoreService) readField(service, "dimensionScoreService");
|
|
|
+ reportBlockService = (ReportBlockService) readField(service, "reportBlockService");
|
|
|
+ reportBlockAssembler = (ReportBlockAssembler) readField(service, "reportBlockAssembler");
|
|
|
+ notificationDispatchService = (NotificationDispatchService) readField(service, "notificationDispatchService");
|
|
|
+ healthPlanService = (HealthPlanService) readField(service, "healthPlanService");
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Object readField(Object target, String name) throws Exception {
|
|
|
+ Field f = target.getClass().getDeclaredField(name);
|
|
|
+ f.setAccessible(true);
|
|
|
+ return f.get(target);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static void injectAll(Object target) throws Exception {
|
|
|
+ for (Field f : target.getClass().getDeclaredFields()) {
|
|
|
+ if (Modifier.isStatic(f.getModifiers()) || Modifier.isFinal(f.getModifiers())) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ f.setAccessible(true);
|
|
|
+ f.set(target, mock(f.getType()));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private DanReportUpload upload(Long id, Long familyId, Long memberId, String dimension, String draftStatus) {
|
|
|
+ DanReportUpload u = new DanReportUpload();
|
|
|
+ u.setId(id);
|
|
|
+ u.setFamilyId(familyId);
|
|
|
+ u.setFamilyMemberId(memberId);
|
|
|
+ u.setUploaderUserId(77L);
|
|
|
+ u.setDimension(dimension);
|
|
|
+ u.setDraftStatus(draftStatus);
|
|
|
+ u.setUploadedAt(new Date());
|
|
|
+ u.setUpdatedAt(new Date());
|
|
|
+ return u;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 正常流:上传并解析 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流_上传合法PDF_解析成功返回预览() throws Exception {
|
|
|
+ MultipartFile file = new MockMultipartFile("file", "dan.pdf", "application/pdf",
|
|
|
+ "dummy-pdf-bytes".getBytes());
|
|
|
+ when(fileStorageService.store(any(MultipartFile.class), anyString()))
|
|
|
+ .thenReturn("http://cdn/uploads/dan-reports/2026/11/07/x.pdf");
|
|
|
+ DanReportParseService.DanParsedReport parsed =
|
|
|
+ new DanReportParseService.DanParsedReport("C1", "cognition",
|
|
|
+ new ArrayList<>(), Collections.emptyMap(), "综合良好", "多食蔬菜");
|
|
|
+ when(danReportParseService.parse(any(java.io.InputStream.class), anyString())).thenReturn(parsed);
|
|
|
+ FamilyMember m = new FamilyMember();
|
|
|
+ m.setId(55L);
|
|
|
+ m.setFamilyId(1L);
|
|
|
+ when(familyMemberMapper.selectById(55L)).thenReturn(m);
|
|
|
+
|
|
|
+ Result<Map<String, Object>> result = service.uploadAndParse(77L, 55L, "cognition", file);
|
|
|
+
|
|
|
+ assertNotNull(result);
|
|
|
+ assertEquals(200, result.getCode(), "上传解析应成功");
|
|
|
+ assertEquals("cognition", result.getData().get("dimension"));
|
|
|
+ assertEquals("解析完成,请确认数据准确性", result.getData().get("message"));
|
|
|
+ verify(fileStorageService).store(any(MultipartFile.class), anyString());
|
|
|
+ verify(uploadMapper).insert(any(DanReportUpload.class));
|
|
|
+ verify(uploadMapper).updateById(any(DanReportUpload.class)); // 解析成功后回写
|
|
|
+ assertTrue(((String) result.getData().get("itemsJson")).contains("[]"), "items 应序列化");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 异常流:上传 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 异常流_空文件_拒绝() {
|
|
|
+ MultipartFile empty = new MockMultipartFile("file", "", "application/pdf", new byte[0]);
|
|
|
+ Result<Map<String, Object>> result = service.uploadAndParse(77L, 55L, "cognition", empty);
|
|
|
+
|
|
|
+ assertNotNull(result);
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
+ assertEquals("请选择文件", result.getMessage());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 异常流_非法维度_拒绝() {
|
|
|
+ MultipartFile file = new MockMultipartFile("file", "dan.pdf", "application/pdf",
|
|
|
+ "dummy".getBytes());
|
|
|
+ Result<Map<String, Object>> result = service.uploadAndParse(77L, 55L, "hack", file);
|
|
|
+
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
+ assertTrue(result.getMessage().contains("dimension"), "非法维度应返回参数错误");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 异常流_文件保存失败_拒绝() {
|
|
|
+ MultipartFile file = new MockMultipartFile("file", "dan.pdf", "application/pdf",
|
|
|
+ "dummy".getBytes());
|
|
|
+ when(fileStorageService.store(any(MultipartFile.class), anyString())).thenReturn(null);
|
|
|
+
|
|
|
+ Result<Map<String, Object>> result = service.uploadAndParse(77L, 55L, "cognition", file);
|
|
|
+
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
+ assertEquals("文件保存失败", result.getMessage());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 异常流_解析异常_标记解析失败并返回错误() throws Exception {
|
|
|
+ MultipartFile file = new MockMultipartFile("file", "dan.pdf", "application/pdf",
|
|
|
+ "dummy".getBytes());
|
|
|
+ when(fileStorageService.store(any(MultipartFile.class), anyString()))
|
|
|
+ .thenReturn("http://cdn/uploads/dan-reports/x.pdf");
|
|
|
+ when(danReportParseService.parse(any(java.io.InputStream.class), anyString()))
|
|
|
+ .thenThrow(new java.io.IOException("PDF 损坏"));
|
|
|
+
|
|
|
+ Result<Map<String, Object>> result = service.uploadAndParse(77L, 55L, "cognition", file);
|
|
|
+
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
+ assertTrue(result.getMessage().contains("PDF解析失败"), "解析失败应返回明确错误");
|
|
|
+ verify(uploadMapper).updateById(any(DanReportUpload.class)); // 状态回写 parse_failed
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 正常流:列表查询(成员 / 家庭 / 可见过滤) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流_listByMember_按成员过滤返回VO列表() {
|
|
|
+ when(uploadMapper.selectList(any())).thenReturn(
|
|
|
+ Collections.singletonList(upload(1L, 1L, 55L, "cognition", DanReportUpload.DRAFT_CONFIRMED)));
|
|
|
+
|
|
|
+ Result<List<DanReportUploadVO>> result = service.listByMember(55L, "cognition");
|
|
|
+
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
+ assertEquals(1, result.getData().size());
|
|
|
+ assertEquals(55L, result.getData().get(0).getFamilyMemberId());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流_listByFamily_管理员不过滤_返回家庭全部() {
|
|
|
+ when(uploadMapper.selectList(any())).thenReturn(Arrays.asList(
|
|
|
+ upload(1L, 1L, 55L, "cognition", DanReportUpload.DRAFT_CONFIRMED),
|
|
|
+ upload(2L, 1L, 66L, "mind", DanReportUpload.DRAFT_CONFIRMED)));
|
|
|
+
|
|
|
+ Result<List<DanReportUploadVO>> result = service.listByFamily(1L, null);
|
|
|
+
|
|
|
+ assertEquals(2, result.getData().size(), "管理员可见全部成员报告");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 权限流_listByFamily_普通成员按visibleMemberIds过滤() {
|
|
|
+ when(uploadMapper.selectList(any())).thenReturn(
|
|
|
+ Collections.singletonList(upload(1L, 1L, 55L, "cognition", DanReportUpload.DRAFT_CONFIRMED)));
|
|
|
+
|
|
|
+ // 普通成员:仅可见自己(memberId=55)+ 被授权成员 → 过滤集合 {55,66}
|
|
|
+ Result<List<DanReportUploadVO>> result =
|
|
|
+ service.listByFamily(1L, null, new HashSet<>(Arrays.asList(55L, 66L)));
|
|
|
+
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
+ verify(uploadMapper).selectList(any());
|
|
|
+ assertEquals(1, result.getData().size());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 正常流 / 异常流:两版对比 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流_compareUploads_数值delta与新增删除标记() {
|
|
|
+ DanReportUpload first = upload(1L, 1L, 55L, "cognition", DanReportUpload.DRAFT_CONFIRMED);
|
|
|
+ first.setUploadedAt(new Date(System.currentTimeMillis() - 100_000L));
|
|
|
+ first.setParsedItems("[{\"code\":\"q1\",\"name\":\"注意力\",\"category\":\"认知\",\"value\":60},"
|
|
|
+ + "{\"code\":\"q2\",\"name\":\"记忆力\",\"category\":\"认知\",\"value\":80}]");
|
|
|
+ DanReportUpload second = upload(2L, 1L, 55L, "cognition", DanReportUpload.DRAFT_CONFIRMED);
|
|
|
+ second.setUploadedAt(new Date());
|
|
|
+ second.setParsedItems("[{\"code\":\"q1\",\"name\":\"注意力\",\"category\":\"认知\",\"value\":75},"
|
|
|
+ + "{\"code\":\"q3\",\"name\":\"新增长指标\",\"category\":\"新增\",\"value\":90}]");
|
|
|
+ when(uploadMapper.selectById(1L)).thenReturn(first);
|
|
|
+ when(uploadMapper.selectById(2L)).thenReturn(second);
|
|
|
+
|
|
|
+ Result<Map<String, Object>> result = service.compareUploads(1L, 2L);
|
|
|
+
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ List<Map<String, Object>> deltas = (List<Map<String, Object>>) result.getData().get("deltas");
|
|
|
+ assertEquals(3, deltas.size(), "q1 修改 + q2 移除 + q3 新增");
|
|
|
+ Map<String, Object> modified = deltas.get(0);
|
|
|
+ assertEquals("modified", modified.get("status"));
|
|
|
+ assertEquals("q1", modified.get("code"));
|
|
|
+ assertEquals(15.0, modified.get("valueDelta"), "60→75 应产生 +15 delta");
|
|
|
+ assertEquals("increase", modified.get("change"));
|
|
|
+ Map<String, Object> removed = deltas.get(1);
|
|
|
+ assertEquals("removed", removed.get("status"));
|
|
|
+ Map<String, Object> added = deltas.get(2);
|
|
|
+ assertEquals("added", added.get("status"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 异常流_compareUploads_跨成员_拒绝() {
|
|
|
+ DanReportUpload a = upload(1L, 1L, 55L, "cognition", DanReportUpload.DRAFT_CONFIRMED);
|
|
|
+ DanReportUpload b = upload(2L, 1L, 66L, "cognition", DanReportUpload.DRAFT_CONFIRMED);
|
|
|
+ when(uploadMapper.selectById(1L)).thenReturn(a);
|
|
|
+ when(uploadMapper.selectById(2L)).thenReturn(b);
|
|
|
+
|
|
|
+ Result<Map<String, Object>> result = service.compareUploads(1L, 2L);
|
|
|
+
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
+ assertTrue(result.getMessage().contains("同一成员"), "跨成员对比应拒绝");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 正常流:确认入库 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流_confirmUpload_确认后关联结果并刷新五维() {
|
|
|
+ DanReportUpload upload = upload(1L, 1L, 55L, "cognition", DanReportUpload.DRAFT_PENDING);
|
|
|
+ upload.setParsedItems("[{\"code\":\"total\",\"name\":\"综合\",\"value\":82}]");
|
|
|
+ upload.setParsedSummary("综合良好");
|
|
|
+ upload.setParsedSuggestions("多运动");
|
|
|
+ when(uploadMapper.selectById(1L)).thenReturn(upload);
|
|
|
+ FamilyMember m = new FamilyMember();
|
|
|
+ m.setId(55L);
|
|
|
+ m.setFamilyId(1L);
|
|
|
+ m.setNickname("小明");
|
|
|
+ when(familyMemberMapper.selectById(55L)).thenReturn(m);
|
|
|
+ DanAssessmentResult inserted = new DanAssessmentResult();
|
|
|
+ inserted.setId(9L);
|
|
|
+ when(resultMapper.insert(any(DanAssessmentResult.class))).thenReturn(1);
|
|
|
+
|
|
|
+ Result<DanReportUploadVO> result = service.confirmUpload(1L, 77L, "小明", "2026-11-01");
|
|
|
+
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
+ verify(resultMapper).insert(any(DanAssessmentResult.class));
|
|
|
+ // 新增结果无自增 id(Mockito 环境不会回填),但 refreshFromDanAssessment 必须被触发且维度正确
|
|
|
+ verify(dimensionScoreService).refreshFromDanAssessment(eq(55L), nullable(Long.class), eq("cognition"));
|
|
|
+ assertEquals(DanReportUpload.DRAFT_CONFIRMED, upload.getDraftStatus());
|
|
|
+ assertNull(upload.getEditCount(), "confirmUpload 不触碰 editCount(属 editDraft)");
|
|
|
+ assertEquals(DanReportUpload.DRAFT_CONFIRMED, result.getData().getDraftStatus());
|
|
|
+ }
|
|
|
+}
|