Files
YschcmsService/src/test/java/com/yunhai/cms/theme/ThemeApiIT.java
T

273 lines
13 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.yunhai.cms.theme;
import com.yunhai.cms.controller.api.ThemeController;
import com.yunhai.cms.repository.CmsSiteRepository;
import com.yunhai.cms.service.impl.PortalQueryService;
import com.yunhai.cms.service.impl.SiteService;
import com.yunhai.cms.service.impl.ThemeService;
import com.yunhai.cms.support.CmsIntegrationTestBase;
import com.yunhai.cms.support.TestUsers;
import com.yunhai.platform.apistd.BizException;
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.setup.MockMvcBuilders;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 主题管理接口契约(方案 §8.3 E 组 T18-T21,§5.2)。
* 测试分层(§8.6):T18 全上下文 MockMvc(匿名 CAS 302 契约,现有唯一先例模式);
* T19 服务级直调;T20/T21 standalone MockMvc(不走 CAS 过滤器链的控制器薄契约)。
*/
@DisplayName("ThemeApi - /api/theme 接口契约(方案 §5.2)")
class ThemeApiIT extends CmsIntegrationTestBase {
@Autowired
private MockMvc mockMvc;
@Autowired
private ThemeService themeService;
@Autowired
private ThemeRegistry themeRegistry;
@Autowired
private ThemePackageService themePackageService;
@Autowired
private SiteService siteService;
@Autowired
private PortalQueryService portalQueryService;
@Autowired
private CmsSiteRepository siteRepository;
private MockMvc standalone;
private java.nio.file.Path themeDir;
@BeforeEach
void setUp() throws Exception {
purgeTables();
// W1:IT 内把外部主题目录指向临时目录(不污染 ${user.dir}/data/themes)
themeDir = java.nio.file.Files.createTempDirectory("w1-it-themes");
themeRegistry.setThemeDir(themeDir.toString());
themeRegistry.reload();
themePackageService.setThemeDir(themeDir.toString());
standalone = MockMvcBuilders
.standaloneSetup(new ThemeController(themeService, themeRegistry, themePackageService)).build();
}
@AfterEach
void cleanThemeDir() {
com.yunhai.cms.support.ThemePackageTestSupport.deleteRecursively(themeDir);
}
@AfterEach
void clean() {
purgeTables();
}
@Test
@DisplayName("T18: /api/theme/list 匿名 302 至 SSO(CAS 契约,同 /api/site 先例)")
void themeList_anonymousRedirectsToSso() throws Exception {
mockMvc.perform(get("/api/theme/list"))
.andExpect(status().is3xxRedirection())
.andExpect(result -> assertThat(result.getResponse().getRedirectedUrl())
.contains("sso."));
}
@Test
@DisplayName("T19: 主题列表返回 schema(含 default:name/title/params/bannerSize/screenshotUrl)")
void themeList_containsDefaultWithSchema() {
List<com.yunhai.cms.model.vo.ThemeVo> list = themeService.list();
assertThat(list).as("至少包含 default(Registry 已注册)")
.extracting(vo -> vo.getName()).contains("default");
com.yunhai.cms.model.vo.ThemeVo def = list.stream()
.filter(v -> "default".equals(v.getName()))
.findFirst().orElseThrow(IllegalStateException::new);
assertThat(def.getTitle()).isNotBlank();
assertThat(def.getBannerSize()).isNotBlank();
assertThat(def.getScreenshotUrl()).isEqualTo("/api/theme/default/asset/screenshot.png");
assertThat(def.getParams()).isNotEmpty();
com.yunhai.cms.model.vo.ThemeVo.ThemeParamVo primary = def.getParams().stream()
.filter(p -> "primary".equals(p.getKey()))
.findFirst().orElseThrow(IllegalStateException::new);
assertThat(primary.getType()).isEqualTo("color");
assertThat(primary.getCssVar()).isEqualTo("--primary");
assertThat(primary.getDefaultValue()).isNotBlank();
}
@Test
@DisplayName("T20: 资产代理防穿越(..%2f 拒绝 400/404)")
void assetTraversalRejected() throws Exception {
standalone.perform(get("/api/theme/t-demo-red/asset/..%2F..%2Fapplication.yml"))
.andExpect(result -> assertThat(result.getResponse().getStatus()).isIn(400, 404));
}
@Test
@DisplayName("T21: 未注册主题资产 404(不落目录拼接)")
void unregisteredThemeAsset404() throws Exception {
standalone.perform(get("/api/theme/no-such-theme/asset/screenshot.png"))
.andExpect(status().isNotFound());
}
@Test
@DisplayName("T21 补充: 夹具主题 screenshot 经代理可取且带缓存头")
void fixtureScreenshotServed() throws Exception {
standalone.perform(get("/api/theme/t-demo-red/asset/screenshot.png"))
.andExpect(status().isOk())
.andExpect(header().string("Cache-Control", "max-age=3600, public"));
}
@Test
@DisplayName("P2-T32: 三新包 screenshot 经资产代理可取(registry 校验存在 + 接口冒烟)")
void newPackageScreenshotsServed() throws Exception {
for (String name : new String[] {"classic-red", "blue-white", "warm-kinder"}) {
standalone.perform(get("/api/theme/{name}/asset/screenshot.png", name))
.andExpect(status().isOk())
.andExpect(header().string("Cache-Control", "max-age=3600, public"));
}
}
// ===== W1 皮肤包管理(方案 §5.3 T-W1-13..18)=====
@Test
@DisplayName("T-W1-13: 非超管上传 403")
void uploadBySchoolAdminForbidden() throws Exception {
org.springframework.mock.web.MockMultipartFile zip =
com.yunhai.cms.support.ThemePackageTestSupport.buildZip(
"t-api-fb", "t-api-fb", true, true, true, true, true, null);
assertThatThrownBy(() -> themePackageService.upload(TestUsers.schoolUser(1), zip))
.isInstanceOf(BizException.class)
.hasMessageContaining("仅平台超管");
}
@Test
@DisplayName("T-W1-14: list 增补字段 source/inUseCount/editable;内置包 editable=false")
void listCarriesNewFields() throws Exception {
themePackageService.upload(TestUsers.superAdmin(),
com.yunhai.cms.support.ThemePackageTestSupport.buildZip(
"t-api-ext", "t-api-ext", true, true, true, true, true, null));
List<com.yunhai.cms.model.vo.ThemeVo> list = themeService.list();
com.yunhai.cms.model.vo.ThemeVo def = list.stream()
.filter(v -> "default".equals(v.getName())).findFirst().orElseThrow(IllegalStateException::new);
assertThat(def.getSource()).isEqualTo("builtin");
assertThat(def.isEditable()).isFalse();
assertThat(def.getInUseCount()).isGreaterThanOrEqualTo(0);
com.yunhai.cms.model.vo.ThemeVo ext = list.stream()
.filter(v -> "t-api-ext".equals(v.getName())).findFirst().orElseThrow(IllegalStateException::new);
assertThat(ext.getSource()).isEqualTo("external");
assertThat(ext.isEditable()).isTrue();
assertThat(ext.getInUseCount()).isEqualTo(0);
}
@Test
@DisplayName("T-W1-15: 被用外部包删除拒绝且消息含站点名")
void deleteInUsePackageRejected() throws Exception {
themePackageService.upload(TestUsers.superAdmin(),
com.yunhai.cms.support.ThemePackageTestSupport.buildZip(
"t-api-del", "t-api-del", true, true, true, true, true, null));
com.yunhai.cms.model.dto.SiteSaveRequest req = new com.yunhai.cms.model.dto.SiteSaveRequest();
req.setCode("delsite");
req.setParentSiteId(0);
req.setSchId(1);
req.setSchName("第一中学");
req.setName("删除保护门户");
req.setTheme("t-api-del");
siteService.save(TestUsers.superAdmin(), req);
assertThatThrownBy(() -> themePackageService.remove(TestUsers.superAdmin(), "t-api-del"))
.isInstanceOf(BizException.class)
.hasMessageContaining("删除保护门户");
assertThat(themeRegistry.contains("t-api-del")).isTrue();
}
@Test
@DisplayName("T-W1-16: 未被用外部包删除成功——list 不含、目录不存在、引用站点历史快照失效")
void deleteUnusedPackageSucceeds() throws Exception {
themePackageService.upload(TestUsers.superAdmin(),
com.yunhai.cms.support.ThemePackageTestSupport.buildZip(
"t-api-ext2", "t-api-ext2", true, true, true, true, true, null));
com.yunhai.cms.model.dto.SiteSaveRequest req = new com.yunhai.cms.model.dto.SiteSaveRequest();
req.setCode("extsite");
req.setParentSiteId(0);
req.setSchId(1);
req.setSchName("第一中学");
req.setName("外部皮肤门户");
req.setTheme("t-api-ext2");
siteService.save(TestUsers.superAdmin(), req);
// 删除前快照主题=外部包(进缓存)
assertThat(portalQueryService.siteSnapshotByPath("extsite").getTheme()).isEqualTo("t-api-ext2");
// 站点先换回默认皮肤(保存点发布 SiteContentChangedEvent → 缓存失效)
com.yunhai.cms.model.dto.SiteSaveRequest switchBack = new com.yunhai.cms.model.dto.SiteSaveRequest();
switchBack.setId(siteRepository.findByCode("extsite").orElseThrow(IllegalStateException::new).getId());
switchBack.setCode("extsite");
switchBack.setParentSiteId(0);
switchBack.setSchId(1);
switchBack.setName("外部皮肤门户");
switchBack.setTheme(com.yunhai.cms.theme.ThemeRegistry.DEFAULT_THEME);
siteService.save(TestUsers.superAdmin(), switchBack);
themePackageService.remove(TestUsers.superAdmin(), "t-api-ext2");
assertThat(themeRegistry.contains("t-api-ext2")).isFalse();
assertThat(themeService.list().stream()
.noneMatch(v -> "t-api-ext2".equals(v.getName()))).isTrue();
assertThat(java.nio.file.Files.exists(themeDir.resolve("t-api-ext2"))).isFalse();
// SiteContentChangedEvent 失效链路:缓存清空后重新装配回退 default(历史快照已失效)
assertThat(portalQueryService.siteSnapshotByPath("extsite").getTheme())
.isEqualTo(com.yunhai.cms.theme.ThemeRegistry.DEFAULT_THEME);
}
@Test
@DisplayName("T-W1-17: 派生+编辑链路——derive(default→my-skin) → PUT meta → list 与资产反映新值")
void deriveAndUpdateMetaChain() throws Exception {
themePackageService.derive(TestUsers.superAdmin(), "default", "my-skin");
assertThat(themeRegistry.contains("my-skin")).isTrue();
assertThat(themeRegistry.sourceOf("my-skin")).isEqualTo("external");
com.yunhai.cms.model.vo.ThemeVo.ThemeParamVo primary = themeService.list().stream()
.filter(v -> "default".equals(v.getName())).findFirst().orElseThrow(IllegalStateException::new)
.getParams().stream().filter(p -> "primary".equals(p.getKey()))
.findFirst().orElseThrow(IllegalStateException::new);
com.yunhai.cms.theme.ThemeParam edited = new com.yunhai.cms.theme.ThemeParam();
edited.setKey(primary.getKey());
edited.setLabel(primary.getLabel());
edited.setType(primary.getType());
edited.setCssVar(primary.getCssVar());
edited.setDefaultValue("#123456");
themePackageService.updateMeta(TestUsers.superAdmin(), "my-skin", "我的皮肤",
java.util.Collections.singletonList(edited));
com.yunhai.cms.model.vo.ThemeVo vo = themeService.list().stream()
.filter(v -> "my-skin".equals(v.getName())).findFirst().orElseThrow(IllegalStateException::new);
assertThat(vo.getTitle()).isEqualTo("我的皮肤");
assertThat(vo.getParams().stream().filter(p -> "primary".equals(p.getKey()))
.findFirst().orElseThrow(IllegalStateException::new).getDefaultValue()).isEqualTo("#123456");
// 外部包资产经代理可取
standalone.perform(get("/api/theme/my-skin/asset/screenshot.png"))
.andExpect(status().isOk());
standalone.perform(get("/api/theme/my-skin/asset/css/main.css"))
.andExpect(status().isOk());
}
@Test
@DisplayName("T-W1-18: reload 端点权限——校级 403、超管 200")
void reloadEndpointPermission() throws Exception {
assertThatThrownBy(() -> themePackageService.reload(TestUsers.schoolUser(1)))
.isInstanceOf(BizException.class)
.hasMessageContaining("仅平台超管");
themePackageService.reload(TestUsers.superAdmin());
}
}