feat: add svn preset management and optimize docker builds

This commit is contained in:
liumangmang
2026-06-11 13:57:20 +08:00
parent 409c5a81e4
commit b5c7907c23
24 changed files with 1317 additions and 138 deletions
@@ -0,0 +1,68 @@
package com.svnlog.web.controller;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mockito;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import com.svnlog.web.service.AiWorkflowService;
import com.svnlog.web.service.HealthService;
import com.svnlog.web.service.OutputFileService;
import com.svnlog.web.service.RepositoryConfigService;
import com.svnlog.web.service.SettingsService;
import com.svnlog.web.service.SvnPresetService;
import com.svnlog.web.service.SvnWorkflowService;
import com.svnlog.web.service.TaskService;
import java.nio.file.Path;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class AppControllerPresetTest {
@TempDir
Path tempDir;
@Test
void shouldRejectUpdateWithBlankName() throws Exception {
buildMockMvc()
.perform(put("/api/svn/presets/some-id")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("名称不能为空"));
}
@Test
void shouldRejectUpdateWithBlankUrl() throws Exception {
buildMockMvc()
.perform(put("/api/svn/presets/some-id")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"url\":\"\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("SVN URL 不能为空"));
}
private MockMvc buildMockMvc() {
final OutputFileService outputFileService = new OutputFileService();
outputFileService.setOutputRoot(tempDir.resolve("outputs").toString());
final RepositoryConfigService repoConfigService = new RepositoryConfigService(outputFileService);
final SvnPresetService svnPresetService = new SvnPresetService(repoConfigService);
final AppController controller = new AppController(
Mockito.mock(SvnWorkflowService.class),
Mockito.mock(AiWorkflowService.class),
Mockito.mock(TaskService.class),
outputFileService,
Mockito.mock(SettingsService.class),
svnPresetService,
Mockito.mock(HealthService.class)
);
return MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new GlobalExceptionHandler())
.build();
}
}
@@ -144,7 +144,6 @@ class AiWorkflowServiceTest {
private SettingsService buildSettingsService(OutputFileService outputFileService) {
final RepositoryConfigService repositoryConfigService = buildRepositoryConfigService(outputFileService);
final SvnPresetService svnPresetService = new SvnPresetService(repositoryConfigService);
svnPresetService.init();
return new SettingsService(outputFileService, new SettingsPersistenceService(), svnPresetService, repositoryConfigService);
}
@@ -29,7 +29,6 @@ class HealthServiceTest {
final RepositoryConfigService repositoryConfigService = new RepositoryConfigService(outputFileService);
repositoryConfigService.init();
final SvnPresetService svnPresetService = new SvnPresetService(repositoryConfigService);
svnPresetService.init();
final SettingsService settingsService = new SettingsService(
outputFileService,
new SettingsPersistenceService(),
@@ -42,6 +42,11 @@ class SettingsServiceTest {
void shouldPersistAndReloadSettingsFromOutputDirectory() throws IOException {
useTempWorkingDirectory();
final Path customOutputDir = tempDir.resolve("custom-output");
final Path initialOutputDir = tempDir.resolve("outputs");
// 创建预设 id=preset-2,用于 defaultSvnPresetId 校验
writeRepositoryConfigToDir(initialOutputDir, "preset-2", "Preset 2", "https://svn.example.com/p2");
writeRepositoryConfigToDir(customOutputDir, "preset-2", "Preset 2", "https://svn.example.com/p2");
final SettingsService settingsService = newSettingsService();
settingsService.updateSettings(
@@ -199,6 +204,45 @@ class SettingsServiceTest {
assertNotNull(settings.get("apiKeySource"));
}
@Test
void shouldPreferPresetCredentialsOverGlobalWhenBothExist() throws IOException {
useTempWorkingDirectory();
final OutputFileService outputFileService = new OutputFileService();
outputFileService.setOutputRoot(tempDir.resolve("outputs").toString());
writeRepositoryConfig(outputFileService.getOutputRoot().resolve("repository-configs.json"));
final RepositoryConfigService repositoryConfigService = new RepositoryConfigService(outputFileService);
repositoryConfigService.init();
final SvnPresetService svnPresetService = new SvnPresetService(repositoryConfigService);
final SettingsService settingsService = new SettingsService(
outputFileService,
new SettingsPersistenceService(),
svnPresetService,
repositoryConfigService
);
// 设置全局 SVN 凭据(与预设不同)
settingsService.updateSettings(
"deepseek-key", // apiKey
"deepseek", // provider
null, // openaiBaseUrl
null, // openaiApiKey
null, // openaiStageOneModel
null, // openaiStageTwoModel
"global-user", // svnUsername(全局)
"global-pass", // svnPassword(全局)
null, // outputDir
null // defaultSvnPresetId
);
// 预设 json-user/json-pass 应优先于全局 global-user/global-pass
final SettingsService.SvnCredentials credentials =
settingsService.resolveSvnCredentials(null, null, "preset-json");
assertEquals("json-user", credentials.getUsername());
assertEquals("json-pass", credentials.getPassword());
}
@Test
void shouldResolveSvnCredentialsFromRepositoryConfigWhenSettingsAreEmpty() throws IOException {
useTempWorkingDirectory();
@@ -209,7 +253,6 @@ class SettingsServiceTest {
final RepositoryConfigService repositoryConfigService = new RepositoryConfigService(outputFileService);
repositoryConfigService.init();
final SvnPresetService svnPresetService = new SvnPresetService(repositoryConfigService);
svnPresetService.init();
final SettingsService settingsService = new SettingsService(
outputFileService,
new SettingsPersistenceService(),
@@ -233,10 +276,24 @@ class SettingsServiceTest {
final RepositoryConfigService repositoryConfigService = new RepositoryConfigService(outputFileService);
repositoryConfigService.init();
final SvnPresetService svnPresetService = new SvnPresetService(repositoryConfigService);
svnPresetService.init();
return new SettingsService(outputFileService, settingsPersistenceService, svnPresetService, repositoryConfigService);
}
private void writeRepositoryConfigToDir(Path outputDir, String id, String name, String url) throws IOException {
final RepositoryConfig config = new RepositoryConfig();
config.setId(id);
config.setName(name);
config.setType("SVN");
config.setEnabled(true);
config.setSvnUrl(url);
config.setSvnUsername("");
final Path configPath = outputDir.resolve("repository-configs.json");
Files.createDirectories(configPath.getParent());
final String json = new GsonBuilder().setPrettyPrinting().create()
.toJson(java.util.Collections.singletonList(config));
Files.write(configPath, json.getBytes(StandardCharsets.UTF_8));
}
private void writeRepositoryConfig(Path configPath) throws IOException {
final RepositoryConfig config = new RepositoryConfig();
config.setId("preset-json");
@@ -0,0 +1,214 @@
package com.svnlog.web.service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import com.svnlog.web.dto.SvnPresetCreateRequest;
import com.svnlog.web.dto.SvnPresetUpdateRequest;
import com.svnlog.web.model.RepositoryConfig;
import com.svnlog.web.model.SvnPresetManageItem;
import com.svnlog.web.util.CryptoUtils;
public class SvnPresetServiceTest {
private static RepositoryConfigService createRepoService(Path outputRoot) throws IOException {
final OutputFileService outputFileService = new OutputFileService();
outputFileService.setOutputRoot(outputRoot.toString());
return new RepositoryConfigService(outputFileService);
}
@Test
public void shouldCreatePresetAndPersistToJson() throws Exception {
final Path tempDir = Files.createTempDirectory("svn-preset-test");
final RepositoryConfigService repoService = createRepoService(tempDir);
final SvnPresetService service = new SvnPresetService(repoService);
final SvnPresetCreateRequest request = new SvnPresetCreateRequest();
request.setName("test-project");
request.setUrl("https://svn.example.com/test");
request.setSvnUsername("testuser");
request.setSvnPassword("secret123");
final SvnPresetManageItem result = service.create(request);
Assertions.assertNotNull(result.getId());
Assertions.assertEquals("test-project", result.getName());
Assertions.assertEquals("https://svn.example.com/test", result.getUrl());
Assertions.assertEquals("testuser", result.getSvnUsername());
Assertions.assertTrue(result.isSvnCredentialsConfigured());
Assertions.assertTrue(result.isEnabled());
// 验证密码加密保存且不回显明文
final RepositoryConfig raw = repoService.getById(result.getId());
Assertions.assertFalse(raw.getSvnPasswordEncrypted().isEmpty());
Assertions.assertNotEquals("secret123", raw.getSvnPasswordEncrypted());
Assertions.assertEquals("secret123", CryptoUtils.decrypt(raw.getSvnPasswordEncrypted()));
// 验证管理列表返回用户名但不返回密码
final SvnPresetManageItem manageItem = service.listManage().get(0);
Assertions.assertEquals("testuser", manageItem.getSvnUsername());
// Password is never exposed in the manage item
}
@Test
public void shouldUpdatePresetWithoutOverwritingPasswordWhenEmpty() throws Exception {
final Path tempDir = Files.createTempDirectory("svn-preset-update-test");
final RepositoryConfigService repoService = createRepoService(tempDir);
final SvnPresetService service = new SvnPresetService(repoService);
// Create preset with password
final SvnPresetCreateRequest request = new SvnPresetCreateRequest();
request.setName("project");
request.setUrl("https://svn.example.com/project");
request.setSvnUsername("user1");
request.setSvnPassword("original-password");
final SvnPresetManageItem created = service.create(request);
final String encryptedBefore = repoService.getById(created.getId()).getSvnPasswordEncrypted();
// Update without password — should not overwrite
final SvnPresetUpdateRequest updateReq = new SvnPresetUpdateRequest();
updateReq.setName("updated-project");
updateReq.setUrl("https://svn.example.com/updated");
updateReq.setSvnUsername("user2");
// svnPassword is null — should not overwrite
updateReq.setEnabled(true);
service.update(created.getId(), updateReq);
final RepositoryConfig updated = repoService.getById(created.getId());
Assertions.assertEquals("updated-project", updated.getName());
Assertions.assertEquals("https://svn.example.com/updated", updated.getSvnUrl());
Assertions.assertEquals("user2", updated.getSvnUsername());
// Password should remain unchanged
Assertions.assertEquals(encryptedBefore, updated.getSvnPasswordEncrypted());
Assertions.assertEquals("original-password", CryptoUtils.decrypt(updated.getSvnPasswordEncrypted()));
}
@Test
public void shouldNotOverwriteUsernameWithEmptyString() throws Exception {
final Path tempDir = Files.createTempDirectory("svn-preset-username-empty-test");
final RepositoryConfigService repoService = createRepoService(tempDir);
final SvnPresetService service = new SvnPresetService(repoService);
final SvnPresetCreateRequest request = new SvnPresetCreateRequest();
request.setName("project");
request.setUrl("https://svn.example.com/project");
request.setSvnUsername("existing-user");
request.setSvnPassword("pass");
final SvnPresetManageItem created = service.create(request);
// Update with empty username — should NOT overwrite
final SvnPresetUpdateRequest updateReq = new SvnPresetUpdateRequest();
updateReq.setSvnUsername("");
service.update(created.getId(), updateReq);
final RepositoryConfig updated = repoService.getById(created.getId());
Assertions.assertEquals("existing-user", updated.getSvnUsername());
}
@Test
public void shouldNotOverwriteUsernameWithWhitespace() throws Exception {
final Path tempDir = Files.createTempDirectory("svn-preset-username-whitespace-test");
final RepositoryConfigService repoService = createRepoService(tempDir);
final SvnPresetService service = new SvnPresetService(repoService);
final SvnPresetCreateRequest request = new SvnPresetCreateRequest();
request.setName("project");
request.setUrl("https://svn.example.com/project");
request.setSvnUsername("existing-user");
request.setSvnPassword("pass");
final SvnPresetManageItem created = service.create(request);
// Update with whitespace-only username — should NOT overwrite
final SvnPresetUpdateRequest updateReq = new SvnPresetUpdateRequest();
updateReq.setSvnUsername(" ");
service.update(created.getId(), updateReq);
final RepositoryConfig updated = repoService.getById(created.getId());
Assertions.assertEquals("existing-user", updated.getSvnUsername());
}
@Test
public void shouldUpdateUsernameWhenValid() throws Exception {
final Path tempDir = Files.createTempDirectory("svn-preset-username-valid-test");
final RepositoryConfigService repoService = createRepoService(tempDir);
final SvnPresetService service = new SvnPresetService(repoService);
final SvnPresetCreateRequest request = new SvnPresetCreateRequest();
request.setName("project");
request.setUrl("https://svn.example.com/project");
request.setSvnUsername("old-user");
request.setSvnPassword("pass");
final SvnPresetManageItem created = service.create(request);
// Update with valid non-empty username
final SvnPresetUpdateRequest updateReq = new SvnPresetUpdateRequest();
updateReq.setSvnUsername("new-user");
service.update(created.getId(), updateReq);
final RepositoryConfig updated = repoService.getById(created.getId());
Assertions.assertEquals("new-user", updated.getSvnUsername());
}
@Test
public void shouldClearPasswordWhenClearSvnPasswordIsTrue() throws Exception {
final Path tempDir = Files.createTempDirectory("svn-preset-clear-pwd-test");
final RepositoryConfigService repoService = createRepoService(tempDir);
final SvnPresetService service = new SvnPresetService(repoService);
final SvnPresetCreateRequest request = new SvnPresetCreateRequest();
request.setName("project");
request.setUrl("https://svn.example.com/project");
request.setSvnPassword("sekret");
final SvnPresetManageItem created = service.create(request);
// Clear password
final SvnPresetUpdateRequest updateReq = new SvnPresetUpdateRequest();
updateReq.setClearSvnPassword(true);
service.update(created.getId(), updateReq);
final RepositoryConfig updated = repoService.getById(created.getId());
Assertions.assertTrue(updated.getSvnPasswordEncrypted() == null
|| updated.getSvnPasswordEncrypted().isEmpty());
Assertions.assertFalse(service.listManage().get(0).isSvnCredentialsConfigured());
}
@Test
public void shouldSoftDeletePreset() throws Exception {
final Path tempDir = Files.createTempDirectory("svn-preset-delete-test");
final RepositoryConfigService repoService = createRepoService(tempDir);
final SvnPresetService service = new SvnPresetService(repoService);
final SvnPresetCreateRequest request = new SvnPresetCreateRequest();
request.setName("to-delete");
request.setUrl("https://svn.example.com/todelete");
final SvnPresetManageItem created = service.create(request);
Assertions.assertTrue(created.isEnabled());
service.delete(created.getId());
// After soft delete, disabled item should not appear in summary list
final java.util.List<com.svnlog.web.model.SvnPresetSummary> summaries = service.listPresetSummaries();
Assertions.assertTrue(summaries.isEmpty());
// But still visible in manage list with enabled=false
final SvnPresetManageItem manageItem = service.listManage().get(0);
Assertions.assertFalse(manageItem.isEnabled());
}
@Test
public void shouldReturnEmptyWhenNoPresetsConfigured() throws Exception {
final Path tempDir = Files.createTempDirectory("svn-preset-empty-test");
final RepositoryConfigService repoService = createRepoService(tempDir);
final SvnPresetService service = new SvnPresetService(repoService);
Assertions.assertTrue(service.listPresetSummaries().isEmpty());
Assertions.assertTrue(service.listManage().isEmpty());
Assertions.assertEquals("", service.firstPresetId());
}
}