Просмотр исходного кода

Merge task/S-cfc-205: admin.data.migration contract test (TDD-RED)

E2E Test Bot 2 часов назад
Родитель
Сommit
6ac615fa73

+ 324 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/AdminDataMigrationContractTest.java

@@ -0,0 +1,324 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.admin.DataMigrationController;
+import com.etotem.cfc.controller.admin.MigrationController;
+import com.etotem.cfc.controller.admin.ReportParseAdminController;
+import com.etotem.cfc.entity.HealthReportDraft;
+import com.etotem.cfc.mapper.HealthReportDraftMapper;
+import com.etotem.cfc.service.DataMigrationService;
+import com.etotem.cfc.service.HealthReportDraftService;
+import com.etotem.cfc.service.ReportCollectService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.lang.reflect.Parameter;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+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.anyString;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:能力键 {@code admin.data.migration}(S-cfc-205,TDD-RED 阶段)。
+ *
+ * <p><b>契约溯源</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-A-10(数据迁移 / 参数校验:
+ *       跑迁移脚本 → 校验参数 → 确认无孤儿数据;边界:迁移幂等;字段校验失败阻断);</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.60.10(
+ *       {@code POST /api/migration/run}、{@code POST /api/migrate/guide-packages}、
+ *       {@code /migrate/knowledge-base-to-articles}、{@code /api/admin/report-parse/backfill-food}、
+ *       {@code /retry});</li>
+ *   <li>用例:{@code tests/test-cases/admin.md} TC-A-15 / TC-A-15-E / TC-A-15-P。</li>
+ * </ul>
+ *
+ * <p><b>契约范围</b>(3 个迁移/参数校验入口的统一契约)</p>
+ * <ul>
+ *   <li>{@code DataMigrationController}(/api/migration/run)——手动触发数据迁移:全部成功返回成功码;
+ *       部分失败必须返回<b>明确错误码</b>,不得以成功码伪装完成;重复执行<b>幂等</b>(不得再次触发迁移);</li>
+ *   <li>{@code MigrationController}(/api/migrate/guide-packages、/knowledge-base-to-articles)——
+ *       专项迁移:DDL/源库故障必须返回<b>明确错误结果</b>,不得吞异常返回成功、不得向调用方抛出原始异常;</li>
+ *   <li>{@code ReportParseAdminController}(/api/admin/report-parse/backfill-food、/retry、/list)——
+ *       参数校验:{@code draftId}/{@code reportId} 非正数、{@code page} 非正数必须<b>阻断并返回明确错误</b>
+ *       (字段校验失败阻断)。</li>
+ * </ul>
+ *
+ * <p><b>覆盖流</b>(纯 Mockito,无 Spring 上下文 / 无 DB / 无外部服务):正常流 / 异常流·边界流(明确错误)/
+ * 幂等流 / 权限流(TC-A-15-P:非 admin 角色访问管理端数据迁移接口 → 401/403)。</p>
+ *
+ * <p><b>RED 预期</b>(当前 src/main 现状,逐项核实):</p>
+ * <ul>
+ *   <li>{@code DataMigrationController.runMigration()} 部分失败走 {@code else} 分支返回
+ *       {@code Result.success("迁移过程中有部分错误", ...)}(code=200)——未返回错误码;且无幂等守卫,
+ *       每次调用都触发 {@code dataMigrationService.runMigration()};</li>
+ *   <li>{@code MigrationController.migrateGuidePackages()} 用空 {@code catch (Exception ignored)}
+ *       吞掉 DDL 异常后仍返回 {@code Result.success("迁移完成")};{@code migrateKnowledgeBaseToArticles()}
+ *       源库故障时异常直接上抛(无 try/catch 转换);</li>
+ *   <li>{@code ReportParseAdminController} 的 {@code retry}/{@code backfillFood} 仅判 {@code null},
+ *       不拒绝 {@code draftId<=0}/{@code reportId<=0};{@code list} 对 {@code page<1} 无校验。</li>
+ * </ul>
+ * 因此本类契约断言在实现补齐前应<b>失败(红)</b>;GREEN 阶段补齐上述行为后转绿。
+ */
+class AdminDataMigrationContractTest {
+
+    private static final String KEY = "[admin.data.migration]";
+
+    private DataMigrationController dataMigrationController;
+    private MigrationController migrationController;
+    private ReportParseAdminController reportParseAdminController;
+
+    private DataMigrationService dataMigrationService;
+    private JdbcTemplate jdbcTemplate;
+    private HealthReportDraftMapper draftMapper;
+    private HealthReportDraftService healthReportDraftService;
+    private ReportCollectService reportCollectService;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        dataMigrationController = new DataMigrationController();
+        injectAll(dataMigrationController);
+        dataMigrationService = (DataMigrationService) readField(dataMigrationController, "dataMigrationService");
+
+        migrationController = new MigrationController();
+        injectAll(migrationController);
+        jdbcTemplate = (JdbcTemplate) readField(migrationController, "jdbcTemplate");
+
+        reportParseAdminController = new ReportParseAdminController();
+        injectAll(reportParseAdminController);
+        draftMapper = (HealthReportDraftMapper) readField(reportParseAdminController, "draftMapper");
+        healthReportDraftService = (HealthReportDraftService) readField(reportParseAdminController, "healthReportDraftService");
+        reportCollectService = (ReportCollectService) readField(reportParseAdminController, "reportCollectService");
+    }
+
+    // ==================== 正常流(契约基线:已符合,记录 GREEN 目标) ====================
+
+    @Test
+    @DisplayName("正常流-1:三个迁移/参数校验契约类应存在")
+    void 正常流_契约类存在() {
+        assertNotNull(loadClass("com.etotem.cfc.controller.admin.DataMigrationController"),
+                KEY + " DataMigrationController(/api/migration/run)应存在(§4.60.10)。");
+        assertNotNull(loadClass("com.etotem.cfc.controller.admin.MigrationController"),
+                KEY + " MigrationController(/api/migrate/*)应存在(§4.60.10)。");
+        assertNotNull(loadClass("com.etotem.cfc.controller.admin.ReportParseAdminController"),
+                KEY + " ReportParseAdminController(/api/admin/report-parse/*)应存在(§4.60.10)。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:runMigration 全部成功应返回成功码")
+    void 正常流_runMigration全部成功_返回成功码() {
+        Map<String, Object> ok = new HashMap<>();
+        ok.put("success", true);
+        when(dataMigrationService.runMigration()).thenReturn(ok);
+
+        Result<Map<String, Object>> result = dataMigrationController.runMigration();
+
+        assertEquals(Integer.valueOf(200), result.getCode(),
+                KEY + " 迁移全部成功应返回成功码(TC-A-15 正常流)。");
+    }
+
+    // ==================== 异常流:明确错误(非伪成功) ====================
+
+    @Test
+    @DisplayName("异常流-1:runMigration 部分失败必须返回错误码,不得以成功码伪装完成")
+    void 异常流_runMigration部分失败_必须返回错误码() {
+        Map<String, Object> partial = new HashMap<>();
+        partial.put("success", false);
+        partial.put("errors", Collections.singletonList("迁移中断: 源连接超时"));
+        when(dataMigrationService.runMigration()).thenReturn(partial);
+
+        Result<Map<String, Object>> result = dataMigrationController.runMigration();
+
+        assertNotEquals(Integer.valueOf(200), result.getCode(),
+                KEY + " 迁移部分失败必须返回错误码(Result.error),不得以成功码伪装完成"
+                        + "(TC-A-15-E:中断回滚 + 明确错误非500)。");
+    }
+
+    @Test
+    @DisplayName("异常流-2:guide-packages 专项迁移 DDL 失败必须返回错误码,不得吞异常伪装成功")
+    void 异常流_guidePackages迁移_DDL失败返回错误码() {
+        doThrow(new RuntimeException("模拟 guide_packages 表不存在/DDL 失败"))
+                .when(jdbcTemplate).execute(anyString());
+
+        Result<String> result = migrationController.migrateGuidePackages();
+
+        assertNotEquals(Integer.valueOf(200), result.getCode(),
+                KEY + " guide-packages 专项迁移 DDL 失败不得吞异常伪装成功(SC-A-10 字段校验失败阻断;"
+                        + "TC-A-15-E 明确错误非500)。");
+    }
+
+    @Test
+    @DisplayName("异常流-3:知识库迁移源库故障应返回明确错误结果,不得向调用方抛出原始异常")
+    void 异常流_知识库迁移_源库故障返回明确错误结果() {
+        when(jdbcTemplate.queryForList(anyString()))
+                .thenThrow(new RuntimeException("源库连接中断"));
+
+        assertDoesNotThrow(() -> {
+            Result<String> result = migrationController.migrateKnowledgeBaseToArticles();
+            assertNotEquals(Integer.valueOf(200), result.getCode(),
+                    KEY + " 知识库迁移源库故障必须返回明确错误码(TC-A-15-E:明确错误非500)。");
+        }, KEY + " 知识库迁移源库故障不得向调用方抛出原始异常,应转为明确错误结果(TC-A-15-E)。");
+    }
+
+    // ==================== 边界流:字段校验失败阻断(SC-A-10 边界) ====================
+
+    @Test
+    @DisplayName("边界流-1:report-parse/retry draftId 非正数应阻断并返回明确错误")
+    void 边界流_retry_draftId非正数_阻断() {
+        Result<Map<String, Object>> result = reportParseAdminController.retry(params("draftId", 0L));
+
+        assertNotEquals(Integer.valueOf(200), result.getCode(),
+                KEY + " report-parse/retry 的 draftId 非法(<=0) 必须阻断并返回明确错误"
+                        + "(SC-A-10 字段校验失败阻断)。");
+        verify(reportCollectService, never()).submitAsyncCollect(anyLong());
+    }
+
+    @Test
+    @DisplayName("边界流-2:report-parse/backfill-food reportId 非正数应阻断并返回明确错误")
+    void 边界流_backfillFood_reportId非正数_阻断() {
+        Result<Map<String, Object>> result = reportParseAdminController.backfillFood(params("reportId", 0L));
+
+        assertNotEquals(Integer.valueOf(200), result.getCode(),
+                KEY + " report-parse/backfill-food 的 reportId 非法(<=0) 必须阻断并返回明确错误"
+                        + "(SC-A-10 字段校验失败阻断)。");
+        verify(reportCollectService, never()).reparseReportFoods(anyLong());
+    }
+
+    @Test
+    @DisplayName("边界流-3:report-parse/list page 非正数应阻断并返回明确错误")
+    void 边界流_list_page非正数_阻断() {
+        when(draftMapper.selectPage(any(), any())).thenReturn(new Page<HealthReportDraft>());
+
+        Result<Map<String, Object>> result = reportParseAdminController.list(params("page", 0, "size", 20));
+
+        assertNotEquals(Integer.valueOf(200), result.getCode(),
+                KEY + " report-parse/list 的 page<1 必须阻断并返回明确错误(SC-A-10 字段校验失败阻断)。");
+    }
+
+    // ==================== 幂等流:重复执行幂等(SC-A-10 边界 / TC-A-15-E) ====================
+
+    @Test
+    @DisplayName("幂等流:重复执行 runMigration 不得再次触发迁移(幂等/提示已迁移)")
+    void 幂等流_重复执行runMigration_不得再次触发() {
+        Map<String, Object> ok = new HashMap<>();
+        ok.put("success", true);
+        when(dataMigrationService.runMigration()).thenReturn(ok);
+
+        dataMigrationController.runMigration();
+        dataMigrationController.runMigration(); // 第二次:契约要求幂等,不得重复触发迁移
+
+        verify(dataMigrationService, times(1)).runMigration();
+    }
+
+    // ==================== 权限流:非 admin 角色访问管理端数据迁移接口 → 401/403(TC-A-15-P) ====================
+
+    @Test
+    @DisplayName("权限流:迁移/报告解析管理端接口应实施 admin 角色门禁(非 admin 401/403)")
+    void 权限流_管理端接口_应实施admin角色门禁() {
+        assertTrue(hasRoleGate(method(DataMigrationController.class, "runMigration")),
+                KEY + " POST /api/migration/run 应实施 admin 角色门禁(非 admin 401/403,TC-A-15-P)。");
+        assertTrue(hasRoleGate(method(MigrationController.class, "migrateGuidePackages")),
+                KEY + " POST /api/migrate/guide-packages 应实施 admin 角色门禁(TC-A-15-P)。");
+        assertTrue(hasRoleGate(method(MigrationController.class, "migrateKnowledgeBaseToArticles")),
+                KEY + " POST /api/migrate/knowledge-base-to-articles 应实施 admin 角色门禁(TC-A-15-P)。");
+        assertTrue(hasRoleGate(method(ReportParseAdminController.class, "list", Map.class)),
+                KEY + " POST /api/admin/report-parse/list 应实施 admin 角色门禁(TC-A-15-P)。");
+        assertTrue(hasRoleGate(method(ReportParseAdminController.class, "retry", Map.class)),
+                KEY + " POST /api/admin/report-parse/retry 应实施 admin 角色门禁(TC-A-15-P)。");
+        assertTrue(hasRoleGate(method(ReportParseAdminController.class, "backfillFood", Map.class)),
+                KEY + " POST /api/admin/report-parse/backfill-food 应实施 admin 角色门禁(TC-A-15-P)。");
+    }
+
+    // ==================== 反射/Mockito 辅助 ====================
+
+    private static Object readField(Object target, String name) throws Exception {
+        Field f = target.getClass().getDeclaredField(name);
+        f.setAccessible(true);
+        return f.get(target);
+    }
+
+    /** 对目标对象所有非 static/final 字段注入 Mockito mock(仿 ReportParserAdminContractTest 既有模式) */
+    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 Method method(Class<?> clazz, String name, Class<?>... paramTypes) {
+        try {
+            return clazz.getDeclaredMethod(name, paramTypes);
+        } catch (NoSuchMethodException e) {
+            throw new IllegalStateException(KEY + " 契约方法缺失: " + clazz.getSimpleName() + "." + name, e);
+        }
+    }
+
+    /** 探测某 handler 是否实施 admin 角色门禁:方法带 role/request 属性参数,或方法/类带角色授权注解 */
+    private boolean hasRoleGate(Method m) {
+        for (Parameter p : m.getParameters()) {
+            if (p.getType().getSimpleName().equals("HttpServletRequest")) {
+                return true;
+            }
+            if ("role".equals(p.getName()) || "currentRole".equals(p.getName())) {
+                return true;
+            }
+            for (Annotation a : p.getAnnotations()) {
+                String n = a.annotationType().getSimpleName();
+                if (n.contains("RequestAttribute") || n.toLowerCase().contains("role")) {
+                    return true;
+                }
+            }
+        }
+        return hasRoleSemanticAnnotation(m.getDeclaringClass().getAnnotations())
+                || hasRoleSemanticAnnotation(m.getAnnotations());
+    }
+
+    private boolean hasRoleSemanticAnnotation(Annotation[] anns) {
+        for (Annotation a : anns) {
+            String n = a.annotationType().getSimpleName().toLowerCase();
+            if (n.contains("role") || n.contains("admin") || n.contains("require") || n.contains("authorize")) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private Map<String, Object> params(Object... kv) {
+        Map<String, Object> m = new HashMap<>();
+        for (int i = 0; i + 1 < kv.length; i += 2) {
+            m.put((String) kv[i], kv[i + 1]);
+        }
+        return m;
+    }
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+}