feat(W1): 皮肤包完整管理-外部主题目录+热注册,zip上传/派生/编辑/删除,ThemePackageValidator收敛,Thymeleaf文件解析器,list增source/inUseCount/editable/usedBySiteNames,T-W1-01..20 全绿299/299
build / build-push (push) Successful in 52s

This commit is contained in:
chenxing
2026-09-08 14:52:29 +08:00
parent 536bd6cea4
commit 338ffb56dd
16 changed files with 1548 additions and 71 deletions
@@ -1,8 +1,13 @@
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;
@@ -14,6 +19,7 @@ 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;
@@ -32,14 +38,33 @@ class ThemeApiIT extends CmsIntegrationTestBase {
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() {
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)).build();
.standaloneSetup(new ThemeController(themeService, themeRegistry, themePackageService)).build();
}
@AfterEach
void cleanThemeDir() {
com.yunhai.cms.support.ThemePackageTestSupport.deleteRecursively(themeDir);
}
@AfterEach
@@ -109,4 +134,139 @@ class ThemeApiIT extends CmsIntegrationTestBase {
.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());
}
}
@@ -0,0 +1,213 @@
package com.yunhai.cms.theme;
import com.yunhai.cms.support.TestUsers;
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.core.io.ClassPathResource;
import org.springframework.mock.web.MockMultipartFile;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 皮肤包上传/派生/编辑/删除 纯单元测试(方案 §5.3 T-W1-05..12):临时目录 + 直接 new,不启 Spring。
* 合规 zip 由 classpath 夹具 t-demo-red 现场构造(改名为独立包名,不污染全量 IT 的 classpath 夹具)。
*/
@DisplayName("ThemePackageService - 皮肤包管理(方案 §5.2/§5.4)")
class ThemePackageServiceTest {
private ThemeRegistry registry;
private ThemePackageService service;
private Path themeDir;
private final List<Path> cleanups = new ArrayList<>();
@BeforeEach
void setUp() throws Exception {
themeDir = Files.createTempDirectory("w1-pkg");
cleanups.add(themeDir);
registry = new ThemeRegistry();
registry.setThemeDir(themeDir.toString()); // 上传/派生落位与 reload 扫描必须是同一目录
registry.load();
service = new ThemePackageService(registry, null, event -> {
});
service.setThemeDir(themeDir.toString());
}
@AfterEach
void clean() {
for (Path dir : cleanups) {
deleteRecursively(dir);
}
}
/** 把 classpath 夹具包打成 zip(name 改为指定值;根目录=包目录名) */
private MockMultipartFile zipOf(String pkgName, String rootDir, boolean withThemeJson,
boolean withListHtml, boolean withScreenshot, boolean withHead) throws Exception {
Path staging = Files.createTempDirectory("w1-zip");
cleanups.add(staging);
Path pkg = staging.resolve(rootDir);
Files.createDirectories(pkg.resolve("assets"));
Files.createDirectories(pkg.resolve("fragments"));
if (withThemeJson) {
String json = org.springframework.util.FileCopyUtils.copyToString(
new java.io.InputStreamReader(new ClassPathResource("templates/themes/t-demo-red/theme.json")
.getInputStream(), StandardCharsets.UTF_8));
json = json.replace("t-demo-red", pkgName);
Files.write(pkg.resolve("theme.json"), json.getBytes(StandardCharsets.UTF_8));
}
if (withListHtml) {
Files.copy(new ClassPathResource("templates/themes/t-demo-red/list.html").getInputStream(),
pkg.resolve("list.html"));
}
for (String t : new String[] {"home.html", "detail.html", "single.html"}) {
Files.copy(new ClassPathResource("templates/themes/t-demo-red/" + t).getInputStream(),
pkg.resolve(t));
}
if (withScreenshot) {
Files.copy(new ClassPathResource("templates/themes/t-demo-red/assets/screenshot.png").getInputStream(),
pkg.resolve("assets").resolve("screenshot.png"));
}
if (withHead) {
Files.copy(new ClassPathResource("templates/themes/t-demo-red/fragments/head.html").getInputStream(),
pkg.resolve("fragments").resolve("head.html"));
}
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(bos)) {
Files.walk(staging).filter(Files::isRegularFile).forEach(p -> {
try {
zip.putNextEntry(new ZipEntry(staging.relativize(p).toString().replace('\\', '/')));
zip.write(Files.readAllBytes(p));
zip.closeEntry();
} catch (Exception e) {
throw new IllegalStateException(e);
}
});
}
return new MockMultipartFile("file", pkgName + ".zip", "application/zip", bos.toByteArray());
}
@Test
@DisplayName("T-W1-05: 合规 zip 上传成功——落盘结构完整、返回 ThemeInfo、registry 含之")
void uploadValidZipSucceeds() throws Exception {
ThemeInfo info = service.upload(TestUsers.superAdmin(), zipOf("t-zip-ok", "t-zip-ok", true, true, true, true));
assertThat(info.getName()).isEqualTo("t-zip-ok");
assertThat(registry.contains("t-zip-ok")).isTrue();
assertThat(registry.sourceOf("t-zip-ok")).isEqualTo("external");
assertThat(Files.exists(themeDir.resolve("t-zip-ok").resolve("theme.json"))).isTrue();
assertThat(Files.exists(themeDir.resolve("t-zip-ok").resolve("assets").resolve("screenshot.png"))).isTrue();
assertThat(Files.exists(themeDir.resolve("t-zip-ok").resolve("fragments").resolve("head.html"))).isTrue();
}
@Test
@DisplayName("T-W1-06: zip-slip 条目(../evil.html)拒绝且目标目录无残留")
void zipSlipEntryRejected() throws Exception {
MockMultipartFile file = zipOf("t-slip", "t-slip", true, true, true, true);
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(bos)) {
zip.putNextEntry(new ZipEntry("../evil.html"));
zip.write("<p>evil</p>".getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
}
MockMultipartFile evil = new MockMultipartFile("file", "evil.zip", "application/zip",
bos.toByteArray());
assertThatThrownBy(() -> service.upload(TestUsers.superAdmin(), evil))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("非法路径");
// 合法包同批校验防误伤
service.upload(TestUsers.superAdmin(), file);
assertThat(Files.exists(themeDir.resolve("t-slip"))).isTrue();
assertThat(Files.exists(themeDir.getParent().resolve("evil.html"))).isFalse();
}
@Test
@DisplayName("T-W1-07: 超大包(>20MB)拒绝")
void oversizePackageRejected() throws Exception {
byte[] big = new byte[21 * 1024 * 1024];
java.util.Arrays.fill(big, (byte) 'x');
MockMultipartFile file = new MockMultipartFile("file", "big.zip", "application/zip", big);
assertThatThrownBy(() -> service.upload(TestUsers.superAdmin(), file))
.hasMessageContaining("20MB");
}
@Test
@DisplayName("T-W1-08: 缺 theme.json 拒绝(报告含'缺 theme.json')")
void missingThemeJsonRejected() throws Exception {
assertThatThrownBy(() -> service.upload(TestUsers.superAdmin(),
zipOf("t-nojson", "t-nojson", false, true, true, true)))
.hasMessageContaining("缺 theme.json");
}
@Test
@DisplayName("T-W1-09: name 与目录不一致拒绝(报告含对应条目)")
void nameDirMismatchRejected() throws Exception {
// zip 根目录 t-other 与 theme.json name=t-nojson 不一致
assertThatThrownBy(() -> service.upload(TestUsers.superAdmin(),
zipOf("t-nojson", "t-other", true, true, true, true)))
.hasMessageContaining("name 与目录名不一致");
}
@Test
@DisplayName("T-W1-10: 缺必备模板/片段/截图拒绝(报告逐条列出)")
void missingTemplatesRejected() throws Exception {
assertThatThrownBy(() -> service.upload(TestUsers.superAdmin(),
zipOf("t-nolist", "t-nolist", true, false, false, false)))
.hasMessageContaining("list.html")
.hasMessageContaining("fragments/head.html")
.hasMessageContaining("screenshot");
}
@Test
@DisplayName("T-W1-11: 与内置同名拒绝(name=default),内置包不受影响")
void builtinNameRejected() throws Exception {
assertThatThrownBy(() -> service.upload(TestUsers.superAdmin(),
zipOf("default", "default", true, true, true, true)))
.hasMessageContaining("内置");
assertThat(registry.sourceOf("default")).isEqualTo("builtin");
assertThat(registry.get("default").getTitle()).isNotBlank();
}
@Test
@DisplayName("T-W1-12: 外部同名=覆盖更新(目录替换、registry schema 更新)")
void externalSameNameOverwrites() throws Exception {
service.upload(TestUsers.superAdmin(), zipOf("t-zip-ov", "t-zip-ov", true, true, true, true));
ThemeInfo first = registry.get("t-zip-ov");
String oldTitle = first.getTitle();
MockMultipartFile v2 = zipOf("t-zip-ov", "t-zip-ov", true, true, true, true);
// 修改 title 便于断言覆盖生效
service.updateMeta(TestUsers.superAdmin(), "t-zip-ov", oldTitle + "-v2", null);
ThemeInfo updated = registry.get("t-zip-ov");
assertThat(updated.getTitle()).isEqualTo(oldTitle + "-v2");
// 重新上传同包 → 目录被替换、注册仍有效(schema 更新语义)
service.upload(TestUsers.superAdmin(), v2);
assertThat(registry.contains("t-zip-ov")).isTrue();
assertThat(Files.exists(themeDir.resolve("t-zip-ov").resolve("theme.json"))).isTrue();
}
/** JDK8 递归删除(测试临时目录专用) */
private static void deleteRecursively(Path path) {
if (!Files.exists(path)) {
return;
}
try (java.util.stream.Stream<Path> walk = Files.walk(path)) {
walk.sorted(java.util.Comparator.reverseOrder()).forEach(p -> {
try {
Files.delete(p);
} catch (java.io.IOException ignored) {
}
});
} catch (java.io.IOException ignored) {
}
}
}
@@ -152,4 +152,120 @@ class ThemeRegistryTest {
.as("旧 /upload/** 值应被过滤丢弃,不落库")
.doesNotContainKey("heroImage");
}
// ===== W1 外部主题目录(方案 §5.3 T-W1-01..04)=====
/** 在临时目录构造一个合规外部包(基于 classpath 夹具 t-demo-red 文件拷贝改名) */
private java.nio.file.Path writeExternalPackage(java.nio.file.Path dir, String name,
java.util.function.UnaryOperator<String> themeJsonMutator) throws Exception {
java.nio.file.Path pkg = dir.resolve(name);
java.nio.file.Files.createDirectories(pkg.resolve("assets"));
java.nio.file.Files.createDirectories(pkg.resolve("fragments"));
org.springframework.util.FileCopyUtils.copy(
new org.springframework.core.io.ClassPathResource("templates/themes/t-demo-red/assets/screenshot.png").getInputStream(),
java.nio.file.Files.newOutputStream(pkg.resolve("assets").resolve("screenshot.png")));
for (String t : new String[] {"home.html", "list.html", "detail.html", "single.html"}) {
org.springframework.util.FileCopyUtils.copy(
new org.springframework.core.io.ClassPathResource("templates/themes/t-demo-red/" + t).getInputStream(),
java.nio.file.Files.newOutputStream(pkg.resolve(t)));
}
org.springframework.util.FileCopyUtils.copy(
new org.springframework.core.io.ClassPathResource("templates/themes/t-demo-red/fragments/head.html").getInputStream(),
java.nio.file.Files.newOutputStream(pkg.resolve("fragments").resolve("head.html")));
String json = org.springframework.util.FileCopyUtils.copyToString(
new java.io.InputStreamReader(new org.springframework.core.io.ClassPathResource(
"templates/themes/t-demo-red/theme.json").getInputStream(),
java.nio.charset.StandardCharsets.UTF_8));
json = json.replace("\"name\": \"t-demo-red\"", "\"name\": \"" + name + "\"")
.replace("\"name\":\"t-demo-red\"", "\"name\":\"" + name + "\"");
if (themeJsonMutator != null) {
json = themeJsonMutator.apply(json);
}
java.nio.file.Files.write(pkg.resolve("theme.json"), json.getBytes(java.nio.charset.StandardCharsets.UTF_8));
return pkg;
}
@Test
@DisplayName("T-W1-01: 外部目录包被扫描注册(source=external)")
void externalDirPackageScanned() throws Exception {
java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("w1-themes");
writeExternalPackage(dir, "t-ext-ok", null);
ThemeRegistry r = new ThemeRegistry();
r.setThemeDir(dir.toString());
r.load();
assertThat(r.contains("t-ext-ok")).isTrue();
assertThat(r.sourceOf("t-ext-ok")).isEqualTo("external");
assertThat(r.sourceOf("default")).isEqualTo("builtin");
deleteRecursively(dir);
}
@Test
@DisplayName("T-W1-02: 运行期放入新包 → reload() 热生效,无需重启")
void reloadPicksUpNewPackage() throws Exception {
java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("w1-themes");
ThemeRegistry r = new ThemeRegistry();
r.setThemeDir(dir.toString());
r.load();
assertThat(r.contains("t-ext-late")).isFalse();
writeExternalPackage(dir, "t-ext-late", null);
r.reload();
assertThat(r.contains("t-ext-late")).isTrue();
deleteRecursively(dir);
}
@Test
@DisplayName("T-W1-03: 内置优先于同名外部(外部放 name=default 被拒且留 warn)")
void builtinOverridesExternalSameName() throws Exception {
java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("w1-themes");
writeExternalPackage(dir, "default", null); // 试图用外部 default 覆盖
ThemeRegistry r = new ThemeRegistry();
r.setThemeDir(dir.toString());
r.load();
assertThat(r.contains("default")).isTrue();
assertThat(r.sourceOf("default")).as("注册结果仍为内置 default").isEqualTo("builtin");
deleteRecursively(dir);
}
@Test
@DisplayName("T-W1-04: reload 缺 default 保护——外部-only 场景破坏 default 后 reload 抛异常且旧集合保持")
void reloadWithoutDefaultKeepsOldSet() throws Exception {
java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("w1-themes");
writeExternalPackage(dir, "t-ext-a", null);
writeExternalPackage(dir, "default", null);
// 外部-only 构造:屏蔽 classpath 扫描
ThemeRegistry r = new ThemeRegistry() {
@Override
protected java.util.List<org.springframework.core.io.Resource> scanClasspathResources() {
return java.util.Collections.emptyList();
}
};
r.setThemeDir(dir.toString());
r.load();
int before = r.list().size();
assertThat(r.contains("default")).isTrue();
deleteRecursively(dir.resolve("default"));
org.assertj.core.api.Assertions.assertThatThrownBy(r::reload)
.as("default 缺失时 reload 抛业务异常")
.isInstanceOf(IllegalStateException.class);
assertThat(r.list().size()).as("reload 失败不回滚旧集合").isEqualTo(before);
assertThat(r.contains("t-ext-a")).isTrue();
deleteRecursively(dir);
}
/** JDK8 递归删除(测试临时目录专用) */
private static void deleteRecursively(java.nio.file.Path path) {
if (!java.nio.file.Files.exists(path)) {
return;
}
try (java.util.stream.Stream<java.nio.file.Path> walk = java.nio.file.Files.walk(path)) {
walk.sorted(java.util.Comparator.reverseOrder()).forEach(p -> {
try {
java.nio.file.Files.delete(p);
} catch (java.io.IOException ignored) {
}
});
} catch (java.io.IOException ignored) {
}
}
}