|
@@ -0,0 +1,505 @@
|
|
|
|
|
+package com.etotem.cfc.unit;
|
|
|
|
|
+
|
|
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
|
|
+import com.etotem.cfc.controller.ContentFilterController;
|
|
|
|
|
+import com.etotem.cfc.controller.admin.AdminArticleController;
|
|
|
|
|
+import com.etotem.cfc.controller.admin.ReviewCenterController;
|
|
|
|
|
+import com.etotem.cfc.entity.Article;
|
|
|
|
|
+import com.etotem.cfc.mapper.ActivityMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.ActivityRegistrationMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.ArticleMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.ButlerProfileMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.PendingRefundMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.ProductMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.ServiceRoleApplicationMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.SupplyHierarchyChangeRequestMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.TaskTemplatePackageMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.UserMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.WithdrawalRequestMapper;
|
|
|
|
|
+import com.etotem.cfc.service.ArticleService;
|
|
|
|
|
+import com.etotem.cfc.service.api.WechatServiceInterface;
|
|
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
|
|
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
|
|
|
|
+import org.springframework.web.bind.annotation.PostMapping;
|
|
|
|
|
+
|
|
|
|
|
+import java.lang.annotation.Annotation;
|
|
|
|
|
+import java.lang.reflect.Field;
|
|
|
|
|
+import java.lang.reflect.Method;
|
|
|
|
|
+import java.util.HashMap;
|
|
|
|
|
+import java.util.HashSet;
|
|
|
|
|
+import java.util.Map;
|
|
|
|
|
+import java.util.Set;
|
|
|
|
|
+import java.util.concurrent.Callable;
|
|
|
|
|
+import java.util.concurrent.Future;
|
|
|
|
|
+import java.util.concurrent.TimeUnit;
|
|
|
|
|
+
|
|
|
|
|
+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.assertThrows;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.anyLong;
|
|
|
|
|
+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.content.security})。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>溯源</b></p>
|
|
|
|
|
+ * <ul>
|
|
|
|
|
+ * <li>需求:{@code tests/user-scenarios/应用场景总览.md} —— SC-A-13(内容安全审核:UGC 审核 →
|
|
|
|
|
+ * 通过/下架,边界:敏感词命中拦截)+ SC-A-18(内容安全审核队列:队列 → 通过/下架,
|
|
|
|
|
+ * 边界:敏感词命中拦截)</li>
|
|
|
|
|
+ * <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.13(管理员接口)+ §4.60.13
|
|
|
|
|
+ * (SC-A-13/A-18 内容安全审核:{@code ReviewCenterController /api/admin/review-center/summary}
|
|
|
|
|
+ * 13 类待审聚合 + 业务 audit 端点)+ §4.65.2(文章发布-审核闭环,端点齐全 ✅)</li>
|
|
|
|
|
+ * <li>编排:{@code docs/tdd/orchestration/sc-key-map.md} —— SC-A-13/A-18 → {@code admin.content.security}
|
|
|
|
|
+ * → RED(S-cfc-208)</li>
|
|
|
|
|
+ * </ul>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>载体(src/main 现状核实,origin/cfclub @ f941b9212)</b></p>
|
|
|
|
|
+ * <ul>
|
|
|
|
|
+ * <li>{@code AdminArticleController /api/admin/articles}:{@code audit} / {@code submit-review} /
|
|
|
|
|
+ * {@code withdraw} / {@code re-draft} / {@code upload/image}(微信 {@code checkImage} 检测)/
|
|
|
|
|
+ * {@code upload/video} / {@code comments/list}</li>
|
|
|
|
|
+ * <li>{@code ArticleService}:{@code submitForReview}(draft→pending+auditStatus=pending)/
|
|
|
|
|
+ * {@code auditArticle}(approved→published / rejected→rejected+原因)/
|
|
|
|
|
+ * {@code withdraw}(published→withdrawn,即"下架")/{@code reDraft}(rejected/withdrawn→draft 清审计字段)</li>
|
|
|
|
|
+ * <li>{@code ReviewCenterController /api/admin/review-center/summary}:13 类待审聚合计数</li>
|
|
|
|
|
+ * <li>{@code ContentFilterController /api/content/by-tags}:按 resultTags + 问题域 code 过滤
|
|
|
|
|
+ * 文章/知识库/饮食</li>
|
|
|
|
|
+ * <li>权限流:{@code JwtInterceptor.LOGIN_REQUIRED_PATHS} 已收口 {@code "/api/admin/"}(B-142-1 已处置),
|
|
|
|
|
+ * {@code audit} 已有 {@code role} 角色守卫(非 admin → "无权限")——权限流由
|
|
|
|
|
+ * {@code AdminArticleAuditContractTest} 锁定,本类不重复。</li>
|
|
|
|
|
+ * </ul>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>RED 任务实证结论</b>:本键(S-cfc-208)为 TDD-RED 契约任务,但契约端点与行为在
|
|
|
|
|
+ * origin/cfclub 已全部落地(契约文档 admin-ops-contract §1.13/§1.18 亦标记「✅ 端点存在」)。
|
|
|
|
|
+ * 按任务执行要求第 2 条(实测发现实现已存在 → 测试意外全绿 → 汇报注明并交编排侧转测试登记,
|
|
|
|
|
+ * 不私改实现),本测试以「契约绿锁定」形式覆盖 SC-A-13/A-18 尚未被既有测试覆盖的行为面
|
|
|
|
|
+ * (submit-review/withdraw/re-draft 状态机、敏感词命中拦截、review-center 13 类队列聚合、
|
|
|
|
|
+ * content by-tags 空标签边界),作为该能力的契约回归基线。</p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>测试范围</b>:纯契约级单测(Mockito + 反射),无 Spring 上下文 / 无 DB / 无真实微信 /
|
|
|
|
|
+ * 无外部服务。</p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>三流划分</b></p>
|
|
|
|
|
+ * <ul>
|
|
|
|
|
+ * <li>正常流(绿锁定):审核端点家族存在;提审/撤回/重编状态机正确流转;敏感词命中拦截;
|
|
|
|
|
+ * 队列 13 类聚合;内容过滤空标签边界</li>
|
|
|
|
|
+ * <li>边界流(绿锁定):提审非草稿 / 撤回非发布中 / 重编非法状态 → 拒绝且不写库;空/空白文本
|
|
|
|
|
+ * fail-open 不触达微信检测</li>
|
|
|
|
|
+ * <li>权限流(已由既有契约锁定):/api/admin/ 整族登录门(B-142-1 已修)+ audit 角色守卫</li>
|
|
|
|
|
+ * </ul>
|
|
|
|
|
+ */
|
|
|
|
|
+class AdminContentSecurityContractTest {
|
|
|
|
|
+
|
|
|
|
|
+ private static final Long ARTICLE_ID = 200L;
|
|
|
|
|
+ private static final Long ADMIN_ID = 88L;
|
|
|
|
|
+
|
|
|
|
|
+ // ==================================================================
|
|
|
|
|
+ // 正常流:SC-A-13 审核端点家族存在(@PostMapping,结构契约)
|
|
|
|
|
+ // ==================================================================
|
|
|
|
|
+
|
|
|
|
|
+ /** 契约端点 → 应存在的映射:{endpoint 后缀 → 期望方法名} */
|
|
|
|
|
+ private static final Map<String, String> ADMIN_ARTICLE_AUDIT_ENDPOINTS = new HashMap<>();
|
|
|
|
|
+
|
|
|
|
|
+ static {
|
|
|
|
|
+ ADMIN_ARTICLE_AUDIT_ENDPOINTS.put("/list", "list");
|
|
|
|
|
+ ADMIN_ARTICLE_AUDIT_ENDPOINTS.put("/audit", "audit");
|
|
|
|
|
+ ADMIN_ARTICLE_AUDIT_ENDPOINTS.put("/submit-review", "submitReview");
|
|
|
|
|
+ ADMIN_ARTICLE_AUDIT_ENDPOINTS.put("/withdraw", "withdraw");
|
|
|
|
|
+ ADMIN_ARTICLE_AUDIT_ENDPOINTS.put("/re-draft", "reDraft");
|
|
|
|
|
+ ADMIN_ARTICLE_AUDIT_ENDPOINTS.put("/upload/image", "uploadImage");
|
|
|
|
|
+ ADMIN_ARTICLE_AUDIT_ENDPOINTS.put("/upload/video", "uploadVideo");
|
|
|
|
|
+ ADMIN_ARTICLE_AUDIT_ENDPOINTS.put("/comments/list", "commentList");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-1:AdminArticleController 应提供 SC-A-13 审核端点家族(audit/submit-review/withdraw/re-draft/upload image|video/comments/list)")
|
|
|
|
|
+ void adminArticleController_auditFamilyEndpointsExist() {
|
|
|
|
|
+ Method[] methods = AdminArticleController.class.getDeclaredMethods();
|
|
|
|
|
+ Map<String, String> missing = new HashMap<>();
|
|
|
|
|
+ for (Map.Entry<String, String> e : ADMIN_ARTICLE_AUDIT_ENDPOINTS.entrySet()) {
|
|
|
|
|
+ Method m = findPostMapping(methods, e.getKey());
|
|
|
|
|
+ if (m == null || !m.getName().equals(e.getValue())) {
|
|
|
|
|
+ missing.put(e.getKey(), e.getValue());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ assertTrue(missing.isEmpty(),
|
|
|
|
|
+ "RED[admin.content.security] AdminArticleController 缺契约端点:"
|
|
|
|
|
+ + missing + "(SC-A-13 内容安全审核:audit/submit-review/withdraw/re-draft/"
|
|
|
|
|
+ + "upload image|video/comments/list,API_REFERENCE §4.60.13)。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-2:ReviewCenterController 应提供 /api/admin/review-center/summary(SC-A-18 审核队列)")
|
|
|
|
|
+ void reviewCenterController_summaryEndpointExists() {
|
|
|
|
|
+ Method m = findPostMapping(ReviewCenterController.class.getDeclaredMethods(), "/summary");
|
|
|
|
|
+ assertNotNull(m,
|
|
|
|
|
+ "RED[admin.content.security] ReviewCenterController 缺 @PostMapping /summary"
|
|
|
|
|
+ + "(SC-A-18 内容安全审核队列:13 类待审聚合,API_REFERENCE §4.60.13)。");
|
|
|
|
|
+ assertEquals("summary", m.getName(), "summary 端点应映射到 summary 方法");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-3:ContentFilterController 应提供 /api/content/by-tags(内容安全过滤)")
|
|
|
|
|
+ void contentFilterController_byTagsEndpointExists() {
|
|
|
|
|
+ Method m = findPostMapping(ContentFilterController.class.getDeclaredMethods(), "/by-tags");
|
|
|
|
|
+ assertNotNull(m,
|
|
|
|
|
+ "RED[admin.content.security] ContentFilterController 缺 @PostMapping /by-tags"
|
|
|
|
|
+ + "(内容安全/内容过滤载体,API_REFERENCE §4.12)。");
|
|
|
|
|
+ assertEquals("byTags", m.getName(), "by-tags 端点应映射到 byTags 方法");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================================================================
|
|
|
|
|
+ // 正常流/边界流:SC-A-13 边界「敏感词命中拦截」+ fail-open 策略
|
|
|
|
|
+ // ==================================================================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-4:标题命中敏感词 → 拦截发布,不触达 articleService")
|
|
|
|
|
+ void create_标题命中敏感词_拦截发布() throws Exception {
|
|
|
|
|
+ AdminArticleController controller = articleControllerWith(false);
|
|
|
|
|
+ ArticleService articleService = (ArticleService) getField(controller, "articleService");
|
|
|
|
|
+
|
|
|
|
|
+ Result<Map<String, Object>> r = callCreate(controller, "标题含敏感词", "<p>正文</p>", "admin");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("文章标题包含敏感信息,发布失败", r.getMessage(),
|
|
|
|
|
+ "SC-A-13 边界「敏感词命中拦截」:微信 checkText 命中 → 拦截,不得发布");
|
|
|
|
|
+ verify(articleService, never()).create(any(), any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-5:内容命中敏感词 → 拦截发布(正文敏感,标题先放行)")
|
|
|
|
|
+ void create_正文命中敏感词_拦截发布() throws Exception {
|
|
|
|
|
+ AdminArticleController controller = articleControllerWith(true, false);
|
|
|
|
|
+ ArticleService articleService = (ArticleService) getField(controller, "articleService");
|
|
|
|
|
+
|
|
|
|
|
+ Result<Map<String, Object>> r = callCreate(controller, "正常标题", "<p>正文含敏感词</p>", "admin");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("文章内容包含敏感信息,发布失败", r.getMessage(),
|
|
|
|
|
+ "SC-A-13 边界「敏感词命中拦截」:标题通过检测、正文命中 → 拦截,不得发布");
|
|
|
|
|
+ verify(articleService, never()).create(any(), any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("边界流-1:空白/空文本不做敏感检测,直接放行(fail-open 不误伤正常输入)")
|
|
|
|
|
+ void checkTextSafe_空白文本_直接放行() throws Exception {
|
|
|
|
|
+ AdminArticleController controller = new AdminArticleController();
|
|
|
|
|
+ // 不注入 wechatService:空白文本分支不触达检测,若触达会 NPE → 测试红
|
|
|
|
|
+ assertTrue(invokeCheckTextSafe(controller, null),
|
|
|
|
|
+ "checkTextSafe(null) 应直接放行,不触达微信检测");
|
|
|
|
|
+ assertTrue(invokeCheckTextSafe(controller, " \t "),
|
|
|
|
|
+ "checkTextSafe(全空白) 应直接放行,不触达微信检测");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================================================================
|
|
|
|
|
+ // 正常流/边界流:ArticleService 审计状态机(SC-A-13 提审→通过/下架 闭环)
|
|
|
|
|
+ // ==================================================================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-6:提审(submitForReview)草稿 → pending + auditStatus=pending")
|
|
|
|
|
+ void submitForReview_草稿_提交审核() throws Exception {
|
|
|
|
|
+ ArticleService service = new ArticleService();
|
|
|
|
|
+ ArticleMapper articleMapper = mock(ArticleMapper.class);
|
|
|
|
|
+ Article a = article("draft", null);
|
|
|
|
|
+ when(articleMapper.selectById(ARTICLE_ID)).thenReturn(a);
|
|
|
|
|
+ injectService(service, "articleMapper", articleMapper);
|
|
|
|
|
+
|
|
|
|
|
+ service.submitForReview(ARTICLE_ID);
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("pending", a.getStatus(), "提审后 status 应置 pending");
|
|
|
|
|
+ assertEquals("pending", a.getAuditStatus(), "提审后 auditStatus 应置 pending(进入审核队列)");
|
|
|
|
|
+ verify(articleMapper).updateById(a);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("边界流-2:提审非草稿 → 抛「仅草稿状态可提交审核」,不写库")
|
|
|
|
|
+ void submitForReview_非草稿_拒绝() throws Exception {
|
|
|
|
|
+ ArticleService service = new ArticleService();
|
|
|
|
|
+ ArticleMapper articleMapper = mock(ArticleMapper.class);
|
|
|
|
|
+ Article a = article("published", "approved");
|
|
|
|
|
+ when(articleMapper.selectById(ARTICLE_ID)).thenReturn(a);
|
|
|
|
|
+ injectService(service, "articleMapper", articleMapper);
|
|
|
|
|
+
|
|
|
|
|
+ RuntimeException ex = assertThrows(RuntimeException.class,
|
|
|
|
|
+ () -> service.submitForReview(ARTICLE_ID));
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("仅草稿状态可提交审核", ex.getMessage());
|
|
|
|
|
+ verify(articleMapper, never()).updateById(any(Article.class));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-7:撤回(withdraw)发布中 → withdrawn(SC-A-13「下架」)")
|
|
|
|
|
+ void withdraw_发布中_下架() throws Exception {
|
|
|
|
|
+ ArticleService service = new ArticleService();
|
|
|
|
|
+ ArticleMapper articleMapper = mock(ArticleMapper.class);
|
|
|
|
|
+ Article a = article("published", "approved");
|
|
|
|
|
+ when(articleMapper.selectById(ARTICLE_ID)).thenReturn(a);
|
|
|
|
|
+ injectService(service, "articleMapper", articleMapper);
|
|
|
|
|
+
|
|
|
|
|
+ service.withdraw(ARTICLE_ID);
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("withdrawn", a.getStatus(), "撤回后 status 应置 withdrawn(下架)");
|
|
|
|
|
+ verify(articleMapper).updateById(a);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("边界流-3:撤回非发布中 → 抛「仅发布中的文章可撤回」,不写库")
|
|
|
|
|
+ void withdraw_非发布中_拒绝() throws Exception {
|
|
|
|
|
+ ArticleService service = new ArticleService();
|
|
|
|
|
+ ArticleMapper articleMapper = mock(ArticleMapper.class);
|
|
|
|
|
+ Article a = article("draft", null);
|
|
|
|
|
+ when(articleMapper.selectById(ARTICLE_ID)).thenReturn(a);
|
|
|
|
|
+ injectService(service, "articleMapper", articleMapper);
|
|
|
|
|
+
|
|
|
|
|
+ RuntimeException ex = assertThrows(RuntimeException.class,
|
|
|
|
|
+ () -> service.withdraw(ARTICLE_ID));
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("仅发布中的文章可撤回", ex.getMessage());
|
|
|
|
|
+ verify(articleMapper, never()).updateById(any(Article.class));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-8:驳回/撤回后重编(reDraft)→ 回草稿并清空审计字段(审核→驳回→重编闭环)")
|
|
|
|
|
+ void reDraft_驳回或撤回_重回草稿并清审计() throws Exception {
|
|
|
|
|
+ ArticleService service = new ArticleService();
|
|
|
|
|
+ ArticleMapper articleMapper = mock(ArticleMapper.class);
|
|
|
|
|
+ Article rejected = article("rejected", "rejected");
|
|
|
|
|
+ when(articleMapper.selectById(ARTICLE_ID)).thenReturn(rejected);
|
|
|
|
|
+ injectService(service, "articleMapper", articleMapper);
|
|
|
|
|
+
|
|
|
|
|
+ service.reDraft(ARTICLE_ID);
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("draft", rejected.getStatus(), "rejected → draft 可重新编辑");
|
|
|
|
|
+ assertNull(rejected.getAuditStatus(), "重编应清空 auditStatus");
|
|
|
|
|
+ assertNull(rejected.getAuditReason(), "重编应清空驳回原因");
|
|
|
|
|
+ assertNull(rejected.getAuditorId(), "重编应清空审核人");
|
|
|
|
|
+ assertNull(rejected.getAuditedAt(), "重编应清空审核时间");
|
|
|
|
|
+ verify(articleMapper).updateById(rejected);
|
|
|
|
|
+
|
|
|
|
|
+ // withdrawn → draft 同路径
|
|
|
|
|
+ ArticleService service2 = new ArticleService();
|
|
|
|
|
+ ArticleMapper mapper2 = mock(ArticleMapper.class);
|
|
|
|
|
+ Article withdrawn = article("withdrawn", "approved");
|
|
|
|
|
+ when(mapper2.selectById(ARTICLE_ID)).thenReturn(withdrawn);
|
|
|
|
|
+ injectService(service2, "articleMapper", mapper2);
|
|
|
|
|
+
|
|
|
|
|
+ service2.reDraft(ARTICLE_ID);
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("draft", withdrawn.getStatus(), "withdrawn → draft 可重新编辑");
|
|
|
|
|
+ verify(mapper2).updateById(withdrawn);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("边界流-4:重编非驳回/非撤回状态 → 抛「仅已驳回或已撤回的文章可重新编辑」,不写库")
|
|
|
|
|
+ void reDraft_非法状态_拒绝() throws Exception {
|
|
|
|
|
+ ArticleService service = new ArticleService();
|
|
|
|
|
+ ArticleMapper articleMapper = mock(ArticleMapper.class);
|
|
|
|
|
+ Article a = article("pending", "pending"); // 审核中不可直接重编
|
|
|
|
|
+ when(articleMapper.selectById(ARTICLE_ID)).thenReturn(a);
|
|
|
|
|
+ injectService(service, "articleMapper", articleMapper);
|
|
|
|
|
+
|
|
|
|
|
+ RuntimeException ex = assertThrows(RuntimeException.class,
|
|
|
|
|
+ () -> service.reDraft(ARTICLE_ID));
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("仅已驳回或已撤回的文章可重新编辑", ex.getMessage());
|
|
|
|
|
+ verify(articleMapper, never()).updateById(any(Article.class));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================================================================
|
|
|
|
|
+ // 正常流:SC-A-18 审核队列(review-center/summary 13 类待审聚合)
|
|
|
|
|
+ // ==================================================================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-9:review-center/summary 应聚合 13 类待审计数并落地各 Tab 键")
|
|
|
|
|
+ void reviewCenterSummary_十三类聚合键() throws Exception {
|
|
|
|
|
+ ReviewCenterController controller = new ReviewCenterController();
|
|
|
|
|
+ ArticleMapper articleMapper = mock(ArticleMapper.class);
|
|
|
|
|
+ when(articleMapper.selectCount(any())).thenReturn(5L);
|
|
|
|
|
+ inject(controller, "articleMapper", articleMapper);
|
|
|
|
|
+ inject(controller, "productMapper", mock(ProductMapper.class));
|
|
|
|
|
+ inject(controller, "userMapper", mock(UserMapper.class));
|
|
|
|
|
+ inject(controller, "activityMapper", mock(ActivityMapper.class));
|
|
|
|
|
+ inject(controller, "activityRegistrationMapper", mock(ActivityRegistrationMapper.class));
|
|
|
|
|
+ inject(controller, "withdrawalRequestMapper", mock(WithdrawalRequestMapper.class));
|
|
|
|
|
+ inject(controller, "taskTemplatePackageMapper", mock(TaskTemplatePackageMapper.class));
|
|
|
|
|
+ inject(controller, "butlerProfileMapper", mock(ButlerProfileMapper.class));
|
|
|
|
|
+ inject(controller, "supplyHierarchyChangeRequestMapper", mock(SupplyHierarchyChangeRequestMapper.class));
|
|
|
|
|
+ inject(controller, "pendingRefundMapper", mock(PendingRefundMapper.class));
|
|
|
|
|
+ inject(controller, "serviceRoleApplicationMapper", mock(ServiceRoleApplicationMapper.class));
|
|
|
|
|
+
|
|
|
|
|
+ Result<Map<String, Integer>> r = controller.summary();
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(200, r.getCode().intValue(), "summary 应成功返回");
|
|
|
|
|
+ Map<String, Integer> counts = r.getData();
|
|
|
|
|
+ assertNotNull(counts, "summary data 不应为空");
|
|
|
|
|
+ Set<String> keys = new HashSet<>(counts.keySet());
|
|
|
|
|
+ Set<String> expected = new HashSet<>();
|
|
|
|
|
+ for (String k : new String[]{
|
|
|
|
|
+ "articlePending", "productPending", "guidePending", "vendorPending",
|
|
|
|
|
+ "butlerPending", "packagePending", "activityPending", "activityRegistrationPending",
|
|
|
|
|
+ "withdrawPending", "refundPending", "hierarchyChangePending",
|
|
|
|
|
+ "nutritionistPending", "knowledgePending"}) {
|
|
|
|
|
+ expected.add(k);
|
|
|
|
|
+ }
|
|
|
|
|
+ assertEquals(expected, keys,
|
|
|
|
|
+ "RED[admin.content.security] review-center/summary 应聚合 SC-A-18 契约的 13 类待审键"
|
|
|
|
|
+ + "(文章/商品/规划师/服务商/管家/套餐/活动/报名/提现/退款/层级变更/营养师/知识)。");
|
|
|
|
|
+ assertEquals(5, counts.get("articlePending").intValue(),
|
|
|
|
|
+ "articlePending 应等于 articles.audit_status=pending 计数");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-10:review-center/summary 无待审数据 → 全部键存在且为 0(空队列口径)")
|
|
|
|
|
+ void reviewCenterSummary_空队列_13键全0() throws Exception {
|
|
|
|
|
+ ReviewCenterController controller = new ReviewCenterController();
|
|
|
|
|
+ // 不 stub 任何 mapper:Mockito 默认计数返回 0 → 空队列
|
|
|
|
|
+ inject(controller, "articleMapper", mock(ArticleMapper.class));
|
|
|
|
|
+ inject(controller, "productMapper", mock(ProductMapper.class));
|
|
|
|
|
+ inject(controller, "userMapper", mock(UserMapper.class));
|
|
|
|
|
+ inject(controller, "activityMapper", mock(ActivityMapper.class));
|
|
|
|
|
+ inject(controller, "activityRegistrationMapper", mock(ActivityRegistrationMapper.class));
|
|
|
|
|
+ inject(controller, "withdrawalRequestMapper", mock(WithdrawalRequestMapper.class));
|
|
|
|
|
+ inject(controller, "taskTemplatePackageMapper", mock(TaskTemplatePackageMapper.class));
|
|
|
|
|
+ inject(controller, "butlerProfileMapper", mock(ButlerProfileMapper.class));
|
|
|
|
|
+ inject(controller, "supplyHierarchyChangeRequestMapper", mock(SupplyHierarchyChangeRequestMapper.class));
|
|
|
|
|
+ inject(controller, "pendingRefundMapper", mock(PendingRefundMapper.class));
|
|
|
|
|
+ inject(controller, "serviceRoleApplicationMapper", mock(ServiceRoleApplicationMapper.class));
|
|
|
|
|
+
|
|
|
|
|
+ Result<Map<String, Integer>> r = controller.summary();
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Integer> counts = r.getData();
|
|
|
|
|
+ assertNotNull(counts, "空队列也应返回 13 键 map");
|
|
|
|
|
+ assertEquals(13, counts.size(), "空队列 13 类键应齐全(值全部为 0)");
|
|
|
|
|
+ for (Map.Entry<String, Integer> e : counts.entrySet()) {
|
|
|
|
|
+ assertEquals(0, e.getValue().intValue(), "空队列 " + e.getKey() + " 应为 0");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================================================================
|
|
|
|
|
+ // 正常流/边界流:ContentFilterController /api/content/by-tags
|
|
|
|
|
+ // ==================================================================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-11:by-tags 无标签无问题域 → 三组内容为空(不触达 DB)")
|
|
|
|
|
+ void byTags_空标签_返回空分组() {
|
|
|
|
|
+ ContentFilterController controller = new ContentFilterController();
|
|
|
|
|
+
|
|
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
|
|
+ Result<Map<String, Object>> r = controller.byTags(new HashMap<>());
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(200, r.getCode().intValue(), "空标签应成功返回(游客可访问)");
|
|
|
|
|
+ Map<String, Object> data = r.getData();
|
|
|
|
|
+ assertNotNull(data, "data 不应为空");
|
|
|
|
|
+ assertEquals(0, ((java.util.List<?>) data.get("resultTags")).size(), "resultTags 应为空集合");
|
|
|
|
|
+ assertEquals(0, ((java.util.List<?>) data.get("articles")).size(), "articles 应为空(无标签不查询)");
|
|
|
|
|
+ assertEquals(0, ((java.util.List<?>) data.get("knowledgeBases")).size(), "knowledgeBases 应为空");
|
|
|
|
|
+ assertEquals(0, ((java.util.List<?>) data.get("foods")).size(), "foods 应为空");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================================================================
|
|
|
|
|
+ // 反射辅助
|
|
|
|
|
+ // ==================================================================
|
|
|
|
|
+
|
|
|
|
|
+ private Method findPostMapping(Method[] methods, String path) {
|
|
|
|
|
+ for (Method m : methods) {
|
|
|
|
|
+ Annotation ann = m.getAnnotation(PostMapping.class);
|
|
|
|
|
+ if (ann == null) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (String value : ((PostMapping) ann).value()) {
|
|
|
|
|
+ if (path.equals(value)) {
|
|
|
|
|
+ return m;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 构造 AdminArticleController,敏感检测按调用序返回(true=放行 / false=命中拦截)。 */
|
|
|
|
|
+ private AdminArticleController articleControllerWith(boolean... safeResults) throws Exception {
|
|
|
|
|
+ AdminArticleController controller = new AdminArticleController();
|
|
|
|
|
+ inject(controller, "articleService", mock(ArticleService.class));
|
|
|
|
|
+ inject(controller, "wechatService", mock(WechatServiceInterface.class));
|
|
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
|
|
+ Future<Boolean> future = mock(Future.class);
|
|
|
|
|
+ if (safeResults.length == 1) {
|
|
|
|
|
+ when(future.get(anyLong(), any(TimeUnit.class))).thenReturn(safeResults[0]);
|
|
|
|
|
+ } else if (safeResults.length > 1) {
|
|
|
|
|
+ Boolean[] boxed = new Boolean[safeResults.length];
|
|
|
|
|
+ for (int i = 0; i < safeResults.length; i++) {
|
|
|
|
|
+ boxed[i] = safeResults[i];
|
|
|
|
|
+ }
|
|
|
|
|
+ when(future.get(anyLong(), any(TimeUnit.class)))
|
|
|
|
|
+ .thenReturn(boxed[0], java.util.Arrays.copyOfRange(boxed, 1, boxed.length));
|
|
|
|
|
+ }
|
|
|
|
|
+ ThreadPoolTaskExecutor taskExecutor = mock(ThreadPoolTaskExecutor.class);
|
|
|
|
|
+ when(taskExecutor.submit(any(Callable.class))).thenReturn(future);
|
|
|
|
|
+ inject(controller, "taskExecutor", taskExecutor);
|
|
|
|
|
+ return controller;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 反射调用 create(Map, adminId, role);返回 Result(contract 兼容任意签名变体)。 */
|
|
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
|
|
+ private Result<Map<String, Object>> callCreate(AdminArticleController controller,
|
|
|
|
|
+ String title, String content, String role) throws Exception {
|
|
|
|
|
+ Map<String, Object> body = new HashMap<>();
|
|
|
|
|
+ body.put("title", title);
|
|
|
|
|
+ body.put("content", content);
|
|
|
|
|
+ for (Method m : AdminArticleController.class.getDeclaredMethods()) {
|
|
|
|
|
+ if (m.getName().equals("create")) {
|
|
|
|
|
+ m.setAccessible(true);
|
|
|
|
|
+ if (m.getParameterCount() == 3) {
|
|
|
|
|
+ return (Result<Map<String, Object>>) m.invoke(controller, body, ADMIN_ID, role);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (m.getParameterCount() == 2) {
|
|
|
|
|
+ return (Result<Map<String, Object>>) m.invoke(controller, body, ADMIN_ID);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ throw new IllegalStateException("契约方法 create 不存在");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private boolean invokeCheckTextSafe(AdminArticleController controller, String text) throws Exception {
|
|
|
|
|
+ Method m = AdminArticleController.class.getDeclaredMethod("checkTextSafe", String.class);
|
|
|
|
|
+ m.setAccessible(true);
|
|
|
|
|
+ Object r = m.invoke(controller, text);
|
|
|
|
|
+ return Boolean.TRUE.equals(r);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private Article article(String status, String auditStatus) {
|
|
|
|
|
+ Article a = new Article();
|
|
|
|
|
+ a.setId(ARTICLE_ID);
|
|
|
|
|
+ a.setStatus(status);
|
|
|
|
|
+ a.setAuditStatus(auditStatus);
|
|
|
|
|
+ a.setTitle("契约测试文章");
|
|
|
|
|
+ a.setContent("<p>正文</p>");
|
|
|
|
|
+ if ("rejected".equals(status)) {
|
|
|
|
|
+ a.setAuditReason("内容涉嫌夸大");
|
|
|
|
|
+ a.setAuditorId(7L);
|
|
|
|
|
+ a.setAuditedAt(new java.util.Date());
|
|
|
|
|
+ }
|
|
|
|
|
+ return a;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void inject(Object target, String field, Object value) throws Exception {
|
|
|
|
|
+ Field f = target.getClass().getDeclaredField(field);
|
|
|
|
|
+ f.setAccessible(true);
|
|
|
|
|
+ f.set(target, value);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private Object getField(Object target, String field) throws Exception {
|
|
|
|
|
+ Field f = target.getClass().getDeclaredField(field);
|
|
|
|
|
+ f.setAccessible(true);
|
|
|
|
|
+ return f.get(target);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void injectService(ArticleService service, String field, Object value) throws Exception {
|
|
|
|
|
+ inject(service, field, value);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|