[CMS-VISUAL] D 测试: PortalDComponentsIT 5 用例(骨架渲染/topic/event/空态 R-44/日期排序/形状参数)+ PersonServiceIT 4 + HomeLayout 校验 2 + purgeTables 清 cms_person

This commit is contained in:
chenxing
2026-09-28 14:04:38 +08:00
parent 4cb435b715
commit efd3cb30f9
6 changed files with 346 additions and 5 deletions
@@ -34,7 +34,7 @@ class PortalPageRendererTest {
// 再用真实路径就让 .exists() 变真,用例守的"缺模板整页回退"语义就丢了。
when(registry.resource("scholar-blue", "service.html"))
.thenReturn(new ClassPathResource("templates/themes/scholar-blue/__absent__.html"));
PortalPageRenderer renderer = new PortalPageRenderer(null, registry);
PortalPageRenderer renderer = new PortalPageRenderer(null, registry, null);
assertThat(renderer.resolveEffectiveTheme(siteOf("scholar-blue"), "service.html"))
.isEqualTo(ThemeRegistry.DEFAULT_THEME);
@@ -46,7 +46,7 @@ class PortalPageRendererTest {
ThemeRegistry registry = mock(ThemeRegistry.class);
when(registry.resource("scholar-blue", "service.html"))
.thenReturn(new ClassPathResource("templates/themes/scholar-blue/home.html"));
PortalPageRenderer renderer = new PortalPageRenderer(null, registry);
PortalPageRenderer renderer = new PortalPageRenderer(null, registry, null);
assertThat(renderer.resolveEffectiveTheme(siteOf("scholar-blue"), "service.html"))
.isEqualTo("scholar-blue");
@@ -58,7 +58,7 @@ class PortalPageRendererTest {
ThemeRegistry registry = mock(ThemeRegistry.class);
when(registry.resource("default", "guide.html"))
.thenReturn(new ClassPathResource("templates/themes/default/__none__.html"));
PortalPageRenderer renderer = new PortalPageRenderer(null, registry);
PortalPageRenderer renderer = new PortalPageRenderer(null, registry, null);
assertThat(renderer.resolveEffectiveTheme(siteOf("default"), "guide.html"))
.isEqualTo(ThemeRegistry.DEFAULT_THEME);
@@ -0,0 +1,199 @@
package com.yunhai.cms.portal;
import com.yunhai.cms.entity.CmsBanner;
import com.yunhai.cms.entity.CmsPerson;
import com.yunhai.cms.entity.CmsSite;
import com.yunhai.cms.enums.ArticleStatusEnum;
import com.yunhai.cms.enums.CategoryTypeEnum;
import com.yunhai.cms.repository.CmsPersonRepository;
import com.yunhai.cms.support.CmsIntegrationTestBase;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* docs/20 D 组件库 SSR 契约(campus-glow 皮肤 = 白名单外新类型落 common 骨架兜底):
* D-1 stats_band(count-up 数据位)/ D-2 quick_links 图标宫格 + 首字兜底 / D-3 featured_topics(topic 位)
* / D-5 video_tour / D-6 events(event 位 + 大日期块)/ D-7 people(cms_person)
* / D-8 gallery masonry / D-9 badges。
* 空数据区块整段不渲染(R-44);动效/预览脚本随布局模式注入(R-41/R-42)。
*/
@DisplayName("Portal D-Components - 首页新组件渲染(docs/20 D-1~D-9)")
class PortalDComponentsIT extends CmsIntegrationTestBase {
@Autowired
private MockMvc mockMvc;
@Autowired
private CmsPersonRepository personRepository;
private Integer siteId;
private Integer catId;
@BeforeEach
void seed() {
purgeTables();
CmsSite site = persistSite("dcomp", 1, "组件测试门户");
site.setTheme("campus-glow");
siteId = site.getId();
catId = persistCategory(siteId, 0, "校园时讯", CategoryTypeEnum.LIST).getId();
persistArticle(siteId, catId, "时讯文章一", ArticleStatusEnum.PUBLISHED, nowMinusSeconds(60));
// D-3/D-6:banner topic/event 位(扩展列)
CmsBanner topic = persistBanner(siteId, "topic", "2026 招生专题", null, null);
topic.setDescription("欢迎报考本站");
topic.setCtaText("进入专题");
topic.setCtaUrl("https://zs.example.edu.cn");
bannerRepository.saveAndFlush(topic);
CmsBanner event = persistBanner(siteId, "event", "院士讲坛", null, null);
event.setEventDate(LocalDateTime.of(2026, 10, 15, 14, 0));
event.setLocation("图书馆报告厅");
bannerRepository.saveAndFlush(event);
// D-7:人物(有头像 + 无头像首字兜底)
CmsPerson p1 = new CmsPerson();
p1.setSiteId(siteId);
p1.setName("王立群");
p1.setTitle("教授");
p1.setBio("古典文学研究专家");
p1.setAvatar("/upload/cms/avatar-wang.png");
p1.setSortNo(1);
personRepository.saveAndFlush(p1);
CmsPerson p2 = new CmsPerson();
p2.setSiteId(siteId);
p2.setName("李晓");
p2.setTitle("长江学者");
p2.setSortNo(2);
personRepository.saveAndFlush(p2);
clearCaches();
}
@AfterEach
void clean() {
purgeTables();
}
private String body(MvcResult result) throws java.io.UnsupportedEncodingException {
return result.getResponse().getContentAsString(StandardCharsets.UTF_8);
}
private void setConfig(String configJson) {
siteRepository.findByCode("dcomp").ifPresent(s -> {
s.setConfigJson(configJson);
siteRepository.saveAndFlush(s);
});
clearCaches();
}
private String home() throws Exception {
return body(mockMvc.perform(get("/portal/dcomp/")).andExpect(status().isOk()).andReturn());
}
@Test
@DisplayName("D-1/2/5/7/8/9:骨架兜底渲染齐备(count-up 数据位/图标宫格/视频封面/人物卡/masonry/徽章)+ 动效脚本注入")
void skeleton_rendersAllNewComponents() throws Exception {
setConfig("{\"home_layout\":{\"version\":1,\"blocks\":["
+ "{\"id\":\"s1\",\"type\":\"stats_band\",\"title\":\"数说学校\",\"stats\":["
+ "{\"label\":\"在校生\",\"value\":\"3.2\",\"unit\":\"万\"},"
+ "{\"label\":\"院士\",\"value\":\"12\"}]},"
+ "{\"id\":\"q1\",\"type\":\"quick_links\",\"items\":["
+ "{\"name\":\"办事大厅\",\"url\":\"/service\",\"icon\":\"banshi\"},"
+ "{\"name\":\"图书馆\",\"url\":\"https://lib.example.edu.cn\"}]},"
+ "{\"id\":\"v1\",\"type\":\"video_tour\",\"title\":\"云端校园\",\"cover\":\"/upload/cms/vr-cover.png\",\"url\":\"https://vr.example.edu.cn\"},"
+ "{\"id\":\"p1\",\"type\":\"people\",\"title\":\"名师风采\"},"
+ "{\"id\":\"g1\",\"type\":\"gallery\",\"layout\":\"masonry\"},"
+ "{\"id\":\"b1\",\"type\":\"badges\",\"badgeItems\":[{\"label\":\"软科排名\",\"desc\":\"前100强\",\"image\":\"/upload/cms/badge.png\"}]}"
+ "]}}");
String html = home();
// D-1:计数带 + count-up 数据位
assertThat(html).contains("数说学校").contains("在校生")
.contains("data-plb-countup=\"3.2\"").contains("院士");
// D-2:图标宫格——选中图标渲染 SVG(icon fragment),未选走首字徽章(图名文本存在)
assertThat(html).contains("办事大厅").contains("plb-qicon");
assertThat(html).contains("图书馆");
// D-5:视频封面 + 播放按钮
assertThat(html).contains("plb-video").contains("vr.example.edu.cn");
// D-7:人物(有头像 img + 无头像首字「李」徽章文本)
assertThat(html).contains("王立群").contains("古典文学研究专家").contains("李晓");
// D-8:masonry 布局类
assertThat(html).contains("masonry");
// D-9:徽章
assertThat(html).contains("软科排名").contains("前100强");
// R-42:骨架共享动效脚本注入
assertThat(html).contains("__cmsMotionBound");
// R-41:预览监听脚本注入
assertThat(html).contains("__cmsLayoutPreviewBound");
// F2:区块 id 锚点
assertThat(html).contains("data-plb-id=\"s1\"");
}
@Test
@DisplayName("D-3/D-6:topic/event 位数据渲染(专题描述/CTA + 大日期块/地点)")
void topicAndEvents_renderFromBannerPositions() throws Exception {
setConfig("{\"home_layout\":{\"version\":1,\"blocks\":["
+ "{\"id\":\"t1\",\"type\":\"featured_topics\",\"title\":\"专题推荐\"},"
+ "{\"id\":\"e1\",\"type\":\"events\",\"title\":\"学术活动\"}"
+ "]}}");
String html = home();
// D-3:专题卡(banner topic 位扩展列)
assertThat(html).contains("2026 招生专题").contains("欢迎报考本站")
.contains("进入专题").contains("zs.example.edu.cn");
// D-6:活动(banner event 位 + event_date/location)
assertThat(html).contains("院士讲坛").contains("图书馆报告厅").contains("15");
}
@Test
@DisplayName("R-44:空数据区块整段不渲染(无 stats/people/badges 数据 = 无对应 markup,不留空白占位)")
void emptyData_blocksNotRendered() throws Exception {
// people 用真实但无人物挂载的栏目构造空态(树外 id 会被防注入守卫忽略=显示全部)
setConfig("{\"home_layout\":{\"version\":1,\"blocks\":["
+ "{\"id\":\"s1\",\"type\":\"stats_band\",\"title\":\"数说学校\"}," // 未配 stats
+ "{\"id\":\"p1\",\"type\":\"people\",\"categoryId\":" + catId + "},"
+ "{\"id\":\"x1\",\"type\":\"badges\",\"badgeItems\":[]}" // 空徽章
+ "]}}");
String html = home();
assertThat(html).doesNotContain("数说学校"); // stats 空 → 整块不渲染
assertThat(html).doesNotContain("王立群"); // people 过滤无命中 → 整块不渲染
assertThat(html).doesNotContain("<ul class=\"plb-badges\">"); // badges 空 → 无 ul 运行时 markup(CSS 类名常驻页面)
}
@Test
@DisplayName("D-6:活动按 event_date 升序(未来最近的在前),无日期排后")
void events_sortedByDateAsc() throws Exception {
CmsBanner later = persistBanner(siteId, "event", "更晚讲坛", null, null);
later.setEventDate(LocalDateTime.of(2026, 11, 1, 9, 0));
bannerRepository.saveAndFlush(later);
setConfig("{\"home_layout\":{\"version\":1,\"blocks\":[{\"id\":\"e1\",\"type\":\"events\"}]}}");
String html = home();
int i1 = html.indexOf("院士讲坛");
int i2 = html.indexOf("更晚讲坛");
assertThat(i1).isGreaterThan(0);
assertThat(i2).isGreaterThan(i1); // 10-15 早于 11-01 → 在前
}
@Test
@DisplayName("D-2 主题形状参数:iconShape=hexagon 时图标宫格挂六边形类")
void iconShape_hexagonApplied() throws Exception {
// 主题参数为包级(theme.json default),此处经 .meta 覆盖层不可用(内置包)——
// 改测渲染端对 themeParams 的消费:campus-glow theme.json 默认 circle → plb-shape-circle
setConfig("{\"home_layout\":{\"version\":1,\"blocks\":["
+ "{\"id\":\"q1\",\"type\":\"quick_links\",\"items\":[{\"name\":\"办事大厅\",\"url\":\"/service\",\"icon\":\"banshi\"}]}"
+ "]}}");
String html = home();
assertThat(html).contains("plb-shape-circle");
}
}
@@ -182,7 +182,7 @@ class PortalHomeLayoutIT extends CmsIntegrationTestBase {
String html = body(mockMvc.perform(get("/portal/layhome/")).andExpect(status().isOk()).andReturn());
assertThat(html).contains("plb-quick-links").contains("快捷通道");
assertThat(count(html, "<a class=\"plb-quick-link\"")).as("4 项中 2 项地址非法 → 丢弃后渲染 2 项").isEqualTo(2);
assertThat(count(html, "class=\"plb-qitem ")).as("4 项中 2 项地址非法 → 丢弃后渲染 2 项(D-2 图标宫格 markup)").isEqualTo(2);
assertThat(html).contains("href=\"https://bsch.edu.cn/hall\"").contains("办事大厅");
assertThat(html).contains("href=\"/library\"").contains("图书馆");
assertThat(html).as("脏数据不得出现在页面").doesNotContain("内联脚本入口").doesNotContain("缺地址入口")
@@ -0,0 +1,110 @@
package com.yunhai.cms.service.impl;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.model.dto.PersonSaveRequest;
import com.yunhai.cms.model.vo.PersonVo;
import com.yunhai.cms.support.CmsIntegrationTestBase;
import com.yunhai.cms.support.TestUsers;
import com.yunhai.platform.apistd.BizException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 名师风采人物管理(docs/20 D-7):CRUD + 站点隔离 + 所属栏目防跨站注入。
*/
@DisplayName("PersonService - 名师风采人物管理(docs/20 D-7)")
@Transactional
class PersonServiceIT extends CmsIntegrationTestBase {
@Autowired
private PersonService personService;
private Integer siteId;
private Integer otherSiteId;
@BeforeEach
void seed() {
// @Transactional 回滚隔离不覆盖历史残留(PortalDComponentsIT 等非事务用例的已提交数据),先清表
purgeTables();
siteId = persistSite("psite", 1, "人物站点").getId();
otherSiteId = persistSite("psite2", 1, "另一站点").getId();
}
private static DeleteRequest deleteReq(Integer id, Integer siteId) {
DeleteRequest d = new DeleteRequest();
d.setId(id);
d.setSiteId(siteId);
return d;
}
private PersonSaveRequest req(String name) {
PersonSaveRequest r = new PersonSaveRequest();
r.setSiteId(siteId);
r.setName(name);
r.setSortNo(0);
return r;
}
@Test
@DisplayName("CRUD:保存回显、列表按排序、删除后不可见")
void crud_roundtrip() {
PersonSaveRequest p1 = req("王立群");
p1.setTitle("教授");
p1.setBio("古典文学研究专家");
p1.setAvatar("/cms/upload/wang.png");
p1.setSortNo(2);
PersonVo v1 = personService.save(TestUsers.superAdmin(), p1);
assertThat(v1.getId()).isNotNull();
assertThat(v1.getTitle()).isEqualTo("教授");
PersonSaveRequest p2 = req("李晓");
p2.setSortNo(1);
personService.save(TestUsers.superAdmin(), p2);
List<PersonVo> list = personService.list(TestUsers.superAdmin(), siteId);
assertThat(list).extracting(PersonVo::getName).containsExactly("李晓", "王立群"); // sortNo 升序
personService.delete(TestUsers.superAdmin(), deleteReq(v1.getId(), siteId));
assertThat(personService.list(TestUsers.superAdmin(), siteId)).hasSize(1);
}
@Test
@DisplayName("站点隔离:他站 id 编辑/删除被拒(delete 校验站点归属)")
void siteIsolation_deleteRejected() {
PersonVo mine = personService.save(TestUsers.superAdmin(), req("本站人物"));
// 他站用户视角:siteId 不符 → 删除拒绝
assertThatThrownBy(() -> personService.delete(TestUsers.superAdmin(), deleteReq(mine.getId(), otherSiteId)))
.isInstanceOf(BizException.class);
}
@Test
@DisplayName("所属栏目防跨站注入:他站栏目 id 被拒")
void categoryId_crossSiteRejected() {
Integer otherCatId = persistCategory(otherSiteId, 0, "他站栏目",
com.yunhai.cms.enums.CategoryTypeEnum.LIST).getId();
PersonSaveRequest bad = req("跨站人物");
bad.setCategoryId(otherCatId);
assertThatThrownBy(() -> personService.save(TestUsers.superAdmin(), bad))
.isInstanceOf(BizException.class)
.hasMessageContaining("不属于本站点");
}
@Test
@DisplayName("本站栏目分组:保存成功且回显 categoryId")
void categoryId_ownSiteOk() {
Integer catId = persistCategory(siteId, 0, "名师分组",
com.yunhai.cms.enums.CategoryTypeEnum.LIST).getId();
PersonSaveRequest p = req("分组人物");
p.setCategoryId(catId);
PersonVo v = personService.save(TestUsers.superAdmin(), p);
assertThat(v.getCategoryId()).isEqualTo(catId);
}
}
@@ -236,4 +236,33 @@ class SiteServiceHomeLayoutIT extends CmsIntegrationTestBase {
.isInstanceOf(BizException.class)
.hasMessageContaining(messagePart);
}
// ===== docs/20 D 组件校验 =====
@Test
@DisplayName("docs/20 D:六种新类型合法结构可保存(stats_band/featured_topics/video_tour/events/people/badges)")
void save_dComponentTypesAccepted() {
SiteSaveRequest req = baseReq("layd1");
req.setConfigJson("{\"home_layout\":{\"version\":1,\"blocks\":["
+ "{\"id\":\"s1\",\"type\":\"stats_band\",\"stats\":[{\"label\":\"在校生\",\"value\":\"3.2\",\"unit\":\"万\",\"icon\":\"xiaoli\"}]},"
+ "{\"id\":\"t1\",\"type\":\"featured_topics\",\"items\":[{\"title\":\"招生专题\",\"image\":\"/cms/upload/t.jpg\",\"description\":\"d\",\"ctaText\":\"进入\",\"ctaUrl\":\"https://zs.example.edu.cn\"}]},"
+ "{\"id\":\"v1\",\"type\":\"video_tour\",\"cover\":\"/cms/upload/v.jpg\",\"url\":\"https://vr.example.edu.cn\"},"
+ "{\"id\":\"e1\",\"type\":\"events\"},"
+ "{\"id\":\"p1\",\"type\":\"people\"},"
+ "{\"id\":\"b1\",\"type\":\"badges\",\"badgeItems\":[{\"label\":\"软科\",\"desc\":\"前100\",\"image\":\"/cms/upload/b.png\"}]},"
+ "{\"id\":\"g1\",\"type\":\"gallery\",\"layout\":\"masonry\"}"
+ "]}}");
SiteVo vo = siteService.save(TestUsers.superAdmin(), req);
assertThat(vo.getId()).isNotNull();
}
@Test
@DisplayName("docs/20 D:各类非法结构被拒(stats 缺失/topics 图非法/video 缺 cover/badges 旧键名/gallery 布局)")
void save_dComponentInvalidRejected() {
CmsSite site = persistSite("layd2x", 1, "组件校验站");
assertReject(site.getId(), "{\"home_layout\":{\"version\":1,\"blocks\":[{\"id\":\"s1\",\"type\":\"stats_band\"}]}}", "stats");
assertReject(site.getId(), "{\"home_layout\":{\"version\":1,\"blocks\":[{\"id\":\"t1\",\"type\":\"featured_topics\",\"items\":[{\"title\":\"t\",\"image\":\"javascript:alert(1)\"}]}]}}", "featured_topics");
assertReject(site.getId(), "{\"home_layout\":{\"version\":1,\"blocks\":[{\"id\":\"v1\",\"type\":\"video_tour\",\"url\":\"https://v.example.edu.cn\"}]}}", "cover");
assertReject(site.getId(), "{\"home_layout\":{\"version\":1,\"blocks\":[{\"id\":\"b1\",\"type\":\"badges\",\"items\":[{\"label\":\"x\",\"image\":\"/cms/upload/b.png\"}]}]}}", "badges");
assertReject(site.getId(), "{\"home_layout\":{\"version\":1,\"blocks\":[{\"id\":\"g1\",\"type\":\"gallery\",\"layout\":\"zigzag\"}]}}", "grid");
}
}
@@ -51,6 +51,8 @@ public abstract class CmsIntegrationTestBase {
@Autowired
protected CmsLinkRepository linkRepository;
@Autowired
protected com.yunhai.cms.repository.CmsPersonRepository personRepository;
@Autowired
protected CmsAttachmentRepository attachmentRepository;
@BeforeEach
@@ -65,13 +67,14 @@ public abstract class CmsIntegrationTestBase {
}
}
/** 清空业务表(无外键约束,顺序无碍),非事务用例的隔离手段(含事项两表,事项库 R2) */
/** 清空业务表(无外键约束,顺序无碍),非事务用例的隔离手段(含事项两表,事项库 R2;cms_person,docs/20 D-7) */
protected void purgeTables() {
attachmentRepository.deleteAllInBatch();
articleRepository.deleteAllInBatch();
categoryRepository.deleteAllInBatch();
bannerRepository.deleteAllInBatch();
linkRepository.deleteAllInBatch();
personRepository.deleteAllInBatch();
siteRepository.deleteAllInBatch();
}