|
@@ -0,0 +1,376 @@
|
|
|
|
|
+package com.etotem.cfc.controller.admin;
|
|
|
|
|
+
|
|
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
|
|
+import com.etotem.cfc.config.JwtInterceptor;
|
|
|
|
|
+import com.etotem.cfc.entity.Article;
|
|
|
|
|
+import com.etotem.cfc.mapper.ArticleMapper;
|
|
|
|
|
+import com.etotem.cfc.service.ArticleService;
|
|
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
|
|
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
|
|
|
|
+
|
|
|
|
|
+import javax.servlet.http.HttpServletRequest;
|
|
|
|
|
+import javax.servlet.http.HttpServletResponse;
|
|
|
|
|
+import java.io.PrintWriter;
|
|
|
|
|
+import java.io.StringWriter;
|
|
|
|
|
+import java.lang.reflect.Field;
|
|
|
|
|
+import java.lang.reflect.Method;
|
|
|
|
|
+import java.util.HashMap;
|
|
|
|
|
+import java.util.Map;
|
|
|
|
|
+
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
|
|
|
+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.junit.jupiter.api.Assertions.fail;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.anyLong;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.eq;
|
|
|
|
|
+import static org.mockito.Mockito.doThrow;
|
|
|
|
|
+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.article.audit},
|
|
|
|
|
+ * {@code POST /api/admin/articles/audit})。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>溯源</b></p>
|
|
|
|
|
+ * <ul>
|
|
|
|
|
+ * <li>需求:{@code docs/tdd/SRS.md}「三、未覆盖控制器」——
|
|
|
|
|
+ * {@code AdminArticleController}(22 方法,0 覆盖),{@code audit} 位于未覆盖方法清单</li>
|
|
|
|
|
+ * <li>设计:{@code AdminArticleController#audit}({@code @PostMapping("/audit")},类级
|
|
|
|
|
+ * {@code /api/admin/articles})+ {@code ArticleService#auditArticle(articleId, auditStatus,
|
|
|
|
|
+ * auditReason, auditorId)} 审核状态机(pending→approved→published / rejected)</li>
|
|
|
|
|
+ * <li>契约:{@code docs/superpowers/api/API_REFERENCE.md} §4.60.4(SC-A-03 文章发布-审核流,
|
|
|
|
|
+ * 含 {@code /audit})+ §4.60.13(SC-A-13 内容安全审核:
|
|
|
|
|
+ * {@code POST /api/admin/articles/audit} 文章审核动作);权限流按契约核对结论
|
|
|
|
|
+ * (admin-ops-contract §5)TR-13:「补 admin 端点 MockMvc 契约 JUnit +
|
|
|
|
|
+ * <b>非 admin token → 403 权限流</b>」</li>
|
|
|
|
|
+ * </ul>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>实现现状(origin/cfclub @ 0b7ba4ecb 实测)</b></p>
|
|
|
|
|
+ * <ul>
|
|
|
|
|
+ * <li>正常流/边界流已实现:audit 解析 {@code id / auditStatus / auditReason} → 参数门禁
|
|
|
|
|
+ * (auditStatus 仅 {@code approved / rejected},拒审必填原因)→ 委托
|
|
|
|
|
+ * {@code articleService.auditArticle} → {@code Result.success("审核完成")};
|
|
|
|
|
+ * service 层状态机(文章不存在 / 非 pending 拒绝;approved→published、rejected→rejected)
|
|
|
|
|
+ * 亦已落地。</li>
|
|
|
|
|
+ * <li><b>权限流未落地(本测试的红)</b>:{@link JwtInterceptor#LOGIN_REQUIRED_PATHS} 未收口
|
|
|
|
|
+ * {@code /api/admin/} 前缀(仅 {@code /api/admin-auth/*} 登录端点走 PUBLIC_PATHS),匿名
|
|
|
|
|
+ * (userId=-1, role=anonymous)被放行至 controller;{@code audit} 亦无
|
|
|
|
|
+ * {@code @RequestAttribute("role")} 角色守卫(参照同控制器 {@code /move} 的「无权限」
|
|
|
|
|
+ * 守卫惯例)。对应缺陷台账 <b>B-142-1</b>(P1 安全:{@code /api/admin/**} 整族鉴权门缺失,
|
|
|
|
|
+ * R152~R154 延续未修;S-cfc-143 admin.article.create 同批已锁定该缺口,
|
|
|
|
|
+ * {@code AdminArticleCreateContractTest} 权限流 3 红)。</li>
|
|
|
|
|
+ * </ul>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>三流划分</b></p>
|
|
|
|
|
+ * <ul>
|
|
|
|
|
+ * <li>正常流(已实现 → 绿锁定契约):approved/rejected 原样委托 service 并返回「审核完成」;</li>
|
|
|
|
|
+ * <li>边界流(已实现 → 绿锁定契约):非法审核状态 / 驳回缺原因 → 参数门禁错误,不触达 service;
|
|
|
|
|
+ * service RuntimeException 透传 error message;</li>
|
|
|
|
|
+ * <li>权限流(契约缺口 → <b>预期红</b>):匿名请求应 401;{@code LOGIN_REQUIRED_PATHS} 应覆盖
|
|
|
|
|
+ * {@code /api/admin/};非 admin 角色应被拒绝且不执行审核。</li>
|
|
|
|
|
+ * </ul>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>RED 预期</b>:权限流 3 例失败(红),断言原文含「RED[admin.article.audit]」,失败原因
|
|
|
|
|
+ * 可贴(匿名放行 / LOGIN_REQUIRED_PATHS 缺 /api/admin/ / audit 无角色守卫)。</p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>GREEN 升级指引</b>:按 B-142-1 处置——① {@code JwtInterceptor.LOGIN_REQUIRED_PATHS} 追加
|
|
|
|
|
+ * {@code "/api/admin/"}(保留 {@code /api/admin-auth/*} 例外在 PUBLIC_PATHS),匿名 → 401;
|
|
|
|
|
+ * ② {@code audit} 增加 {@code @RequestAttribute("role") String role} 参数并在入口拒绝非 admin
|
|
|
|
|
+ * ({@code !"admin".equals(role) → Result.error("无权限")},与 {@code /move} 守卫一致)。
|
|
|
|
|
+ * 实现后本测试权限流 3 例自动转绿。</p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>测试范围</b>:纯契约级单测(Mockito + 反射),无 Spring 上下文 / 无 DB / 无真实微信 /
|
|
|
|
|
+ * 无外部服务;{@code JwtInterceptor} 直接 new(匿名分支不触达其 @Resource 字段)。</p>
|
|
|
|
|
+ */
|
|
|
|
|
+class AdminArticleAuditContractTest {
|
|
|
|
|
+
|
|
|
|
|
+ /** 契约端点:管理端文章审核 */
|
|
|
|
|
+ private static final String ENDPOINT = "/api/admin/articles/audit";
|
|
|
|
|
+
|
|
|
|
|
+ private static final Long ARTICLE_ID = 100L;
|
|
|
|
|
+ private static final Long AUDITOR_ID = 88L;
|
|
|
|
|
+
|
|
|
|
|
+ private AdminArticleController controller;
|
|
|
|
|
+ private ArticleService articleService;
|
|
|
|
|
+
|
|
|
|
|
+ @BeforeEach
|
|
|
|
|
+ void setUp() throws Exception {
|
|
|
|
|
+ controller = new AdminArticleController();
|
|
|
|
|
+ articleService = mock(ArticleService.class);
|
|
|
|
|
+ setField("articleService", articleService);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 正常流:参数门禁通过后原样委托 service,返回「审核完成」 ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-1:审核通过(approved)→ 委托 auditArticle(id, approved, null, adminId),返回审核完成")
|
|
|
|
|
+ void audit_审核通过_委托service并返回审核完成() throws Exception {
|
|
|
|
|
+ Result<String> r = callAudit(validBody("approved", null), AUDITOR_ID, "admin");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(200, r.getCode());
|
|
|
|
|
+ assertEquals("审核完成", r.getData());
|
|
|
|
|
+ verify(articleService).auditArticle(ARTICLE_ID, "approved", null, AUDITOR_ID);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-2:审核驳回(rejected,含原因)→ 原因原样转发 service,返回审核完成")
|
|
|
|
|
+ void audit_审核驳回_原因原样转发service() throws Exception {
|
|
|
|
|
+ Result<String> r = callAudit(validBody("rejected", "内容涉嫌夸大"), AUDITOR_ID, "admin");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(200, r.getCode());
|
|
|
|
|
+ assertEquals("审核完成", r.getData());
|
|
|
|
|
+ verify(articleService).auditArticle(ARTICLE_ID, "rejected", "内容涉嫌夸大", AUDITOR_ID);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 边界流:参数门禁 / service 异常透传 ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("边界流-1:非法审核状态(非 approved/rejected)→ 审核状态不正确,不触达 service")
|
|
|
|
|
+ void audit_非法审核状态_返回审核状态不正确() throws Exception {
|
|
|
|
|
+ for (String bad : new String[]{"unknown", "pending", "APPROVED", ""}) {
|
|
|
|
|
+ Result<String> r = callAudit(validBody(bad, null), AUDITOR_ID, "admin");
|
|
|
|
|
+ assertEquals(500, r.getCode());
|
|
|
|
|
+ assertEquals("审核状态不正确", r.getMessage(), "auditStatus=" + bad + " 应被拒");
|
|
|
|
|
+ }
|
|
|
|
|
+ verify(articleService, never()).auditArticle(any(), any(), any(), any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("边界流-2:驳回但原因缺失/全空白 → 驳回时必须填写原因,不触达 service")
|
|
|
|
|
+ void audit_驳回缺原因_返回驳回必须填写原因() throws Exception {
|
|
|
|
|
+ // 原因缺失
|
|
|
|
|
+ Result<String> noReason = callAudit(validBody("rejected", null), AUDITOR_ID, "admin");
|
|
|
|
|
+ assertEquals(500, noReason.getCode());
|
|
|
|
|
+ assertEquals("驳回时必须填写原因", noReason.getMessage());
|
|
|
|
|
+ // 原因全空白(trim 后为空)
|
|
|
|
|
+ Result<String> blankReason = callAudit(validBody("rejected", " \t "), AUDITOR_ID, "admin");
|
|
|
|
|
+ assertEquals(500, blankReason.getCode());
|
|
|
|
|
+ assertEquals("驳回时必须填写原因", blankReason.getMessage());
|
|
|
|
|
+ verify(articleService, never()).auditArticle(any(), any(), any(), any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("边界流-3:service 运行时异常 → Result.error 透传 message(如「仅待审核状态可审核」)")
|
|
|
|
|
+ void audit_service运行时异常_返回error透传message() throws Exception {
|
|
|
|
|
+ doThrow(new RuntimeException("仅待审核状态可审核"))
|
|
|
|
|
+ .when(articleService)
|
|
|
|
|
+ .auditArticle(eq(ARTICLE_ID), eq("rejected"), eq("原因"), eq(AUDITOR_ID));
|
|
|
|
|
+
|
|
|
|
|
+ Result<String> r = callAudit(validBody("rejected", "原因"), AUDITOR_ID, "admin");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(500, r.getCode());
|
|
|
|
|
+ assertEquals("仅待审核状态可审核", r.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 权限流(契约缺口,预期红,B-142-1 / TR-13) ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("权限流-1:匿名(无 token)POST /api/admin/articles/audit 应被 JwtInterceptor 401 拒绝")
|
|
|
|
|
+ void audit_匿名请求_应401拒绝() throws Exception {
|
|
|
|
|
+ HttpServletRequest request = mock(HttpServletRequest.class);
|
|
|
|
|
+ HttpServletResponse response = mock(HttpServletResponse.class);
|
|
|
|
|
+ when(request.getMethod()).thenReturn("POST");
|
|
|
|
|
+ when(request.getRequestURI()).thenReturn(ENDPOINT);
|
|
|
|
|
+ when(request.getHeader("Authorization")).thenReturn(null);
|
|
|
|
|
+ when(request.getParameter("token")).thenReturn(null);
|
|
|
|
|
+ when(response.getWriter()).thenReturn(new PrintWriter(new StringWriter()));
|
|
|
|
|
+
|
|
|
|
|
+ JwtInterceptor interceptor = new JwtInterceptor(); // @Resource 字段留空:匿名分支不触达 DB
|
|
|
|
|
+
|
|
|
|
|
+ boolean allowed = interceptor.preHandle(request, response, null);
|
|
|
|
|
+
|
|
|
|
|
+ assertFalse(allowed,
|
|
|
|
|
+ "RED[admin.article.audit] 匿名(无 token)POST " + ENDPOINT
|
|
|
|
|
+ + " 应被 JwtInterceptor 拒绝(401);当前 LOGIN_REQUIRED_PATHS 未收口 /api/admin/,"
|
|
|
|
|
+ + "匿名被放行至 controller,且 audit 无角色守卫,匿名(userId=-1, role=anonymous)"
|
|
|
|
|
+ + "可直接审核文章(B-142-1 P1:/api/admin/** 整族鉴权门缺失,TR-13 权限流要求匿名→401/403)。");
|
|
|
|
|
+ verify(response).setStatus(401);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("权限流-2:JwtInterceptor.LOGIN_REQUIRED_PATHS 应覆盖 /api/admin/ 前缀")
|
|
|
|
|
+ void loginRequiredPaths_应覆盖apiAdmin前缀() throws Exception {
|
|
|
|
|
+ Field f = JwtInterceptor.class.getDeclaredField("LOGIN_REQUIRED_PATHS");
|
|
|
|
|
+ f.setAccessible(true);
|
|
|
|
|
+ String[] paths = (String[]) f.get(null);
|
|
|
|
|
+
|
|
|
|
|
+ boolean covered = false;
|
|
|
|
|
+ for (String p : paths) {
|
|
|
|
|
+ if (ENDPOINT.startsWith(p)) {
|
|
|
|
|
+ covered = true;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ assertTrue(covered,
|
|
|
|
|
+ "RED[admin.article.audit] JwtInterceptor.LOGIN_REQUIRED_PATHS 应含 /api/admin/ 前缀条目,"
|
|
|
|
|
+ + "使匿名 POST " + ENDPOINT + " → 401;当前仅收口 /api/admin-auth/* 登录端点"
|
|
|
|
|
+ + "且它们走 PUBLIC_PATHS 例外,admin 业务族整体缺失(B-142-1-Update,R152~R154 延续未修)。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("权限流-3:audit 应按项目 admin 守卫惯例声明 role 参数并拒绝非 admin(不执行审核)")
|
|
|
|
|
+ void audit_非admin角色_应拒绝且不审核() throws Exception {
|
|
|
|
|
+ Method m = findAuditWithRole();
|
|
|
|
|
+ assertNotNull(m,
|
|
|
|
|
+ "RED[admin.article.audit] 契约方法 audit 应声明 role 参数"
|
|
|
|
|
+ + "(@RequestAttribute(\"role\") String role),按项目 admin 守卫惯例拒绝非 admin"
|
|
|
|
|
+ + "(参照 AdminArticleController#move 的「无权限」守卫);"
|
|
|
|
|
+ + "当前仅 audit(Map, Long) 两参数、无角色校验,登录 C 端用户(parent/child)"
|
|
|
|
|
+ + "即可越权审核文章(B-142-1,TR-13 权限流)。");
|
|
|
|
|
+ m.setAccessible(true);
|
|
|
|
|
+
|
|
|
|
|
+ Result<?> r = (Result<?>) m.invoke(controller, validBody("approved", null), AUDITOR_ID, "parent");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("无权限", r.getMessage());
|
|
|
|
|
+ verify(articleService, never()).auditArticle(any(), any(), any(), any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== Service 状态机(auditArticle 契约,纯 Mockito) ====================
|
|
|
|
|
+
|
|
|
|
|
+ private ArticleService service;
|
|
|
|
|
+ private ArticleMapper articleMapper;
|
|
|
|
|
+ private ThreadPoolTaskExecutor taskExecutor;
|
|
|
|
|
+
|
|
|
|
|
+ @BeforeEach
|
|
|
|
|
+ void setUpService() throws Exception {
|
|
|
|
|
+ service = new ArticleService();
|
|
|
|
|
+ articleMapper = mock(ArticleMapper.class);
|
|
|
|
|
+ taskExecutor = mock(ThreadPoolTaskExecutor.class);
|
|
|
|
|
+ injectIntoService("articleMapper", articleMapper);
|
|
|
|
|
+ injectIntoService("taskExecutor", taskExecutor);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void injectIntoService(String field, Object value) throws Exception {
|
|
|
|
|
+ Field f = ArticleService.class.getDeclaredField(field);
|
|
|
|
|
+ f.setAccessible(true);
|
|
|
|
|
+ f.set(service, value);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private Article pendingArticle() {
|
|
|
|
|
+ Article a = new Article();
|
|
|
|
|
+ a.setId(ARTICLE_ID);
|
|
|
|
|
+ a.setStatus("draft");
|
|
|
|
|
+ a.setAuditStatus("pending");
|
|
|
|
|
+ a.setTitle("契约测试文章");
|
|
|
|
|
+ a.setContent("<p>正文</p>");
|
|
|
|
|
+ return a;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("service-1:审核通过 → status=published、清空驳回原因、记录审核人/时间")
|
|
|
|
|
+ void service_审核通过_置published并清空驳回原因() {
|
|
|
|
|
+ Article a = pendingArticle();
|
|
|
|
|
+ when(articleMapper.selectById(ARTICLE_ID)).thenReturn(a);
|
|
|
|
|
+
|
|
|
|
|
+ service.auditArticle(ARTICLE_ID, "approved", null, AUDITOR_ID);
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("published", a.getStatus(), "approved → status 应置 published");
|
|
|
|
|
+ assertEquals("approved", a.getAuditStatus(), "approved → auditStatus 应置 approved");
|
|
|
|
|
+ assertNull(a.getAuditReason(), "approved → 驳回原因应清空");
|
|
|
|
|
+ assertNotNull(a.getPublishedAt(), "approved → 应记录发布/通过时间");
|
|
|
|
|
+ assertNotNull(a.getAuditedAt(), "approved → 应记录审核时间");
|
|
|
|
|
+ assertEquals(AUDITOR_ID, a.getAuditorId(), "approved → 应记录审核人 ID");
|
|
|
|
|
+ verify(articleMapper).updateById(a);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("service-2:审核驳回 → status=rejected、保留驳回原因、记录审核人/时间")
|
|
|
|
|
+ void service_审核拒绝_置rejected并保留驳回原因() {
|
|
|
|
|
+ Article a = pendingArticle();
|
|
|
|
|
+ when(articleMapper.selectById(ARTICLE_ID)).thenReturn(a);
|
|
|
|
|
+
|
|
|
|
|
+ service.auditArticle(ARTICLE_ID, "rejected", "内容涉嫌夸大", AUDITOR_ID);
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("rejected", a.getStatus(), "rejected → status 应置 rejected");
|
|
|
|
|
+ assertEquals("rejected", a.getAuditStatus(), "rejected → auditStatus 应置 rejected");
|
|
|
|
|
+ assertEquals("内容涉嫌夸大", a.getAuditReason(), "rejected → 驳回原因应保留");
|
|
|
|
|
+ assertNull(a.getPublishedAt(), "rejected → 不应有发布/通过时间");
|
|
|
|
|
+ assertNotNull(a.getAuditedAt(), "rejected → 应记录审核时间");
|
|
|
|
|
+ assertEquals(AUDITOR_ID, a.getAuditorId(), "rejected → 应记录审核人 ID");
|
|
|
|
|
+ verify(articleMapper).updateById(a);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("service-3:文章不存在 → 抛「文章不存在」,不写库")
|
|
|
|
|
+ void service_文章不存在_抛文章不存在_不写库() {
|
|
|
|
|
+ when(articleMapper.selectById(ARTICLE_ID)).thenReturn(null);
|
|
|
|
|
+
|
|
|
|
|
+ RuntimeException ex = assertThrows(RuntimeException.class,
|
|
|
|
|
+ () -> service.auditArticle(ARTICLE_ID, "approved", null, AUDITOR_ID));
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("文章不存在", ex.getMessage());
|
|
|
|
|
+ verify(articleMapper, never()).updateById(any(Article.class));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("service-4:非 pending 审核态 → 抛「仅待审核状态可审核」,不写库")
|
|
|
|
|
+ void service_非pending审核态_抛仅待审核状态可审核_不写库() {
|
|
|
|
|
+ Article a = pendingArticle();
|
|
|
|
|
+ a.setAuditStatus("approved"); // 已审核过的文章不允许重复审核
|
|
|
|
|
+ when(articleMapper.selectById(ARTICLE_ID)).thenReturn(a);
|
|
|
|
|
+
|
|
|
|
|
+ RuntimeException ex = assertThrows(RuntimeException.class,
|
|
|
|
|
+ () -> service.auditArticle(ARTICLE_ID, "approved", null, AUDITOR_ID));
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("仅待审核状态可审核", ex.getMessage());
|
|
|
|
|
+ verify(articleMapper, never()).updateById(any(Article.class));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 辅助 ====================
|
|
|
|
|
+
|
|
|
|
|
+ private Map<String, Object> validBody(String auditStatus, String auditReason) {
|
|
|
|
|
+ Map<String, Object> body = new HashMap<>();
|
|
|
|
|
+ body.put("id", ARTICLE_ID);
|
|
|
|
|
+ body.put("auditStatus", auditStatus);
|
|
|
|
|
+ if (auditReason != null) {
|
|
|
|
|
+ body.put("auditReason", auditReason);
|
|
|
|
|
+ }
|
|
|
|
|
+ return body;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void setField(String name, Object value) throws Exception {
|
|
|
|
|
+ Field f = AdminArticleController.class.getDeclaredField(name);
|
|
|
|
|
+ f.setAccessible(true);
|
|
|
|
|
+ f.set(controller, value);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 反射派发 audit:兼容当前 2 参数签名(RED 现状)与契约 3 参数签名(GREEN 后
|
|
|
|
|
+ * audit(Map, Long, String role)),避免 GREEN 阶段改签名导致本测试编译失败。
|
|
|
|
|
+ */
|
|
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
|
|
+ private Result<String> callAudit(Map<String, Object> body, Long adminId, String role)
|
|
|
|
|
+ throws Exception {
|
|
|
|
|
+ for (Method m : AdminArticleController.class.getDeclaredMethods()) {
|
|
|
|
|
+ if (m.getName().equals("audit")) {
|
|
|
|
|
+ m.setAccessible(true);
|
|
|
|
|
+ if (m.getParameterCount() == 3) {
|
|
|
|
|
+ return (Result<String>) m.invoke(controller, body, adminId, role);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (m.getParameterCount() == 2) {
|
|
|
|
|
+ return (Result<String>) m.invoke(controller, body, adminId);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ fail("契约方法 audit 不存在");
|
|
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 契约签名:audit(Map body, Long adminId, String role)——role 承载 admin 角色守卫。 */
|
|
|
|
|
+ private Method findAuditWithRole() {
|
|
|
|
|
+ for (Method m : AdminArticleController.class.getDeclaredMethods()) {
|
|
|
|
|
+ if (m.getName().equals("audit") && m.getParameterCount() == 3) {
|
|
|
|
|
+ return m;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|