|
|
@@ -0,0 +1,275 @@
|
|
|
+package com.etotem.cfc.integration;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.controller.content.ArticleController;
|
|
|
+import com.etotem.cfc.entity.Article;
|
|
|
+import com.etotem.cfc.mapper.ArticleMapper;
|
|
|
+import com.etotem.cfc.mapper.ArticleReadingRecordMapper;
|
|
|
+import com.etotem.cfc.mapper.UserMapper;
|
|
|
+import com.etotem.cfc.service.ArticlePermissionService;
|
|
|
+import com.etotem.cfc.service.ArticleService;
|
|
|
+import com.etotem.cfc.service.ContentDimensionThresholdService;
|
|
|
+import com.etotem.cfc.service.FiveDimensionScoreService;
|
|
|
+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.reflect.Field;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.Date;
|
|
|
+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.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.mock;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+
|
|
|
+/**
|
|
|
+ * S-cfc-131(标签订阅缺口键 content.article.listByTag)契约测试。
|
|
|
+ *
|
|
|
+ * <p>规格:{@code docs/superpowers/specs/2026-11-10-article-tag-filter-design.md}(实现 commit 6e9a511e2)。</p>
|
|
|
+ *
|
|
|
+ * <p>锚定条款:</p>
|
|
|
+ * <ul>
|
|
|
+ * <li>§3.1 {@code getPublicList} 10 参:tag 非空时按 {@code parseCommaOrJsonTags} 解析后内存过滤,
|
|
|
+ * tags 兼容 JSON 数组({@code ["减脂","睡眠"]})与逗号分隔({@code 减脂,睡眠})两种存储格式;</li>
|
|
|
+ * <li>§3.1 {@code listTopTags(int limit)}:仅统计已发布且 tags 非空,按频次降序、并列按字典序,
|
|
|
+ * {@code limit<=0} 默认 20;</li>
|
|
|
+ * <li>§3.1 {@code POST /api/articles/tags} 返回 {@code Result<List<String>>}(Top 20);</li>
|
|
|
+ * <li>§3.1 {@code POST /api/articles/list} 读 {@code body.tag} 并透传到 10 参 getPublicList(位置 4)。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p>口径说明:本类为契约级(Controller↔Service 参数与返回结构),不验 UI 渲染;
|
|
|
+ * 前端 Tab 行为由 cfc-frontend Jest 覆盖。</p>
|
|
|
+ */
|
|
|
+class ArticleTagFilterContractTest {
|
|
|
+
|
|
|
+ private ArticleService articleService;
|
|
|
+ private ArticleController articleController;
|
|
|
+
|
|
|
+ private ArticleMapper articleMapper;
|
|
|
+ private ArticlePermissionService articlePermissionService;
|
|
|
+ private ArticleReadingRecordMapper articleReadingRecordMapper;
|
|
|
+ private FiveDimensionScoreService fiveDimensionScoreService;
|
|
|
+ private ContentDimensionThresholdService contentDimensionThresholdService;
|
|
|
+ private JdbcTemplate jdbcTemplate;
|
|
|
+ private UserMapper userMapper;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() throws Exception {
|
|
|
+ articleService = new ArticleService();
|
|
|
+ articleController = new ArticleController();
|
|
|
+
|
|
|
+ articleMapper = mock(ArticleMapper.class);
|
|
|
+ articlePermissionService = mock(ArticlePermissionService.class);
|
|
|
+ articleReadingRecordMapper = mock(ArticleReadingRecordMapper.class);
|
|
|
+ fiveDimensionScoreService = mock(FiveDimensionScoreService.class);
|
|
|
+ contentDimensionThresholdService = mock(ContentDimensionThresholdService.class);
|
|
|
+ jdbcTemplate = mock(JdbcTemplate.class);
|
|
|
+ userMapper = mock(UserMapper.class);
|
|
|
+
|
|
|
+ injectService("articleMapper", articleMapper);
|
|
|
+ injectService("articlePermissionService", articlePermissionService);
|
|
|
+ injectService("articleReadingRecordMapper", articleReadingRecordMapper);
|
|
|
+ injectService("fiveDimensionScoreService", fiveDimensionScoreService);
|
|
|
+ injectService("contentDimensionThresholdService", contentDimensionThresholdService);
|
|
|
+ injectService("jdbcTemplate", jdbcTemplate);
|
|
|
+ injectService("userMapper", userMapper);
|
|
|
+ injectController("articleService", articleService);
|
|
|
+ injectController("userMapper", userMapper);
|
|
|
+
|
|
|
+ // 权限过滤放行(原样返回)、无家庭得分(阈值过滤 fail-open)、无阅读时长、无已完成记录
|
|
|
+ when(articlePermissionService.filterVisible(any(), any(), any(), any()))
|
|
|
+ .thenAnswer(inv -> inv.getArgument(0));
|
|
|
+ when(fiveDimensionScoreService.getFamilyAverageScores(any())).thenReturn(Collections.emptyMap());
|
|
|
+ when(contentDimensionThresholdService.listByContents(anyString(), any())).thenReturn(Collections.emptyMap());
|
|
|
+ when(jdbcTemplate.queryForList(anyString())).thenReturn(Collections.emptyList());
|
|
|
+ when(articleReadingRecordMapper.selectCompletedArticleIdsByChild(anyLong()))
|
|
|
+ .thenReturn(Collections.emptyList());
|
|
|
+ }
|
|
|
+
|
|
|
+ private void injectService(String field, Object value) throws Exception {
|
|
|
+ Field f = ArticleService.class.getDeclaredField(field);
|
|
|
+ f.setAccessible(true);
|
|
|
+ f.set(articleService, value);
|
|
|
+ }
|
|
|
+
|
|
|
+ private void injectController(String field, Object value) throws Exception {
|
|
|
+ Field f = ArticleController.class.getDeclaredField(field);
|
|
|
+ f.setAccessible(true);
|
|
|
+ f.set(articleController, value);
|
|
|
+ }
|
|
|
+
|
|
|
+ private Article article(Long id, String tags) {
|
|
|
+ Article a = new Article();
|
|
|
+ a.setId(id);
|
|
|
+ a.setStatus("published");
|
|
|
+ a.setTags(tags);
|
|
|
+ a.setTitle("文章" + id);
|
|
|
+ a.setIsFeatured(0);
|
|
|
+ a.setSortOrder(0);
|
|
|
+ a.setPublishedAt(new Date());
|
|
|
+ return a;
|
|
|
+ }
|
|
|
+
|
|
|
+ private void mockAll(List<Article> all) {
|
|
|
+ when(articleMapper.selectList(any())).thenReturn(new ArrayList<>(all));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------- TC-AT-01~05:/list 的 tag 过滤语义 ----------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("TC-AT-01 tag 命中 JSON 数组存法的文章,未命中文章被过滤")
|
|
|
+ void listFiltersByTagJsonArrayStorage() {
|
|
|
+ mockAll(Arrays.asList(
|
|
|
+ article(1L, "[\"减脂\",\"睡眠\"]"),
|
|
|
+ article(2L, "[\"免疫\"]"),
|
|
|
+ article(3L, null)));
|
|
|
+
|
|
|
+ Page<Article> page = articleService.getPublicList(null, null, null, "减脂",
|
|
|
+ 1, 10, null, null, null, null);
|
|
|
+
|
|
|
+ assertEquals(1, page.getTotal(), "total 必须是过滤后的真实条数");
|
|
|
+ assertEquals(1, page.getRecords().size());
|
|
|
+ assertEquals(1L, page.getRecords().get(0).getId());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("TC-AT-02 tag 命中逗号分隔存法(历史数据兼容)")
|
|
|
+ void listFiltersByTagCommaStorage() {
|
|
|
+ mockAll(Arrays.asList(
|
|
|
+ article(1L, "减脂,睡眠"),
|
|
|
+ article(2L, "免疫")));
|
|
|
+
|
|
|
+ Page<Article> page = articleService.getPublicList(null, null, null, "睡眠",
|
|
|
+ 1, 10, null, null, null, null);
|
|
|
+
|
|
|
+ assertEquals(1, page.getTotal());
|
|
|
+ assertEquals(1L, page.getRecords().get(0).getId());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("TC-AT-03 tag 为空 → 不做标签过滤,行为与旧版一致")
|
|
|
+ void listWithoutTagKeepsAll() {
|
|
|
+ mockAll(Arrays.asList(article(1L, "[\"减脂\"]"), article(2L, null), article(3L, "免疫")));
|
|
|
+
|
|
|
+ Page<Article> page = articleService.getPublicList(null, null, null, "",
|
|
|
+ 1, 10, null, null, null, null);
|
|
|
+
|
|
|
+ assertEquals(3, page.getTotal());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("TC-AT-04 tag 无匹配 → 返回空页且 total=0(非 500/异常)")
|
|
|
+ void listWithUnknownTagReturnsEmptyPage() {
|
|
|
+ mockAll(Arrays.asList(article(1L, "[\"减脂\"]")));
|
|
|
+
|
|
|
+ Page<Article> page = articleService.getPublicList(null, null, null, "不存在的标签",
|
|
|
+ 1, 10, null, null, null, null);
|
|
|
+
|
|
|
+ assertEquals(0, page.getTotal());
|
|
|
+ assertTrue(page.getRecords().isEmpty());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("TC-AT-05 tag 为精确匹配(子串不命中)")
|
|
|
+ void listTagRequiresExactMatch() {
|
|
|
+ mockAll(Arrays.asList(article(1L, "[\"减脂\"]")));
|
|
|
+
|
|
|
+ Page<Article> page = articleService.getPublicList(null, null, null, "减",
|
|
|
+ 1, 10, null, null, null, null);
|
|
|
+
|
|
|
+ assertEquals(0, page.getTotal(), "「减」不应命中「减脂」——Tab 语义是全等而非模糊");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------- TC-AT-06~09:/tags 聚合语义 ----------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("TC-AT-06 listTopTags 按出现频次降序")
|
|
|
+ void listTopTagsOrderByFrequencyDesc() {
|
|
|
+ mockAll(Arrays.asList(
|
|
|
+ article(1L, "[\"减脂\",\"睡眠\"]"),
|
|
|
+ article(2L, "[\"减脂\"]"),
|
|
|
+ article(3L, "减脂,免疫")));
|
|
|
+
|
|
|
+ List<String> tags = articleService.listTopTags(20);
|
|
|
+
|
|
|
+ assertEquals(Arrays.asList("减脂", "免疫", "睡眠"), tags);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("TC-AT-07 listTopTags 频次并列时按标签字典序")
|
|
|
+ void listTopTagsTieBreakByLexicalOrder() {
|
|
|
+ mockAll(Arrays.asList(
|
|
|
+ article(1L, "[\"睡眠\"]"),
|
|
|
+ article(2L, "[\"免疫\"]"),
|
|
|
+ article(3L, "[\"减脂\"]")));
|
|
|
+
|
|
|
+ List<String> tags = articleService.listTopTags(20);
|
|
|
+
|
|
|
+ // 频次均为 1 → 字典序(按 String.compareTo:免疫 < 减脂 < 睡眠 的码点序)
|
|
|
+ List<String> sorted = new ArrayList<>(Arrays.asList("睡眠", "免疫", "减脂"));
|
|
|
+ Collections.sort(sorted);
|
|
|
+ assertEquals(sorted, tags);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("TC-AT-08 listTopTags limit<=0 时默认取 20 条")
|
|
|
+ void listTopTagsDefaultLimit20() {
|
|
|
+ List<Article> all = new ArrayList<>();
|
|
|
+ for (int i = 0; i < 25; i++) {
|
|
|
+ all.add(article((long) i + 1, "[\"tag" + i + "\"]"));
|
|
|
+ }
|
|
|
+ mockAll(all);
|
|
|
+
|
|
|
+ assertEquals(20, articleService.listTopTags(0).size());
|
|
|
+ assertEquals(20, articleService.listTopTags(-5).size());
|
|
|
+ assertEquals(3, articleService.listTopTags(3).size(), "显式 limit 必须生效");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("TC-AT-09 listTopTags 忽略空 tags / 空标签项")
|
|
|
+ void listTopTagsSkipsBlankTags() {
|
|
|
+ mockAll(Arrays.asList(
|
|
|
+ article(1L, null),
|
|
|
+ article(2L, " "),
|
|
|
+ article(3L, "[\"\",\" \",\"减脂\"]"),
|
|
|
+ article(4L, "减脂,,")));
|
|
|
+
|
|
|
+ assertEquals(Collections.singletonList("减脂"), articleService.listTopTags(20));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------- TC-AT-10:Controller 契约 ----------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("TC-AT-10 POST /api/articles/tags 返回 Top20;/list 透传 body.tag")
|
|
|
+ void controllerExposesTagsEndpointAndPassesTag() {
|
|
|
+ mockAll(Arrays.asList(
|
|
|
+ article(1L, "[\"减脂\"]"),
|
|
|
+ article(2L, "[\"减脂\"]"),
|
|
|
+ article(3L, "[\"免疫\"]")));
|
|
|
+
|
|
|
+ Result<List<String>> tagsResult = articleController.tags();
|
|
|
+ assertEquals(200, tagsResult.getCode());
|
|
|
+ assertNotNull(tagsResult.getData());
|
|
|
+ assertEquals(Arrays.asList("减脂", "免疫"), tagsResult.getData());
|
|
|
+
|
|
|
+ Map<String, Object> body = new HashMap<>();
|
|
|
+ body.put("tag", "免疫");
|
|
|
+ Result<Page<Article>> listResult = articleController.list(body, null, null);
|
|
|
+ assertEquals(200, listResult.getCode());
|
|
|
+ assertEquals(1, listResult.getData().getTotal(),
|
|
|
+ "body.tag 必须透传到 getPublicList 第 4 参(否则标签筛选静默失效)");
|
|
|
+ assertEquals(3L, listResult.getData().getRecords().get(0).getId());
|
|
|
+ }
|
|
|
+}
|