feat: 完善多模型配置与运行切换

将模型连接统一持久化管理,并支持 Agent 运行中切换模型以及中断后选择模型继续。补充配置校验、事务与前后端交互测试。
This commit is contained in:
Zhu Junhao
2026-09-03 10:46:25 +08:00
parent e968a8ddc1
commit 988c0fe555
36 changed files with 1715 additions and 190 deletions

View File

@@ -1,6 +1,7 @@
package tech.easyflow.manuagent;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -373,7 +374,7 @@ class DatabaseAndEventIntegrationTest {
}
/**
* 验证模型新增、保留旧密钥更新、默认分配及密钥解密读取
* 验证多个模型持久化、首模型自动默认、保留旧密钥更新以及默认模型切换
*
* @throws Exception Mapper XML 初始化失败时抛出
*/
@@ -386,24 +387,19 @@ class DatabaseAndEventIntegrationTest {
when(users.requireUserId("admin")).thenReturn(userId);
AppProperties properties = new AppProperties(
temporaryDirectory,
temporaryDirectory.resolve("deepseek.key"),
temporaryDirectory.resolve("dashscope.key"),
"integration-master-key",
"admin",
"admin",
"https://default.example.test",
"default-model",
131_072,
"runtime:test",
"bridge",
Duration.ofMinutes(1));
ModelService service = new ModelService(
modelConfigMapper(),
modelAssignmentMapper(),
mock(AppUserMapper.class),
agentRunMapper(),
users,
new KeyCipher(properties),
properties,
new ObjectMapper());
Map<String, Object> capabilities = Map.of(
"toolCalling", true, "reasoning", true, "contextWindow", 65_536);
@@ -420,16 +416,61 @@ class DatabaseAndEventIntegrationTest {
"测试模型更新", "https://model.example.test", "model-v2", "",
Map.of("timeoutSeconds", 120), capabilities),
() -> "admin");
service.setDefault(created.id(), () -> "admin");
ModelService.ModelSecret secret = service.defaultModelSecret();
ModelService.ModelView second = service.save(
null,
new ModelService.ModelInput(
"第二测试模型", "https://second-model.example.test", "model-second", "secret-5678",
Map.of("timeoutSeconds", 90), capabilities),
() -> "admin");
assertThat(created.defaultModel()).isTrue();
assertThat(second.defaultModel()).isFalse();
service.setDefault(second.id(), () -> "admin");
ModelService.ModelSecret firstSecret = service.requireRuntimeModel(created.id());
ModelService.ModelSecret defaultSecret = service.defaultModelSecret();
assertThat(updated.name()).isEqualTo("测试模型更新");
assertThat(updated.apiKeyHint()).endsWith("1234");
assertThat(secret.apiKey()).isEqualTo("secret-1234");
assertThat(secret.modelId()).isEqualTo("model-v2");
assertThat(secret.contextWindow()).isEqualTo(65_536);
assertThat(firstSecret.apiKey()).isEqualTo("secret-1234");
assertThat(firstSecret.modelId()).isEqualTo("model-v2");
assertThat(defaultSecret.apiKey()).isEqualTo("secret-5678");
assertThat(defaultSecret.modelId()).isEqualTo("model-second");
assertThat(defaultSecret.contextWindow()).isEqualTo(65_536);
assertThat(jdbc().sql("SELECT COUNT(*) FROM app.model_assignment WHERE model_config_id = :id")
.param("id", created.id()).query(Long.class).single()).isEqualTo(3L);
.param("id", second.id()).query(Long.class).single()).isEqualTo(3L);
assertThatThrownBy(() -> service.setEnabled(second.id(), false))
.isInstanceOfSatisfying(tech.easyflow.manuagent.common.ApiException.class, exception ->
assertThat(exception.code()).isEqualTo("DEFAULT_MODEL_REQUIRED"));
ModelService.ModelView disabled = service.setEnabled(created.id(), false);
assertThat(disabled.enabled()).isFalse();
// 已完成 Run 仍属于历史审计事实;即使模型已经停用,也必须阻止真删除。
UUID historyProjectId = UUID.randomUUID();
UUID historyRunId = UUID.randomUUID();
jdbc().sql("""
INSERT INTO app.project(id, company_name, project_name, agui_thread_id, application_level, created_by)
VALUES (:id, '模型历史企业', '模型历史测试', :threadId, 'ADVANCED', :userId)
""").param("id", historyProjectId).param("threadId", "thread-" + historyProjectId)
.param("userId", userId).update();
jdbc().sql("""
INSERT INTO app.agent_run(
id, project_id, model_config_id, trigger_type, status, trace_id, ended_at)
VALUES (:id, :projectId, :modelId, 'INITIAL', 'COMPLETED', :traceId, CURRENT_TIMESTAMP)
""").param("id", historyRunId).param("projectId", historyProjectId)
.param("modelId", created.id()).param("traceId", "trace-" + historyRunId).update();
assertThatThrownBy(() -> service.delete(created.id()))
.isInstanceOfSatisfying(tech.easyflow.manuagent.common.ApiException.class, exception ->
assertThat(exception.code()).isEqualTo("MODEL_HISTORY_EXISTS"));
// 清理本测试创建的引用后,再验证从未被历史 Run 使用的模型仍可按原有规则删除。
jdbc().sql("DELETE FROM app.agent_run WHERE id = :id").param("id", historyRunId).update();
jdbc().sql("DELETE FROM app.project WHERE id = :id").param("id", historyProjectId).update();
service.delete(created.id());
assertThat(jdbc().sql("SELECT COUNT(*) FROM app.model_config WHERE id = :id")
.param("id", created.id()).query(Long.class).single()).isZero();
}
/**
@@ -442,14 +483,10 @@ class DatabaseAndEventIntegrationTest {
SkillConfigMapper mapper = skillConfigMapper();
AppProperties properties = new AppProperties(
temporaryDirectory,
temporaryDirectory.resolve("deepseek.key"),
temporaryDirectory.resolve("dashscope.key"),
"integration-master-key",
"admin",
"admin",
"https://default.example.test",
"default-model",
131_072,
"runtime:test",
"bridge",
Duration.ofMinutes(1));

View File

@@ -28,8 +28,8 @@ class KeyCipherAndShellTest {
private AppProperties properties() {
return new AppProperties(
Path.of("data"), Path.of("deepseek"), Path.of("dashscope"),
"unit-test-master", "admin", "admin123", "https://api.example.test", "model",
131_072, "smart-factory-agent-runtime:test", "bridge", Duration.ofMinutes(1));
Path.of("data"), Path.of("dashscope"),
"unit-test-master", "admin", "admin123",
"smart-factory-agent-runtime:test", "bridge", Duration.ofMinutes(1));
}
}

View File

@@ -33,6 +33,7 @@ import tech.easyflow.manuagent.config.MyBatisFlexConfiguration;
import tech.easyflow.manuagent.mapper.AppUserMapper;
import tech.easyflow.manuagent.mapper.ModelAssignmentMapper;
import tech.easyflow.manuagent.mapper.ModelConfigMapper;
import tech.easyflow.manuagent.mapper.AgentRunMapper;
import tech.easyflow.manuagent.model.KeyCipher;
import tech.easyflow.manuagent.model.ModelService;
@@ -91,7 +92,7 @@ class MyBatisFlexTransactionIntegrationTest {
UUID userId = UUID.randomUUID();
UUID missingUserId = UUID.randomUUID();
UUID currentDefaultId = UUID.randomUUID();
UUID disabledTargetId = UUID.randomUUID();
UUID enabledTargetId = UUID.randomUUID();
jdbc.sql("""
INSERT INTO app.app_user(id, username, password_hash, display_name)
VALUES (:id, :username, 'encoded', '事务测试用户')
@@ -106,12 +107,12 @@ class MyBatisFlexTransactionIntegrationTest {
(:currentId, :currentName, 'OPENAI_COMPATIBLE', 'https://current.test',
'current-model', TRUE, TRUE),
(:targetId, :targetName, 'OPENAI_COMPATIBLE', 'https://target.test',
'target-model', FALSE, FALSE)
'target-model', TRUE, FALSE)
""")
.param("currentId", currentDefaultId)
.param("currentName", "current-" + currentDefaultId)
.param("targetId", disabledTargetId)
.param("targetName", "target-" + disabledTargetId)
.param("targetId", enabledTargetId)
.param("targetName", "target-" + enabledTargetId)
.update();
UserService users = context.getBean(UserService.class);
when(users.requireUserId("admin")).thenReturn(missingUserId);
@@ -119,7 +120,7 @@ class MyBatisFlexTransactionIntegrationTest {
assertThat(context.getBeansOfType(PlatformTransactionManager.class)).hasSize(1);
assertThat(AopUtils.isAopProxy(modelService)).isTrue();
assertThatThrownBy(() -> modelService.setDefault(disabledTargetId, () -> "admin"))
assertThatThrownBy(() -> modelService.setDefault(enabledTargetId, () -> "admin"))
.hasRootCauseInstanceOf(PSQLException.class);
assertThat(jdbc.sql("SELECT is_default FROM app.model_config WHERE id = :id")
@@ -127,7 +128,7 @@ class MyBatisFlexTransactionIntegrationTest {
.query(Boolean.class)
.single()).isTrue();
assertThat(jdbc.sql("SELECT is_default FROM app.model_config WHERE id = :id")
.param("id", disabledTargetId)
.param("id", enabledTargetId)
.query(Boolean.class)
.single()).isFalse();
});
@@ -152,15 +153,14 @@ class MyBatisFlexTransactionIntegrationTest {
ModelService modelService(
ModelConfigMapper modelMapper,
ModelAssignmentMapper assignmentMapper,
AppUserMapper userMapper,
AgentRunMapper runMapper,
UserService userService) {
return new ModelService(
modelMapper,
assignmentMapper,
userMapper,
runMapper,
userService,
mock(KeyCipher.class),
mock(AppProperties.class),
new ObjectMapper());
}
}

View File

@@ -1,14 +1,62 @@
package tech.easyflow.manuagent.agent;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.agentscope.core.agui.adapter.AguiAgentAdapter;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import tech.easyflow.manuagent.project.ProjectFileService;
import tech.easyflow.manuagent.project.ProjectService;
import tech.easyflow.manuagent.skill.SkillService;
/**
* 验证 Agent 事件持久化的精简规则。
*/
class AgentExecutionServiceTest {
/**
* Run 在创建时已经固化模型配置 ID执行和模型重连都必须沿用该 ID
* 不能在工厂内部重新读取可能已经变化的全局默认模型。
*/
@Test
void shouldCreateAgentWithModelBoundToRun() {
UUID projectId = UUID.randomUUID();
UUID runId = UUID.randomUUID();
UUID modelId = UUID.randomUUID();
AgentFactory factory = mock(AgentFactory.class);
AgentFactory.AgentHandle handle = mock(AgentFactory.AgentHandle.class);
AguiAgentAdapter adapter = mock(AguiAgentAdapter.class);
SkillService skillService = mock(SkillService.class);
when(handle.adapter()).thenReturn(adapter);
when(adapter.run(any())).thenReturn(Flux.empty());
when(skillService.enabledNames()).thenReturn(new String[] {"document"});
when(factory.create(eq(projectId), eq(modelId), any(String[].class))).thenReturn(handle);
AgentExecutionService service = new AgentExecutionService(
new ObjectMapper(),
factory,
mock(AgentEventService.class),
mock(ProjectFileService.class),
skillService);
ProjectService.ProjectView project = mock(ProjectService.ProjectView.class);
when(project.id()).thenReturn(projectId);
when(project.threadId()).thenReturn("project-" + projectId);
AgentRunService.RunView run = new AgentRunService.RunView(
runId, projectId, modelId, "INITIAL", "RUNNING", null, null, null, null);
service.execute(project, run, "执行测试", Mono.never(), () -> { }, () -> false);
verify(factory).create(eq(projectId), eq(modelId), any(String[].class));
}
/**
* 验证文档视觉结果保留图片路径元数据并丢弃 Base64 正文。
*/

View File

@@ -1,20 +1,87 @@
package tech.easyflow.manuagent.agent;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.agentscope.core.model.transport.HttpTransportException;
import io.agentscope.core.skill.AgentSkill;
import java.nio.file.Path;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import org.junit.jupiter.api.Test;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionTemplate;
import tech.easyflow.manuagent.artifact.ArtifactService;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.mapper.AgentRunMapper;
import tech.easyflow.manuagent.project.ProjectFileService;
import tech.easyflow.manuagent.project.ProjectService;
/**
* 验证 Agent Run 的模型重连边界。
*/
class AgentRunServiceTest {
/**
* 运行中模型配置被编辑后,用户应能选择同一模型 ID 创建新的恢复 Run
* 使新 Agent 客户端重新读取数据库中的最新地址、模型标识和密钥。
*/
@Test
void shouldRestartRunningTaskWhenSameModelConfigurationWasUpdated() {
UUID projectId = UUID.randomUUID();
UUID modelId = UUID.randomUUID();
UUID currentRunId = UUID.randomUUID();
UUID replacementRunId = UUID.randomUUID();
OffsetDateTime now = OffsetDateTime.now();
ProjectService.ProjectView project = new ProjectService.ProjectView(
projectId, "测试企业", "测试项目", "thread-1", "ADVANCED", "MATERIAL_CHECK", 0L, now, now);
AgentRunService.RunView current = new AgentRunService.RunView(
currentRunId, projectId, modelId, "INITIAL", "RUNNING", null, null, now, null);
AgentRunService.RunView replacement = new AgentRunService.RunView(
replacementRunId, projectId, modelId, "RESUME", "RUNNING", null, null, now, null);
AgentRunMapper runMapper = mock(AgentRunMapper.class);
AgentRunStore runStore = mock(AgentRunStore.class);
ProjectService projectService = mock(ProjectService.class);
UserService userService = mock(UserService.class);
when(projectService.require(projectId)).thenReturn(project);
when(userService.requireUserId("admin")).thenReturn(UUID.randomUUID());
when(runStore.latest(projectId)).thenReturn(current);
when(runStore.interruptedPhase(current, project)).thenReturn("MATERIAL_CHECK");
when(runMapper.interruptRunning(currentRunId)).thenReturn(1);
when(runStore.create(projectId, "RESUME", currentRunId, modelId)).thenReturn(replacement);
AgentRunService service = new AgentRunService(
runMapper,
new ObjectMapper(),
mock(AgentExecutionService.class),
mock(AgentOutputService.class),
runStore,
mock(AgentEventService.class),
projectService,
mock(ProjectFileService.class),
userService,
mock(ArtifactService.class),
mock(ExecutorService.class),
mock(TransactionTemplate.class));
// 服务会注册“提交后取消旧流并启动新流”的回调;测试只验证注册前的事务内状态转换。
TransactionSynchronizationManager.initSynchronization();
try {
assertThat(service.switchModel(projectId, modelId, () -> "admin")).isEqualTo(replacement);
verify(runMapper).interruptRunning(currentRunId);
verify(runStore).create(projectId, "RESUME", currentRunId, modelId);
} finally {
TransactionSynchronizationManager.clearSynchronization();
}
}
/**
* 网络故障和服务端错误允许重连,参数错误保持原始失败。
*/

View File

@@ -14,6 +14,7 @@ import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import tech.easyflow.manuagent.entity.AgentRunEntity;
import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.entity.ModelConfigEntity;
import tech.easyflow.manuagent.mapper.AgentEventMapper;
import tech.easyflow.manuagent.mapper.AgentRunMapper;
import tech.easyflow.manuagent.mapper.ModelConfigMapper;
@@ -49,10 +50,11 @@ class AgentRunStoreQueryTest {
}
/**
* 默认模型缺失属于数据库配置异常,不应在 ORM 迁移中新增 409 业务错误
* 数据库允许在首次启动时没有模型,因此启动 Run 时应返回可操作的业务错误
* 不能把正常的“尚未配置”状态暴露为服务端技术异常。
*/
@Test
void shouldKeepMissingDefaultModelAsUnexpectedTechnicalFailure() {
void shouldReportMissingDefaultModelAsConfigurationConflict() {
AgentRunMapper runMapper = mock(AgentRunMapper.class);
when(runMapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(0L);
AgentRunStore store = new AgentRunStore(
@@ -62,8 +64,49 @@ class AgentRunStoreQueryTest {
new ObjectMapper());
assertThatThrownBy(() -> store.create(UUID.randomUUID(), "INITIAL", null))
.isInstanceOf(IllegalStateException.class)
.isNotInstanceOf(ApiException.class);
.isInstanceOfSatisfying(ApiException.class, exception -> {
assertThat(exception.status().value()).isEqualTo(409);
assertThat(exception.code()).isEqualTo("MODEL_NOT_CONFIGURED");
});
}
/**
* 用户恢复任务时可以显式选择替代模型;新 Run 必须保存该模型 ID
* 不能再次回退到随后可能变化的全局默认模型。
*/
@Test
void shouldCreateRunWithExplicitEnabledModel() {
UUID projectId = UUID.randomUUID();
UUID modelId = UUID.randomUUID();
AgentRunMapper runMapper = mock(AgentRunMapper.class);
ModelConfigMapper modelMapper = mock(ModelConfigMapper.class);
when(runMapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(0L);
ModelConfigEntity selectedModel = new ModelConfigEntity();
selectedModel.setId(modelId);
selectedModel.setEnabled(true);
when(modelMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(selectedModel);
AgentRunEntity storedRun = new AgentRunEntity();
storedRun.setId(UUID.randomUUID());
storedRun.setProjectId(projectId);
storedRun.setModelConfigId(modelId);
storedRun.setTriggerType("RESUME");
storedRun.setStatus("RUNNING");
when(runMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(storedRun);
AgentRunStore store = new AgentRunStore(
runMapper,
mock(AgentEventMapper.class),
modelMapper,
new ObjectMapper());
AgentRunService.RunView run = store.create(projectId, "RESUME", null, modelId);
ArgumentCaptor<AgentRunEntity> entityCaptor = ArgumentCaptor.forClass(AgentRunEntity.class);
verify(runMapper).insertSelective(entityCaptor.capture());
assertThat(entityCaptor.getValue().getModelConfigId()).isEqualTo(modelId);
assertThat(run.modelConfigId()).isEqualTo(modelId);
}
/**

View File

@@ -0,0 +1,72 @@
package tech.easyflow.manuagent.config;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.core.env.PropertySource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.context.annotation.Configuration;
/**
* 验证应用主密钥沿用本地配置文件的读取方式。
*/
class AppPropertiesValidationTest {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withUserConfiguration(PropertiesConfiguration.class)
.withPropertyValues(
"app.data-root=file:../data",
"app.dashscope-key-file=./dashscope_key.txt",
"app.master-key=unit-test-master-key",
"app.admin-username=admin",
"app.admin-password=admin123",
"app.sandbox-image=runtime:test",
"app.sandbox-network=bridge",
"app.run-timeout=1m");
/**
* 默认应用配置必须直接提供主密钥,使本地启动不依赖额外环境变量。
*
* @throws IOException application.yml 无法读取时抛出
*/
@Test
void shouldProvideMasterKeyInApplicationConfiguration() throws IOException {
YamlPropertySourceLoader loader = new YamlPropertySourceLoader();
List<PropertySource<?>> sources = loader.load(
"application.yml",
new ClassPathResource("application.yml"));
assertThat(sources)
.extracting(source -> source.getProperty("app.master-key"))
.singleElement()
.isInstanceOf(String.class)
.asString()
.isNotBlank()
.doesNotContain("APP_MASTER_KEY");
}
/**
* 提供非空加密主密钥后,配置属性应可以正常绑定并供密钥组件使用。
*/
@Test
void shouldBindConfiguredMasterKey() {
contextRunner.run(context -> {
assertThat(context).hasNotFailed();
assertThat(context.getBean(AppProperties.class).masterKey())
.isEqualTo("unit-test-master-key");
});
}
/**
* 注册应用配置属性,复用生产环境的 Spring Boot 配置绑定流程。
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(AppProperties.class)
static class PropertiesConfiguration {
}
}

View File

@@ -0,0 +1,76 @@
package tech.easyflow.manuagent.model;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
/**
* 验证模型管理页面依赖的 HTTP 接口契约。
*
* <p>这些测试刻意放在控制器边界,防止前端请求方法或路径与后端映射再次发生漂移。</p>
*/
class ModelControllerTest {
/**
* 测试连接必须接收当前表单草稿,使用户无需先持久化可能无效的配置。
*
* @throws Exception MockMvc 执行失败时抛出
*/
@Test
void shouldTestCurrentModelDraftWithoutSavingIt() throws Exception {
ModelService service = mock(ModelService.class);
when(service.test(any(ModelService.ConnectionTestInput.class)))
.thenReturn(new ModelService.ConnectionResult(true, 12L, "连接正常"));
MockMvc mvc = MockMvcBuilders.standaloneSetup(new ModelController(service)).build();
mvc.perform(post("/api/models/test")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"id": "01cdd509-a79a-4503-839f-160b32d518e2",
"baseUrl": "https://draft.example.test/v1",
"modelId": "draft-model",
"apiKey": "draft-secret"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("连接正常"));
verify(service).test(new ModelService.ConnectionTestInput(
UUID.fromString("01cdd509-a79a-4503-839f-160b32d518e2"),
"https://draft.example.test/v1",
"draft-model",
"draft-secret"));
}
/**
* 停用操作必须由 PATCH 状态接口处理,不能落入静态资源处理器并返回 404。
*
* @throws Exception MockMvc 执行失败时抛出
*/
@Test
void shouldRouteDisableRequestToModelService() throws Exception {
UUID id = UUID.fromString("01cdd509-a79a-4503-839f-160b32d518e2");
ModelService service = mock(ModelService.class);
MockMvc mvc = MockMvcBuilders.standaloneSetup(new ModelController(service)).build();
mvc.perform(patch("/api/models/{id}/enabled", id)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"enabled\":false}"))
.andExpect(status().isOk());
verify(service).setEnabled(id, false);
}
}

View File

@@ -0,0 +1,201 @@
package tech.easyflow.manuagent.model;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.entity.ModelConfigEntity;
import tech.easyflow.manuagent.mapper.AgentRunMapper;
import tech.easyflow.manuagent.mapper.ModelAssignmentMapper;
import tech.easyflow.manuagent.mapper.ModelConfigMapper;
/**
* 验证模型草稿连接测试的密钥选择和外部请求边界。
*/
class ModelServiceConnectionTest {
/**
* Spring 容器必须能够在生产构造器和包内测试构造器之间选择生产构造器。
*
* <p>该测试防止新增辅助构造器后,应用启动阶段退化为查找不存在的无参构造器。</p>
*/
@Test
void shouldCreateModelServiceBeanWithProductionConstructor() {
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
context.registerBean(ModelConfigMapper.class, () -> mock(ModelConfigMapper.class));
context.registerBean(ModelAssignmentMapper.class, () -> mock(ModelAssignmentMapper.class));
context.registerBean(AgentRunMapper.class, () -> mock(AgentRunMapper.class));
context.registerBean(UserService.class, () -> mock(UserService.class));
context.registerBean(KeyCipher.class, () -> mock(KeyCipher.class));
context.registerBean(ObjectMapper.class, () -> new ObjectMapper());
context.register(ModelService.class);
context.refresh();
assertThat(context.getBean(ModelService.class)).isNotNull();
}
}
/**
* 表单提供新密钥时,应直接测试草稿地址,且不能读取或修改数据库模型配置。
*
* @throws Exception HTTP 客户端桩配置或调用失败时抛出
*/
@Test
void shouldTestDraftWithSubmittedKeyWithoutReadingDatabase() throws Exception {
ModelConfigMapper modelMapper = mock(ModelConfigMapper.class);
HttpClient httpClient = successfulHttpClient();
ModelService service = service(modelMapper, mock(KeyCipher.class), httpClient);
ModelService.ConnectionResult result = service.test(new ModelService.ConnectionTestInput(
UUID.randomUUID(),
"https://draft.example.test/v1/",
"draft-model",
"draft-secret"));
assertThat(result.success()).isTrue();
HttpRequest request = sentRequest(httpClient);
assertThat(request.uri()).isEqualTo(URI.create("https://draft.example.test/v1/chat/completions"));
assertThat(request.headers().firstValue("Authorization")).contains("Bearer draft-secret");
verifyNoInteractions(modelMapper);
}
/**
* 新模型没有数据库 ID 和保存密钥API Key 留空时应在发起网络请求前返回明确错误。
*/
@Test
void shouldRequireKeyWhenTestingNewModelDraft() {
ModelConfigMapper modelMapper = mock(ModelConfigMapper.class);
HttpClient httpClient = mock(HttpClient.class);
ModelService service = service(modelMapper, mock(KeyCipher.class), httpClient);
assertThatThrownBy(() -> service.test(new ModelService.ConnectionTestInput(
null,
"https://draft.example.test/v1",
"draft-model",
"")))
.isInstanceOf(ApiException.class)
.hasMessage("测试新模型需要 API Key");
verifyNoInteractions(modelMapper, httpClient);
}
/**
* 草稿改到其他 API 主机时不能转发数据库中的隐藏密钥,必须要求用户重新输入 Key。
*/
@Test
void shouldNotForwardStoredKeyToChangedBaseUrl() {
UUID modelId = UUID.randomUUID();
ModelConfigEntity saved = new ModelConfigEntity();
saved.setId(modelId);
saved.setBaseUrl("https://saved.example.test/v1");
saved.setApiKeyCiphertext("encrypted-secret".getBytes(java.nio.charset.StandardCharsets.UTF_8));
ModelConfigMapper modelMapper = mock(ModelConfigMapper.class);
when(modelMapper.selectOneByQuery(any())).thenReturn(saved);
HttpClient httpClient = mock(HttpClient.class);
ModelService service = service(modelMapper, mock(KeyCipher.class), httpClient);
assertThatThrownBy(() -> service.test(new ModelService.ConnectionTestInput(
modelId,
"https://attacker.example.test/v1",
"draft-model",
"")))
.isInstanceOf(ApiException.class)
.hasMessage("API 地址变更后需要重新输入 API Key");
verifyNoInteractions(httpClient);
}
/**
* 草稿地址与保存地址一致时,允许安全复用保存密钥,避免用户为普通模型参数调整重复输入 Key。
*
* @throws Exception HTTP 客户端桩配置或调用失败时抛出
*/
@Test
void shouldReuseStoredKeyWhenBaseUrlIsUnchanged() throws Exception {
UUID modelId = UUID.randomUUID();
byte[] ciphertext = "encrypted-secret".getBytes(java.nio.charset.StandardCharsets.UTF_8);
ModelConfigEntity saved = new ModelConfigEntity();
saved.setId(modelId);
saved.setBaseUrl("https://saved.example.test/v1");
saved.setApiKeyCiphertext(ciphertext);
ModelConfigMapper modelMapper = mock(ModelConfigMapper.class);
when(modelMapper.selectOneByQuery(any())).thenReturn(saved);
KeyCipher keyCipher = mock(KeyCipher.class);
when(keyCipher.decrypt(ciphertext)).thenReturn("stored-secret");
HttpClient httpClient = successfulHttpClient();
ModelService service = service(modelMapper, keyCipher, httpClient);
ModelService.ConnectionResult result = service.test(new ModelService.ConnectionTestInput(
modelId,
"https://saved.example.test/v1/",
"updated-model-id",
""));
assertThat(result.success()).isTrue();
assertThat(sentRequest(httpClient).headers().firstValue("Authorization"))
.contains("Bearer stored-secret");
}
/**
* 创建仅替换外部依赖的模型服务,生产逻辑仍由真实 {@link ModelService} 执行。
*
* @param modelMapper 模型配置 Mapper 桩
* @param keyCipher 密钥组件桩
* @param httpClient HTTP 客户端桩
* @return 待测试模型服务
*/
private ModelService service(ModelConfigMapper modelMapper, KeyCipher keyCipher, HttpClient httpClient) {
return new ModelService(
modelMapper,
mock(ModelAssignmentMapper.class),
mock(AgentRunMapper.class),
mock(UserService.class),
keyCipher,
new ObjectMapper(),
httpClient);
}
/**
* 创建固定返回 HTTP 200 的客户端,避免测试访问真实模型服务。
*
* @return HTTP 客户端桩
* @throws Exception 配置泛型 send 方法桩时抛出
*/
@SuppressWarnings("unchecked")
private HttpClient successfulHttpClient() throws Exception {
HttpClient client = mock(HttpClient.class);
HttpResponse<String> response = mock(HttpResponse.class);
when(response.statusCode()).thenReturn(200);
when(client.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))).thenReturn(response);
return client;
}
/**
* 捕获服务发送的请求,以结果状态而非内部调用顺序验证连接目标和认证头。
*
* @param client HTTP 客户端桩
* @return 捕获到的请求
* @throws Exception Mockito 验证泛型 send 方法时抛出
*/
@SuppressWarnings("unchecked")
private HttpRequest sentRequest(HttpClient client) throws Exception {
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
org.mockito.Mockito.verify(client).send(captor.capture(), any(HttpResponse.BodyHandler.class));
return captor.getValue();
}
}

View File

@@ -12,6 +12,7 @@ import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.boot.ApplicationRunner;
import org.mockito.ArgumentCaptor;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.config.AppProperties;
@@ -19,12 +20,21 @@ import tech.easyflow.manuagent.entity.ModelConfigEntity;
import tech.easyflow.manuagent.mapper.AppUserMapper;
import tech.easyflow.manuagent.mapper.ModelAssignmentMapper;
import tech.easyflow.manuagent.mapper.ModelConfigMapper;
import tech.easyflow.manuagent.mapper.AgentRunMapper;
/**
* 验证模型管理接口使用最小字段投影,不把加密 API Key 读入普通请求内存。
*/
class ModelServiceQueryTest {
/**
* 模型必须完全由管理接口和数据库维护,服务启动不得再通过 Key 文件自动灌入默认模型。
*/
@Test
void shouldNotInitializeModelConfigurationFromApplicationFiles() {
assertThat(ApplicationRunner.class.isAssignableFrom(ModelService.class)).isFalse();
}
/**
* 模型列表只需要页面展示字段,查询 SQL 不得包含密文和密钥版本列。
*/
@@ -36,10 +46,9 @@ class ModelServiceQueryTest {
ModelService service = new ModelService(
modelMapper,
mock(ModelAssignmentMapper.class),
mock(AppUserMapper.class),
mock(AgentRunMapper.class),
mock(UserService.class),
mock(KeyCipher.class),
mock(AppProperties.class),
new ObjectMapper());
List<ModelService.ModelView> models = service.list();

View File

@@ -0,0 +1,93 @@
package tech.easyflow.manuagent.model;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.mapper.AgentRunMapper;
import tech.easyflow.manuagent.mapper.ModelAssignmentMapper;
import tech.easyflow.manuagent.mapper.ModelConfigMapper;
/**
* 验证模型配置在进入数据库和外部 HTTP 客户端之前具有明确、可审计的输入边界。
*/
class ModelServiceValidationTest {
/**
* 验证名称、地址、模型标识和 API Key 的长度限制,防止超长请求占用内存或触发数据库截断异常。
*/
@Test
void shouldBoundModelInputTextFields() {
Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
ModelService.ModelInput input = new ModelService.ModelInput(
"".repeat(101),
"https://example.test/" + "a".repeat(500),
"m".repeat(256),
"k".repeat(4097),
Map.of(),
Map.of("contextWindow", 8_192));
// 一次构造四个越界字段,并按属性名断言,确保每个 HTTP 入参都真正受到约束。
Set<String> invalidProperties = validator.validate(input).stream()
.map(violation -> violation.getPropertyPath().toString())
.collect(Collectors.toSet());
assertThat(invalidProperties).containsExactlyInAnyOrder("name", "baseUrl", "modelId", "apiKey");
}
/**
* 验证模型地址拒绝非 HTTP(S) 协议、缺失主机以及可能改变请求语义的用户信息、查询和片段。
*/
@Test
void shouldRejectUnsafeOrAmbiguousBaseUrls() {
ModelService service = service();
for (String baseUrl : Set.of(
"file:///etc/passwd",
"https:///v1",
"https://user:password@example.test/v1",
"https://example.test/v1?tenant=other",
"https://example.test/v1#fragment")) {
ModelService.ModelInput input = new ModelService.ModelInput(
"测试模型",
baseUrl,
"model-v1",
"secret",
Map.of(),
Map.of("contextWindow", 8_192));
assertThatThrownBy(() -> service.save(null, input, () -> "admin"))
.as("地址应被拒绝:%s", baseUrl)
.isInstanceOfSatisfying(ApiException.class, exception ->
assertThat(exception.code()).isEqualTo("MODEL_BASE_URL_INVALID"));
}
}
/**
* 创建只执行输入校验所需路径的模型服务;无效 URL 应在任何持久化写入之前被拒绝。
*
* @return 配置了当前用户的模型服务
*/
private ModelService service() {
UserService userService = mock(UserService.class);
when(userService.requireUserId("admin")).thenReturn(UUID.randomUUID());
return new ModelService(
mock(ModelConfigMapper.class),
mock(ModelAssignmentMapper.class),
mock(AgentRunMapper.class),
userService,
mock(KeyCipher.class),
new ObjectMapper());
}
}

View File

@@ -38,9 +38,9 @@ class ProjectFileServiceTest {
@Test
void shouldDeleteProjectWorkspace() throws Exception {
AppProperties properties = new AppProperties(
temporaryDirectory, Path.of("deepseek"), Path.of("dashscope"),
"test-master", "admin", "admin", "https://example.test", "model",
131_072, "runtime:test", "bridge", Duration.ofMinutes(1));
temporaryDirectory, Path.of("dashscope"),
"test-master", "admin", "admin",
"runtime:test", "bridge", Duration.ofMinutes(1));
ProjectFileService service = new ProjectFileService(
mock(ProjectFileMapper.class), mock(UserService.class), mock(ProjectService.class), properties);
UUID projectId = UUID.randomUUID();
@@ -121,8 +121,8 @@ class ProjectFileServiceTest {
*/
private AppProperties properties() {
return new AppProperties(
temporaryDirectory, Path.of("deepseek"), Path.of("dashscope"),
"test-master", "admin", "admin", "https://example.test", "model",
131_072, "runtime:test", "bridge", Duration.ofMinutes(1));
temporaryDirectory, Path.of("dashscope"),
"test-master", "admin", "admin",
"runtime:test", "bridge", Duration.ofMinutes(1));
}
}