Browse Source

test(tdd): S-cfc-142 admin.article.list 契约级单测 —— 实现已落地(origin/cfclub 已含 list/getAdminList),6 契约用例全绿,转测试登记

Sisyphus Agent 1 day ago
parent
commit
fcd9117bff

+ 229 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/admin/AdminArticleListContractTest.java

@@ -0,0 +1,229 @@
+package com.etotem.cfc.controller.admin;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Article;
+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.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Collections;
+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.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:后台文章列表(能力键 {@code admin.article.list},S-cfc-142)。
+ *
+ * <p><b>契约锚点</b></p>
+ * <ul>
+ *   <li>需求:{@code docs/tdd/SRS.md}「三、未覆盖控制器」{@code AdminArticleController}
+ *       (/api/admin/articles,22 方法);</li>
+ *   <li>设计:{@code AdminArticleController#list(Map)}({@code @PostMapping("/list")});
+ *       {@code ArticleService#getAdminList(...)}(11 参:status/categoryId/keyword/contentType/
+ *       difficultyLevel/dimensionCode/tagId/auditStatus/page/size/sortSpecs);</li>
+ *   <li>契约:{@code docs/superpowers/api/API_REFERENCE.md} §4.60.4
+ *       {@code POST /api/admin/articles/list}(文章 CRUD + 排序,SC-A-03);</li>
+ *   <li>调用方:{@code cfc-web/src/api/article.js#adminArticleList} +
+ *       {@code views/admin/ArticleManage.vue#loadList}(page/size/status/categoryId/
+ *       contentType/difficultyLevel/dimensionCode/tagId/keyword/sort)、
+ *       {@code views/admin/review/ArticleAudit.vue}(status/auditStatus/keyword/sort)。</li>
+ * </ul>
+ *
+ * <p><b>被校验的契约行为</b></p>
+ * <ul>
+ *   <li>未传 {@code page}/{@code size} 时默认 1/20;</li>
+ *   <li>8 个过滤参数(status/categoryId/keyword/contentType/difficultyLevel/dimensionCode/
+ *       tagId/auditStatus)解析后透传 {@code ArticleService.getAdminList};</li>
+ *   <li>{@code sort} 排序规格(长列表 {@code [{field, order}]})透传;</li>
+ *   <li>返回 {@code Result.success(Page<Article>)} 信封(HTTP 200 信封 + 业务码 200)。</li>
+ * </ul>
+ *
+ * <p><b>测试隔离</b>:{@code new AdminArticleController()} + 反射注入 mock
+ * {@code ArticleService},直接调 {@code list(Map)},无 Spring 上下文 / 无 DB / 无外部服务。</p>
+ *
+ * <p><b>RED 预期说明</b>:本键为契约新增能力。实测 worktree 基底
+ * {@code origin/cfclub} 上 {@code AdminArticleController#list} 与
+ * {@code ArticleService#getAdminList} 已存在(实现已落地),因此本契约测试首次运行
+ * 预期即绿(既成行为的回归契约);若后续实现对上述契约行为(默认分页/过滤透传/排序透传/
+ * 响应信封)发生回归,本类将转红。按编排约定:实现已存在 → 转测试登记,不私改 src/main。</p>
+ */
+class AdminArticleListContractTest {
+
+    private static final String ARTICLES_BASE_PATH = "/api/admin/articles";
+
+    private AdminArticleController controller;
+    private ArticleService articleService;
+
+    /** 每次调用记录 getAdminList 的实际入参,供断言 */
+    private final List<Object[]> calls = new ArrayList<>();
+
+    @BeforeEach
+    void setUp() throws Exception {
+        controller = new AdminArticleController();
+        articleService = mock(ArticleService.class);
+        inject(controller, "articleService", articleService);
+        calls.clear();
+    }
+
+    // ==================== 正常流:默认分页 / 过滤透传 ====================
+
+    @Test
+    @DisplayName("list:空 body → 默认 page=1 size=20,全部过滤参数为 null")
+    void list_空body_默认分页与空过滤() {
+        Object[] args = callList(new HashMap<>());
+
+        assertEquals(1, args[8], "page 缺省默认 1");
+        assertEquals(20, args[9], "size 缺省默认 20");
+        assertEquals(null, args[0], "status 未传为 null");
+        assertEquals(null, args[1], "categoryId 未传为 null");
+        assertEquals(null, args[2], "keyword 未传为 null");
+        assertEquals(null, args[3], "contentType 未传为 null");
+        assertEquals(null, args[4], "difficultyLevel 未传为 null");
+        assertEquals(null, args[5], "dimensionCode 未传为 null");
+        assertEquals(null, args[6], "tagId 未传为 null");
+        assertEquals(null, args[7], "auditStatus 未传为 null");
+        assertEquals(null, args[10], "sort 未传为 null");
+    }
+
+    @Test
+    @DisplayName("list:全量过滤参数 + 显式分页 + sort → 解析后原样透传 getAdminList")
+    void list_全量过滤参数_透传解析() {
+        List<Map<String, String>> sortSpecs = Collections.singletonList(
+                Collections.singletonMap("field", "createdAt"));
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("status", "published");
+        body.put("categoryId", 5);
+        body.put("keyword", "亲子");
+        body.put("contentType", "article");
+        body.put("difficultyLevel", 3);
+        body.put("dimensionCode", "mind");
+        body.put("tagId", 7);
+        body.put("auditStatus", "approved");
+        body.put("page", 2);
+        body.put("size", 10);
+        body.put("sort", sortSpecs);
+
+        Object[] args = callList(body);
+
+        assertEquals("published", args[0], "status 应透传");
+        assertEquals(5L, args[1], "categoryId 应解析为 Long");
+        assertEquals("亲子", args[2], "keyword 应透传");
+        assertEquals("article", args[3], "contentType 应透传");
+        assertEquals(3, args[4], "difficultyLevel 应解析为 Integer");
+        assertEquals("mind", args[5], "dimensionCode 应透传");
+        assertEquals(7L, args[6], "tagId 应解析为 Long");
+        assertEquals("approved", args[7], "auditStatus 应透传");
+        assertEquals(2, args[8], "page 应透传");
+        assertEquals(10, args[9], "size 应透传");
+        assertEquals(sortSpecs, args[10], "sort 排序规格应原样透传");
+    }
+
+    @Test
+    @DisplayName("list:数字字符串参数(前端常见传法)→ 正常解析为 Long/Integer")
+    void list_数字字符串参数_正常解析() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("categoryId", "5");
+        body.put("difficultyLevel", "3");
+        body.put("tagId", "7");
+        body.put("page", "2");
+        body.put("size", "10");
+
+        Object[] args = callList(body);
+
+        assertEquals(5L, args[1], "字符串 categoryId 应解析为 Long");
+        assertEquals(3, args[4], "字符串 difficultyLevel 应解析为 Integer");
+        assertEquals(7L, args[6], "字符串 tagId 应解析为 Long");
+        assertEquals(2, args[8], "字符串 page 应解析为 int");
+        assertEquals(10, args[9], "字符串 size 应解析为 int");
+    }
+
+    // ==================== 边界流:非法数字参数 ====================
+
+    @Test
+    @DisplayName("list:非数字 categoryId/tagId → 解析为 null(ParamUtils 语义),不抛异常")
+    void list_非数字过滤参数_安全解析为null() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("categoryId", "abc");
+        body.put("tagId", "");
+
+        Object[] args = callList(body);
+
+        assertEquals(null, args[1], "非数字 categoryId 应安全解析为 null");
+        assertEquals(null, args[6], "空字符串 tagId 应安全解析为 null");
+    }
+
+    // ==================== 响应信封 ====================
+
+    @Test
+    @DisplayName("list:成功 → Result.success 包装 Page<Article>(业务码 200)")
+    void list_成功_返回success信封() {
+        Page<Article> page = new Page<>(1, 20);
+        page.setTotal(42);
+        when(articleService.getAdminList(any(), any(), any(), any(), any(), any(), any(), any(),
+                anyInt(), anyInt(), any())).thenReturn(page);
+
+        Result<Page<Article>> result = controller.list(new HashMap<>());
+
+        assertNotNull(result, "list 必须返回 Result 信封");
+        assertEquals(200, result.getCode(), "成功业务码应为 200");
+        assertEquals(page, result.getData(), "data 应为 getAdminList 返回的 Page");
+    }
+
+    // ==================== 契约结构:路由映射 ====================
+
+    @Test
+    @DisplayName("契约:AdminArticleController 类级映射 /api/admin/articles + list 方法级 /list")
+    void 契约_路由映射_POST_api_admin_articles_list() throws Exception {
+        RequestMapping classMapping = AdminArticleController.class.getAnnotation(RequestMapping.class);
+        assertNotNull(classMapping, "控制器必须有类级 @RequestMapping");
+        assertEquals(ARTICLES_BASE_PATH, classMapping.value()[0],
+                "类级路径应为 " + ARTICLES_BASE_PATH);
+
+        Method list = AdminArticleController.class.getMethod("list", Map.class);
+        PostMapping postMapping = list.getAnnotation(PostMapping.class);
+        assertNotNull(postMapping, "list 方法必须有 @PostMapping");
+        assertEquals("/list", postMapping.value()[0]);
+        // POST 语义由 @PostMapping 注解类型本身保证(组合注解固定 method=POST),无需再断言 method()
+    }
+
+    // ==================== 辅助 ====================
+
+    /** 调用 controller.list(body),返回 getAdminList 的实际入参数组(含 11 个参数) */
+    @SuppressWarnings("unchecked")
+    private Object[] callList(Map<String, Object> body) {
+        Page<Article> page = new Page<>(1, 20);
+        when(articleService.getAdminList(any(), any(), any(), any(), any(), any(), any(), any(),
+                anyInt(), anyInt(), any()))
+                .thenAnswer(inv -> {
+                    calls.add(inv.getArguments());
+                    return page;
+                });
+
+        Result<Page<Article>> result = controller.list(body);
+
+        assertNotNull(result, "list 必须返回 Result 信封");
+        assertEquals(1, calls.size(), "list 恰调用一次 getAdminList");
+        return calls.get(0);
+    }
+
+    private static void inject(Object target, String field, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(field);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+}