|
|
@@ -0,0 +1,616 @@
|
|
|
+package com.etotem.cfc.unit;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
|
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
|
|
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.controller.admin.ReportParseAdminController;
|
|
|
+import com.etotem.cfc.controller.admin.ReportParserAdminController;
|
|
|
+import com.etotem.cfc.entity.HealthReportDraft;
|
|
|
+import com.etotem.cfc.entity.Product;
|
|
|
+import com.etotem.cfc.entity.ReportFingerprint;
|
|
|
+import com.etotem.cfc.entity.ReportParserImport;
|
|
|
+import com.etotem.cfc.entity.ReportTypeRegistry;
|
|
|
+import com.etotem.cfc.entity.ReportUnknownCluster;
|
|
|
+import com.etotem.cfc.entity.ReportUnknownUpload;
|
|
|
+import com.etotem.cfc.mapper.HealthReportDraftMapper;
|
|
|
+import com.etotem.cfc.mapper.ProductMapper;
|
|
|
+import com.etotem.cfc.mapper.ReportFingerprintMapper;
|
|
|
+import com.etotem.cfc.mapper.ReportParserImportMapper;
|
|
|
+import com.etotem.cfc.mapper.ReportTemplateMapper;
|
|
|
+import com.etotem.cfc.mapper.ReportTypeRegistryMapper;
|
|
|
+import com.etotem.cfc.mapper.ReportUnknownClusterMapper;
|
|
|
+import com.etotem.cfc.mapper.ReportUnknownUploadMapper;
|
|
|
+import com.etotem.cfc.service.DanReportUploadService;
|
|
|
+import com.etotem.cfc.service.HealthReportDraftService;
|
|
|
+import com.etotem.cfc.service.ReportCollectService;
|
|
|
+import com.etotem.cfc.service.ReportImportService;
|
|
|
+import com.etotem.cfc.service.ReportScriptGeneratorService;
|
|
|
+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.mockito.ArgumentCaptor;
|
|
|
+
|
|
|
+import java.lang.reflect.Field;
|
|
|
+import java.lang.reflect.Modifier;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.HashMap;
|
|
|
+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.anyLong;
|
|
|
+import static org.mockito.ArgumentMatchers.eq;
|
|
|
+import static org.mockito.ArgumentMatchers.isNull;
|
|
|
+import static org.mockito.Mockito.mock;
|
|
|
+import static org.mockito.Mockito.never;
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 契约级单测:管理端报告解析能力(能力键 {@code admin.reportParser.manage},SC-A-02 报告解析中台·类型/聚类 +
|
|
|
+ * SC-A-16 报告解析自学习/未知类型标注)。
|
|
|
+ *
|
|
|
+ * <p>契约来源:
|
|
|
+ * <ul>
|
|
|
+ * <li>API_REFERENCE.md §4.30 管理端-报告解析({@code /api/admin/report-parser/*}):types /
|
|
|
+ * types/save / types/toggle / types/delete / fingerprints/list / fingerprints/save /
|
|
|
+ * fingerprints/delete / imports / imports/import / imports/approve / imports/reject /
|
|
|
+ * unknown-clusters / unknown-uploads / generate-type / dan/reparse;</li>
|
|
|
+ * <li>API_REFERENCE.md §4.59.1 管理端-报告解析进度({@code /api/admin/report-parse/*}):list /
|
|
|
+ * retry / backfill-food;</li>
|
|
|
+ * <li>tests/test-cases/admin.md TC-A-05/06/07/17(正常流 + 异常流 + 权限流:非 admin 访问
|
|
|
+ * {@code /api/admin/report-*} 统一 401/403,不泄露后台数据)。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p>实现现状(contract 核对,2026-11-13):{@code ReportParserAdminController}(/api/admin/report-parser)
|
|
|
+ * 与 {@code ReportParseAdminController}(/api/admin/report-parse)均已落地于 origin/cfclub;
|
|
|
+ * 既有 {@code ReportParserAdminContractTest} 仅覆盖 generateType / listTypes 两个方法。本类补全
|
|
|
+ * 其余端点(saveType/toggleType/deleteType/fingerprints/imports/clusters/dan/reparse +
|
|
|
+ * report-parse list/retry/backfill-food)的正常流 / 异常流契约断言,并按 TR-7(B-2 契约核对)作为
|
|
|
+ * 测试侧三类流执行证据登记;不修改 src/main。</p>
|
|
|
+ *
|
|
|
+ * <p>风格:纯 Mockito + 反射注入(无 Spring / 无 DB / 无外部服务),对齐 TeamProfitHookContractTest、
|
|
|
+ * ReportParserAdminContractTest。权限流为说明性契约(admin 整族登录拦截由 JwtInterceptor
|
|
|
+ * LOGIN_REQUIRED_PATHS 的 {@code /api/admin/} 前缀 + controller 层 {@code @RequestAttribute("role")}
|
|
|
+ * 守卫构成,本类固定两个 Controller 的映射前缀归属 /api/admin 命名空间)。</p>
|
|
|
+ */
|
|
|
+class AdminReportParserManageContractTest {
|
|
|
+
|
|
|
+ private ReportParserAdminController parserController;
|
|
|
+ private ReportParseAdminController parseAdminController;
|
|
|
+
|
|
|
+ private ReportTypeRegistryMapper typeRegistryMapper;
|
|
|
+ private ReportFingerprintMapper fingerprintMapper;
|
|
|
+ private ReportUnknownClusterMapper unknownClusterMapper;
|
|
|
+ private ReportUnknownUploadMapper unknownUploadMapper;
|
|
|
+ private ReportParserImportMapper importMapper;
|
|
|
+ private ProductMapper productMapper;
|
|
|
+ private ReportImportService reportImportService;
|
|
|
+ private DanReportUploadService danReportUploadService;
|
|
|
+ private ReportTemplateMapper reportTemplateMapper;
|
|
|
+ private ReportScriptGeneratorService reportScriptGeneratorService;
|
|
|
+
|
|
|
+ private HealthReportDraftMapper draftMapper;
|
|
|
+ private HealthReportDraftService healthReportDraftService;
|
|
|
+ private ReportCollectService reportCollectService;
|
|
|
+
|
|
|
+ @BeforeAll
|
|
|
+ static void initMybatisPlusTableInfo() {
|
|
|
+ MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
|
|
|
+ TableInfoHelper.initTableInfo(assistant, ReportTypeRegistry.class);
|
|
|
+ TableInfoHelper.initTableInfo(assistant, ReportFingerprint.class);
|
|
|
+ TableInfoHelper.initTableInfo(assistant, ReportUnknownCluster.class);
|
|
|
+ TableInfoHelper.initTableInfo(assistant, ReportUnknownUpload.class);
|
|
|
+ TableInfoHelper.initTableInfo(assistant, ReportParserImport.class);
|
|
|
+ TableInfoHelper.initTableInfo(assistant, HealthReportDraft.class);
|
|
|
+ TableInfoHelper.initTableInfo(assistant, Product.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() throws Exception {
|
|
|
+ parserController = new ReportParserAdminController();
|
|
|
+ injectAll(parserController);
|
|
|
+ typeRegistryMapper = (ReportTypeRegistryMapper) readField(parserController, "typeRegistryMapper");
|
|
|
+ fingerprintMapper = (ReportFingerprintMapper) readField(parserController, "fingerprintMapper");
|
|
|
+ unknownClusterMapper = (ReportUnknownClusterMapper) readField(parserController, "unknownClusterMapper");
|
|
|
+ unknownUploadMapper = (ReportUnknownUploadMapper) readField(parserController, "unknownUploadMapper");
|
|
|
+ importMapper = (ReportParserImportMapper) readField(parserController, "importMapper");
|
|
|
+ productMapper = (ProductMapper) readField(parserController, "productMapper");
|
|
|
+ reportImportService = (ReportImportService) readField(parserController, "reportImportService");
|
|
|
+ danReportUploadService = (DanReportUploadService) readField(parserController, "danReportUploadService");
|
|
|
+ reportTemplateMapper = (ReportTemplateMapper) readField(parserController, "reportTemplateMapper");
|
|
|
+ reportScriptGeneratorService =
|
|
|
+ (ReportScriptGeneratorService) readField(parserController, "reportScriptGeneratorService");
|
|
|
+
|
|
|
+ parseAdminController = new ReportParseAdminController();
|
|
|
+ injectAll(parseAdminController);
|
|
|
+ draftMapper = (HealthReportDraftMapper) readField(parseAdminController, "draftMapper");
|
|
|
+ healthReportDraftService = (HealthReportDraftService) readField(parseAdminController, "healthReportDraftService");
|
|
|
+ reportCollectService = (ReportCollectService) readField(parseAdminController, "reportCollectService");
|
|
|
+ }
|
|
|
+
|
|
|
+ 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 ReportTypeRegistry newType(String typeId, String displayName) {
|
|
|
+ ReportTypeRegistry t = new ReportTypeRegistry();
|
|
|
+ t.setTypeId(typeId);
|
|
|
+ t.setDisplayName(displayName);
|
|
|
+ return t;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> params(Object... kv) {
|
|
|
+ Map<String, Object> p = new HashMap<>();
|
|
|
+ for (int i = 0; i < kv.length; i += 2) {
|
|
|
+ p.put((String) kv[i], kv[i + 1]);
|
|
|
+ }
|
|
|
+ return p;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== ReportParserAdminController · 类型 CRUD ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流_listTypes_分页返回类型列表并回填商品名() {
|
|
|
+ ReportTypeRegistry t = newType("gut_flora", "肠道菌群");
|
|
|
+ t.setId(1L);
|
|
|
+ t.setLinkedProductId(5L);
|
|
|
+ Page<ReportTypeRegistry> page = new Page<>(1, 20);
|
|
|
+ page.setRecords(Collections.singletonList(t));
|
|
|
+ page.setTotal(1);
|
|
|
+ when(typeRegistryMapper.selectPage(any(), any())).thenReturn(page);
|
|
|
+ Product p = new Product();
|
|
|
+ p.setId(5L);
|
|
|
+ p.setName("肠道菌群检测");
|
|
|
+ when(productMapper.selectBatchIds(any())).thenReturn(Collections.singletonList(p));
|
|
|
+
|
|
|
+ Result<Page<ReportTypeRegistry>> result = parserController.listTypes(params("page", 1, "size", 20));
|
|
|
+
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
+ assertEquals(1, result.getData().getRecords().size());
|
|
|
+ assertEquals("gut_flora", result.getData().getRecords().get(0).getTypeId());
|
|
|
+ assertEquals("肠道菌群检测", result.getData().getRecords().get(0).getProductName(),
|
|
|
+ "linkedProductId 非空时应回填商品名(§4.30 types)");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流_saveType_新增插入并落默认值() {
|
|
|
+ ReportTypeRegistry t = newType("physical_exam", "体检报告");
|
|
|
+ when(typeRegistryMapper.selectCount(any())).thenReturn(0L);
|
|
|
+
|
|
|
+ Result<Void> result = parserController.saveType(t);
|
|
|
+
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
+ ArgumentCaptor<ReportTypeRegistry> captor = ArgumentCaptor.forClass(ReportTypeRegistry.class);
|
|
|
+ verify(typeRegistryMapper).insert(captor.capture());
|
|
|
+ ReportTypeRegistry saved = captor.getValue();
|
|
|
+ assertTrue(saved.getIsActive(), "新增类型默认启用(isActive=true)");
|
|
|
+ assertEquals("prebuilt", saved.getSource());
|
|
|
+ assertEquals("approved", saved.getReviewStatus());
|
|
|
+ assertEquals("1.0", saved.getVersion());
|
|
|
+ assertEquals(10, saved.getMinConfidence());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流_saveType_已存在id走更新并允许清空商品链接() {
|
|
|
+ ReportTypeRegistry t = newType("gut_flora", "肠道菌群");
|
|
|
+ t.setId(9L);
|
|
|
+ t.setLinkedProductId(null);
|
|
|
+
|
|
|
+ Result<Void> result = parserController.saveType(t);
|
|
|
+
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
+ verify(typeRegistryMapper).updateById(t);
|
|
|
+ verify(typeRegistryMapper).update(isNull(),
|
|
|
+ any(com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 异常流_saveType_typeId缺失_拒绝() {
|
|
|
+ ReportTypeRegistry t = newType("", "肠道菌群");
|
|
|
+ Result<Void> result = parserController.saveType(t);
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
+ assertTrue(result.getMessage().contains("typeId"), "typeId 缺失应报错");
|
|
|
+ verify(typeRegistryMapper, never()).insert(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 异常流_saveType_displayName缺失_拒绝() {
|
|
|
+ ReportTypeRegistry t = newType("gut_flora", "");
|
|
|
+ Result<Void> result = parserController.saveType(t);
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
+ assertTrue(result.getMessage().contains("displayName"), "displayName 缺失应报错");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 异常流_saveType_expectedIndicators非法JSON_拒绝() {
|
|
|
+ ReportTypeRegistry t = newType("gut_flora", "肠道菌群");
|
|
|
+ t.setExpectedIndicators("{不是数组}");
|
|
|
+ Result<Void> result = parserController.saveType(t);
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
+ assertTrue(result.getMessage().contains("expectedIndicators"), "expectedIndicators 非法应报错");
|
|
|
+ verify(typeRegistryMapper, never()).insert(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 异常流_saveType_重复typeId_拒绝不插入() {
|
|
|
+ ReportTypeRegistry t = newType("gut_flora", "肠道菌群");
|
|
|
+ when(typeRegistryMapper.selectCount(any())).thenReturn(1L);
|
|
|
+ Result<Void> result = parserController.saveType(t);
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
+ assertTrue(result.getMessage().contains("已存在"), "重复 typeId 应报错(TC-A-06:重复 typeId 拒绝)");
|
|
|
+ verify(typeRegistryMapper, never()).insert(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流_toggleType_停用报告类型() {
|
|
|
+ ReportTypeRegistry t = newType("gut_flora", "肠道菌群");
|
|
|
+ when(typeRegistryMapper.selectOne(any())).thenReturn(t);
|
|
|
+ Result<Void> result = parserController.toggleType(params("typeId", "gut_flora", "isActive", false));
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
+ verify(typeRegistryMapper).updateById(t);
|
|
|
+ assertEquals(false, t.getIsActive(), "isActive=false 应停用类型");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 异常流_toggleType_typeId缺失_拒绝() {
|
|
|
+ Result<Void> result = parserController.toggleType(params("isActive", false));
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
+ assertTrue(result.getMessage().contains("typeId"));
|
|
|
+ verify(typeRegistryMapper, never()).updateById(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 异常流_toggleType_类型不存在_拒绝() {
|
|
|
+ when(typeRegistryMapper.selectOne(any())).thenReturn(null);
|
|
|
+ Result<Void> result = parserController.toggleType(params("typeId", "nope"));
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
+ assertTrue(result.getMessage().contains("不存在"));
|
|
|
+ verify(typeRegistryMapper, never()).updateById(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流_deleteType_删除类型并级联删除指纹() {
|
|
|
+ Result<Void> result = parserController.deleteType(params("typeId", "gut_flora"));
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
+ verify(typeRegistryMapper).delete(any());
|
|
|
+ verify(fingerprintMapper).delete(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 异常流_deleteType_typeId缺失_拒绝() {
|
|
|
+ Result<Void> result = parserController.deleteType(params());
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
+ assertTrue(result.getMessage().contains("typeId"));
|
|
|
+ verify(typeRegistryMapper, never()).delete(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== ReportParserAdminController · 指纹规则 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流_listFingerprints_按typeId查询并按排序返回() {
|
|
|
+ ReportFingerprint fp = new ReportFingerprint();
|
|
|
+ fp.setId(1L);
|
|
|
+ fp.setTypeId("gut_flora");
|
|
|
+ fp.setPattern("菌群多样性");
|
|
|
+ when(fingerprintMapper.selectList(any())).thenReturn(Collections.singletonList(fp));
|
|
|
+
|
|
|
+ Result<List<ReportFingerprint>> result = parserController.listFingerprints(params("typeId", "gut_flora"));
|
|
|
+
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
+ assertEquals(1, result.getData().size());
|
|
|
+ assertEquals("菌群多样性", result.getData().get(0).getPattern());
|
|
|
+ verify(fingerprintMapper).selectList(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 异常流_listFingerprints_typeId缺失_拒绝() {
|
|
|
+ Result<List<ReportFingerprint>> result = parserController.listFingerprints(params());
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
+ assertTrue(result.getMessage().contains("typeId"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流_saveFingerprint_插入并落默认权重与排序() {
|
|
|
+ ReportFingerprint fp = new ReportFingerprint();
|
|
|
+ fp.setTypeId("gut_flora");
|
|
|
+ fp.setPattern("菌群多样性");
|
|
|
+
|
|
|
+ Result<Void> result = parserController.saveFingerprint(fp);
|
|
|
+
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
+ ArgumentCaptor<ReportFingerprint> captor = ArgumentCaptor.forClass(ReportFingerprint.class);
|
|
|
+ verify(fingerprintMapper).insert(captor.capture());
|
|
|
+ ReportFingerprint saved = captor.getValue();
|
|
|
+ assertEquals(10, saved.getWeight(), "指纹默认权重 10");
|
|
|
+ assertEquals(false, saved.getIsExclusion(), "默认非排除规则");
|
|
|
+ assertEquals(0, saved.getSortOrder(), "默认排序 0");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 异常流_saveFingerprint_typeId或pattern缺失_拒绝() {
|
|
|
+ Result<Void> result = parserController.saveFingerprint(new ReportFingerprint());
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
+ assertTrue(result.getMessage().contains("typeId") && result.getMessage().contains("pattern"));
|
|
|
+ verify(fingerprintMapper, never()).insert(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流_deleteFingerprint_按id删除() {
|
|
|
+ Result<Void> result = parserController.deleteFingerprint(params("id", 7L));
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
+ verify(fingerprintMapper).deleteById(7L);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 异常流_deleteFingerprint_id缺失_拒绝() {
|
|
|
+ Result<Void> result = parserController.deleteFingerprint(params());
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
+ assertTrue(result.getMessage().contains("id"));
|
|
|
+ verify(fingerprintMapper, never()).deleteById(anyLong());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== ReportParserAdminController · 导入审批链 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流_listImports_分页返回导入记录() {
|
|
|
+ Page<ReportParserImport> page = new Page<>(1, 20);
|
|
|
+ ReportParserImport imp = new ReportParserImport();
|
|
|
+ imp.setId(1L);
|
|
|
+ imp.setPackageName("gut_flora_v2.json");
|
|
|
+ imp.setStatus("pending");
|
|
|
+ page.setRecords(Collections.singletonList(imp));
|
|
|
+ page.setTotal(1);
|
|
|
+ when(importMapper.selectPage(any(), any())).thenReturn(page);
|
|
|
+
|
|
|
+ Result<Page<ReportParserImport>> result = parserController.listImports(params("page", 1, "size", 20));
|
|
|
+
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
+ assertEquals(1, result.getData().getRecords().size());
|
|
|
+ assertEquals("pending", result.getData().getRecords().get(0).getStatus());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流_uploadImport_委托导入服务解析导入包() {
|
|
|
+ ReportParserImport imp = new ReportParserImport();
|
|
|
+ imp.setId(1L);
|
|
|
+ when(reportImportService.importPackage(any(), any(), any())).thenReturn(Result.success(imp));
|
|
|
+
|
|
|
+ Result<ReportParserImport> result = parserController.uploadImport(
|
|
|
+ params("jsonContent", "{\"typeId\":\"gut_flora\"}", "fileName", "pkg.json", "userId", 3L));
|
|
|
+
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
+ verify(reportImportService).importPackage(eq("pkg.json"), eq("{\"typeId\":\"gut_flora\"}"), eq(3L));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 异常流_uploadImport_jsonContent缺失_拒绝() {
|
|
|
+ Result<ReportParserImport> result = parserController.uploadImport(params("fileName", "pkg.json"));
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
+ assertTrue(result.getMessage().contains("jsonContent"));
|
|
|
+ verify(reportImportService, never()).importPackage(any(), any(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流_approveImport_委托审批导入包() {
|
|
|
+ when(reportImportService.approveImport(anyLong(), any())).thenReturn(Result.success());
|
|
|
+ Result<Map<String, Object>> result = parserController.approveImport(params("id", 2L, "reviewerId", 9L));
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
+ verify(reportImportService).approveImport(2L, 9L);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 异常流_approveImport_id缺失_拒绝() {
|
|
|
+ Result<Map<String, Object>> result = parserController.approveImport(params("reviewerId", 9L));
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
+ assertTrue(result.getMessage().contains("id"));
|
|
|
+ verify(reportImportService, never()).approveImport(anyLong(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流_rejectImport_委托驳回导入包() {
|
|
|
+ when(reportImportService.rejectImport(anyLong(), any(), any())).thenReturn(Result.success());
|
|
|
+ Result<Void> result = parserController.rejectImport(params("id", 2L, "reviewerId", 9L, "note", "字段缺"));
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
+ verify(reportImportService).rejectImport(2L, 9L, "字段缺");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 异常流_rejectImport_id缺失_拒绝() {
|
|
|
+ Result<Void> result = parserController.rejectImport(params("note", "x"));
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
+ assertTrue(result.getMessage().contains("id"));
|
|
|
+ verify(reportImportService, never()).rejectImport(anyLong(), any(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== ReportParserAdminController · 未知聚类 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流_listClusters_分页并按状态过滤() {
|
|
|
+ Page<ReportUnknownCluster> page = new Page<>(1, 20);
|
|
|
+ ReportUnknownCluster c = new ReportUnknownCluster();
|
|
|
+ c.setId(3L);
|
|
|
+ c.setClusterKey("City Hospital");
|
|
|
+ c.setStatus(ReportUnknownCluster.STATUS_READY);
|
|
|
+ c.setReportCount(12);
|
|
|
+ page.setRecords(Collections.singletonList(c));
|
|
|
+ page.setTotal(1);
|
|
|
+ when(unknownClusterMapper.selectPage(any(), any())).thenReturn(page);
|
|
|
+
|
|
|
+ Result<Page<ReportUnknownCluster>> result =
|
|
|
+ parserController.listClusters(params("page", 1, "size", 20, "status", "ready"));
|
|
|
+
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
+ assertEquals(1, result.getData().getRecords().size());
|
|
|
+ assertEquals("ready", result.getData().getRecords().get(0).getStatus());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流_listUploads_按clusterId返回聚类上传记录() {
|
|
|
+ ReportUnknownUpload u = new ReportUnknownUpload();
|
|
|
+ u.setId(1L);
|
|
|
+ u.setClusterId(3L);
|
|
|
+ u.setFileHash("abc");
|
|
|
+ when(unknownUploadMapper.selectList(any())).thenReturn(Collections.singletonList(u));
|
|
|
+
|
|
|
+ Result<List<ReportUnknownUpload>> result = parserController.listUploads(params("clusterId", 3L));
|
|
|
+
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
+ assertEquals(1, result.getData().size());
|
|
|
+ assertEquals("abc", result.getData().get(0).getFileHash());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 异常流_listUploads_clusterId缺失_拒绝() {
|
|
|
+ Result<List<ReportUnknownUpload>> result = parserController.listUploads(params());
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
+ assertTrue(result.getMessage().contains("clusterId"));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== ReportParserAdminController · DAN 重解析 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流_reparseDan_委托DAN报告按维度重解析() {
|
|
|
+ Map<String, Object> reparse = new HashMap<>();
|
|
|
+ reparse.put("uploadId", 6L);
|
|
|
+ reparse.put("dimension", "learning");
|
|
|
+ reparse.put("parsed", true);
|
|
|
+ when(danReportUploadService.reparseUpload(anyLong(), any())).thenReturn(Result.success(reparse));
|
|
|
+
|
|
|
+ Result<Map<String, Object>> result =
|
|
|
+ parserController.reparseDan(params("uploadId", 6L, "dimension", "learning"));
|
|
|
+
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
+ assertEquals(true, result.getData().get("parsed"));
|
|
|
+ verify(danReportUploadService).reparseUpload(6L, "learning");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 异常流_reparseDan_uploadId或dimension缺失_拒绝() {
|
|
|
+ Result<Map<String, Object>> result = parserController.reparseDan(params("uploadId", 6L));
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
+ assertTrue(result.getMessage().contains("uploadId") || result.getMessage().contains("dimension"));
|
|
|
+ verify(danReportUploadService, never()).reparseUpload(anyLong(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== ReportParseAdminController · 解析进度管理 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流_list_分页返回草稿解析进度记录字段集() {
|
|
|
+ HealthReportDraft d = new HealthReportDraft();
|
|
|
+ d.setId(11L);
|
|
|
+ d.setUserId(22L);
|
|
|
+ d.setReportType("gut_flora");
|
|
|
+ d.setOriginalFilename("child_report.pdf");
|
|
|
+ d.setFileHash("hash1");
|
|
|
+ d.setParseStatus("failed");
|
|
|
+ d.setParseMethod("langgraph");
|
|
|
+ d.setParseError("parse timeout");
|
|
|
+ d.setParseAttempt(2);
|
|
|
+ d.setStatus("drafting");
|
|
|
+ d.setConfirmedReportId(null);
|
|
|
+ d.setCreatedAt(new Date());
|
|
|
+ d.setUpdatedAt(new Date());
|
|
|
+ Page<HealthReportDraft> page = new Page<>(1, 20);
|
|
|
+ page.setRecords(Collections.singletonList(d));
|
|
|
+ page.setTotal(1);
|
|
|
+ when(draftMapper.selectPage(any(), any())).thenReturn(page);
|
|
|
+
|
|
|
+ Result<Map<String, Object>> result = parseAdminController.list(params("page", 1, "size", 20, "parseStatus", "failed"));
|
|
|
+
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
+ assertEquals(1L, ((Number) result.getData().get("total")).longValue());
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ List<Map<String, Object>> records = (List<Map<String, Object>>) result.getData().get("records");
|
|
|
+ assertEquals(1, records.size());
|
|
|
+ Map<String, Object> r = records.get(0);
|
|
|
+ assertEquals(11L, r.get("draftId"));
|
|
|
+ assertEquals("gut_flora", r.get("reportType"));
|
|
|
+ assertEquals("failed", r.get("parseStatus"));
|
|
|
+ assertEquals(2, r.get("parseAttempt"));
|
|
|
+ assertEquals("drafting", r.get("draftStatus"));
|
|
|
+ assertNull(r.get("confirmedReportId"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流_retry_重置解析进度并重新提交异步采集() {
|
|
|
+ Result<Map<String, Object>> result = parseAdminController.retry(params("draftId", 11L));
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
+ assertEquals(11L, result.getData().get("draftId"));
|
|
|
+ assertEquals("collecting", result.getData().get("parseStatus"));
|
|
|
+ verify(healthReportDraftService).resetForManualRetry(11L);
|
|
|
+ verify(reportCollectService).submitAsyncCollect(11L);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 异常流_retry_draftId缺失_拒绝() {
|
|
|
+ Result<Map<String, Object>> result = parseAdminController.retry(params());
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
+ assertTrue(result.getMessage().contains("draftId"));
|
|
|
+ verify(healthReportDraftService, never()).resetForManualRetry(anyLong());
|
|
|
+ verify(reportCollectService, never()).submitAsyncCollect(anyLong());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流_backfillFood_重新解析并覆盖写入食物清单() {
|
|
|
+ when(reportCollectService.reparseReportFoods(7L)).thenReturn(42);
|
|
|
+ Result<Map<String, Object>> result = parseAdminController.backfillFood(params("reportId", 7L));
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
+ assertEquals(7L, result.getData().get("reportId"));
|
|
|
+ assertEquals(42, result.getData().get("foodCount"));
|
|
|
+ verify(reportCollectService).reparseReportFoods(7L);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 异常流_backfillFood_reportId缺失_拒绝() {
|
|
|
+ Result<Map<String, Object>> result = parseAdminController.backfillFood(params());
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
+ assertTrue(result.getMessage().contains("reportId"));
|
|
|
+ verify(reportCollectService, never()).reparseReportFoods(anyLong());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 异常流_backfillFood_服务非法状态_错误返回保留旧载荷() {
|
|
|
+ when(reportCollectService.reparseReportFoods(7L))
|
|
|
+ .thenThrow(new IllegalStateException("报告状态不允许回填"));
|
|
|
+ Result<Map<String, Object>> result = parseAdminController.backfillFood(params("reportId", 7L));
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
+ assertTrue(result.getMessage().contains("不允许回填"),
|
|
|
+ "IllegalStateException 应转 Result.error(失败保留旧载荷,§4.59.1 backfill-food)");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 权限流 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 权限流_管理端报告解析端点归属admin命名空间_由JwtInterceptor统一登录拦截() {
|
|
|
+ assertEquals("/api/admin/report-parser", parserController.getClass()
|
|
|
+ .getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0],
|
|
|
+ "ReportParserAdminController 前缀归属 /api/admin 命名空间(JwtInterceptor LOGIN_REQUIRED_PATHS /api/admin/)");
|
|
|
+ assertEquals("/api/admin/report-parse", parseAdminController.getClass()
|
|
|
+ .getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0],
|
|
|
+ "ReportParseAdminController 前缀归属 /api/admin 命名空间(JwtInterceptor LOGIN_REQUIRED_PATHS /api/admin/)");
|
|
|
+ // 说明性契约:无 token → 401(LOGIN_REQUIRED_PATHS "/api/admin/"),非 admin token → controller 层
|
|
|
+ // @RequestAttribute("role") 守卫 403(B-142-1 双层防线);本类不重复实现鉴权逻辑(TC-A-05/06/17-P)。
|
|
|
+ assertTrue(true, "权限流说明:admin.reportParser.manage 鉴权由 JwtInterceptor(/api/admin/) + controller role 守卫构成");
|
|
|
+ }
|
|
|
+}
|