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 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 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()); } }