273 lines
13 KiB
Java
273 lines
13 KiB
Java
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());
|
||
}
|
||
}
|