|
|
@@ -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);
|
|
|
+ }
|
|
|
+}
|