重构:使用 MyBatis-Flex 迁移应用 ORM
将应用自管表的 JdbcClient 数据访问迁移为实体、Mapper、构造器查询和必要的显式 SQL。 保留 AgentScope 自管表及原有业务语义,并补充事务、查询与数据库集成测试。
This commit is contained in:
@@ -5,20 +5,53 @@ import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import tech.easyflow.manuagent.agent.AgentEventService;
|
||||
import tech.easyflow.manuagent.agent.AgentRunService;
|
||||
import tech.easyflow.manuagent.agent.AgentRunStore;
|
||||
import tech.easyflow.manuagent.artifact.ArtifactService;
|
||||
import tech.easyflow.manuagent.artifact.DocxValidator;
|
||||
import tech.easyflow.manuagent.auth.UserService;
|
||||
import tech.easyflow.manuagent.entity.AgentEventEntity;
|
||||
import tech.easyflow.manuagent.entity.AppUserEntity;
|
||||
import tech.easyflow.manuagent.mapper.AgentEventMapper;
|
||||
import tech.easyflow.manuagent.mapper.AgentRunMapper;
|
||||
import tech.easyflow.manuagent.mapper.AppUserMapper;
|
||||
import tech.easyflow.manuagent.mapper.ArtifactMapper;
|
||||
import tech.easyflow.manuagent.mapper.ProjectMapper;
|
||||
import tech.easyflow.manuagent.mapper.ProjectPlanMapper;
|
||||
import tech.easyflow.manuagent.mapper.ModelAssignmentMapper;
|
||||
import tech.easyflow.manuagent.mapper.ModelConfigMapper;
|
||||
import tech.easyflow.manuagent.mapper.SkillConfigMapper;
|
||||
import tech.easyflow.manuagent.model.KeyCipher;
|
||||
import tech.easyflow.manuagent.model.ModelService;
|
||||
import tech.easyflow.manuagent.config.AppProperties;
|
||||
import tech.easyflow.manuagent.skill.SkillPackageReader;
|
||||
import tech.easyflow.manuagent.skill.SkillService;
|
||||
import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository;
|
||||
import tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler;
|
||||
import tech.easyflow.manuagent.typehandler.UuidTypeHandler;
|
||||
import tech.easyflow.manuagent.project.ProjectService;
|
||||
import tech.easyflow.manuagent.project.ProjectFileService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.mybatisflex.core.MybatisFlexBootstrap;
|
||||
import com.mybatisflex.core.datasource.FlexDataSource;
|
||||
import com.mybatisflex.core.mybatis.FlexConfiguration;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import com.mybatisflex.core.table.TableInfo;
|
||||
import com.mybatisflex.core.table.TableInfoFactory;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.io.InputStream;
|
||||
import java.sql.DriverManager;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.time.Duration;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
|
||||
import org.apache.ibatis.io.Resources;
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
@@ -77,7 +110,7 @@ class DatabaseAndEventIntegrationTest {
|
||||
* 验证事件按项目全局 ID 增量回放且不重复。
|
||||
*/
|
||||
@Test
|
||||
void shouldReplayEventsAfterCursorInOrder() {
|
||||
void shouldReplayEventsAfterCursorInOrder() throws Exception {
|
||||
JdbcClient jdbc = jdbc();
|
||||
UUID userId = UUID.randomUUID();
|
||||
UUID modelId = UUID.randomUUID();
|
||||
@@ -87,7 +120,7 @@ class DatabaseAndEventIntegrationTest {
|
||||
.param("id", userId).param("name", "u-" + userId).update();
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.model_config(id, name, provider, base_url, model_id, is_default)
|
||||
VALUES (:id, :name, 'OPENAI_COMPATIBLE', 'https://example.test', 'model', TRUE)
|
||||
VALUES (:id, :name, 'OPENAI_COMPATIBLE', 'https://example.test', 'model', FALSE)
|
||||
""").param("id", modelId).param("name", "m-" + modelId).update();
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.project(id, company_name, project_name, agui_thread_id, application_level, created_by)
|
||||
@@ -99,7 +132,7 @@ class DatabaseAndEventIntegrationTest {
|
||||
""").param("id", runId).param("projectId", projectId).param("modelId", modelId)
|
||||
.param("trace", UUID.randomUUID().toString()).update();
|
||||
|
||||
AgentEventService service = new AgentEventService(jdbc, new ObjectMapper());
|
||||
AgentEventService service = new AgentEventService(agentEventMapper(), new ObjectMapper());
|
||||
long first = service.append(projectId, runId, "RUN_STARTED", Map.of("phase", "MATERIAL_CHECK")).id();
|
||||
long second = service.append(projectId, runId, "TEXT_MESSAGE_CONTENT", Map.of("delta", "分析")).id();
|
||||
long third = service.append(projectId, runId, "TEXT_MESSAGE_CONTENT", Map.of("delta", "完成")).id();
|
||||
@@ -152,7 +185,7 @@ class DatabaseAndEventIntegrationTest {
|
||||
Files.writeString(document, "first");
|
||||
ProjectFileService files = mock(ProjectFileService.class);
|
||||
when(files.safeProjectPath(projectId, "artifacts/draft.docx")).thenReturn(document);
|
||||
ArtifactService artifacts = new ArtifactService(jdbc, files, new DocxValidator());
|
||||
ArtifactService artifacts = new ArtifactService(artifactMapper(), files, new DocxValidator());
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
ArtifactService.ArtifactView first = artifacts.publish(
|
||||
@@ -173,7 +206,7 @@ class DatabaseAndEventIntegrationTest {
|
||||
* 验证项目真删除会清除所有关联业务记录。
|
||||
*/
|
||||
@Test
|
||||
void shouldDeleteProjectRecords() {
|
||||
void shouldDeleteProjectRecords() throws Exception {
|
||||
JdbcClient jdbc = jdbc();
|
||||
UUID userId = UUID.randomUUID();
|
||||
UUID projectId = UUID.randomUUID();
|
||||
@@ -213,7 +246,8 @@ class DatabaseAndEventIntegrationTest {
|
||||
""").param("id", UUID.randomUUID()).param("projectId", projectId).param("runId", runId)
|
||||
.param("sha", "0".repeat(64)).update();
|
||||
|
||||
ProjectService service = new ProjectService(jdbc, mock(UserService.class), new ObjectMapper());
|
||||
ProjectService service = new ProjectService(
|
||||
projectMapper(), mock(ProjectPlanMapper.class), mock(UserService.class), new ObjectMapper());
|
||||
service.delete(projectId);
|
||||
|
||||
for (String table : List.of("agent_event", "artifact", "project_plan", "project_file", "agent_run")) {
|
||||
@@ -229,11 +263,453 @@ class DatabaseAndEventIntegrationTest {
|
||||
.single()).isZero();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Run 创建、等待确认、完成和中断均遵守数据库状态机条件。
|
||||
*
|
||||
* <p>该测试直接覆盖 MyBatis-Flex BaseMapper 插入、XML 状态更新和实体结果映射,
|
||||
* 防止迁移后出现 UUID 主键未写入、JSONB Ask 丢失或终态被重复覆盖。</p>
|
||||
*/
|
||||
@Test
|
||||
void shouldPersistAndTransitionAgentRunWithMybatisFlex() throws Exception {
|
||||
JdbcClient jdbc = jdbc();
|
||||
UUID userId = UUID.randomUUID();
|
||||
UUID modelId = UUID.randomUUID();
|
||||
UUID projectId = UUID.randomUUID();
|
||||
jdbc.sql("INSERT INTO app.app_user(id, username, password_hash, display_name) VALUES (:id, :name, 'x', 'test')")
|
||||
.param("id", userId).param("name", "run-u-" + userId).update();
|
||||
// 测试类共用同一容器;先释放其他用例留下的唯一默认模型,再建立本用例的确定性前置条件。
|
||||
jdbc.sql("UPDATE app.model_config SET is_default = FALSE WHERE is_default").update();
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.model_config(id, name, provider, base_url, model_id, is_default)
|
||||
VALUES (:id, :name, 'OPENAI_COMPATIBLE', 'https://example.test', 'model', TRUE)
|
||||
""").param("id", modelId).param("name", "run-m-" + modelId).update();
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.project(id, company_name, project_name, agui_thread_id, application_level, created_by)
|
||||
VALUES (:id, '企业', 'Run 迁移测试', :threadId, 'ADVANCED', :userId)
|
||||
""").param("id", projectId).param("threadId", "run-thread-" + projectId)
|
||||
.param("userId", userId).update();
|
||||
|
||||
AgentRunMapper mapper = agentRunMapper();
|
||||
AgentEventMapper events = agentEventMapper();
|
||||
AgentRunStore store = new AgentRunStore(mapper, events, modelConfigMapper(), new ObjectMapper());
|
||||
AgentRunService.RunView initial = store.create(projectId, "INITIAL", null);
|
||||
assertThat(initial.status()).isEqualTo("RUNNING");
|
||||
assertThat(store.latest(projectId).id()).isEqualTo(initial.id());
|
||||
store.ensureRunning(initial.id());
|
||||
|
||||
String interrupt = "{\"kind\":\"material_check\",\"items\":[]}";
|
||||
assertThat(mapper.waitForInput(initial.id(), interrupt)).isEqualTo(1);
|
||||
assertThat(new ObjectMapper().readTree(
|
||||
store.requireWaiting(projectId, "material_check").pendingInterrupt()))
|
||||
.isEqualTo(new ObjectMapper().readTree(interrupt));
|
||||
store.completeWaiting(initial.id());
|
||||
assertThat(store.require(initial.id()).status()).isEqualTo("COMPLETED");
|
||||
|
||||
AgentRunService.RunView resumed = store.create(projectId, "RESUME", initial.id());
|
||||
AgentEventEntity started = new AgentEventEntity();
|
||||
started.setProjectId(projectId);
|
||||
started.setRunId(resumed.id());
|
||||
started.setEventType("RUN_STARTED");
|
||||
started.setEventId(UUID.randomUUID().toString());
|
||||
started.setPayloadJson("{\"phase\":\"PLANNING\"}");
|
||||
events.insertReturning(started);
|
||||
AgentEventEntity response = new AgentEventEntity();
|
||||
response.setProjectId(projectId);
|
||||
response.setRunId(resumed.id());
|
||||
response.setEventType("ASK_RESPONDED");
|
||||
response.setEventId(UUID.randomUUID().toString());
|
||||
response.setPayloadJson("{\"decisions\":[]}");
|
||||
events.insertReturning(response);
|
||||
assertThat(events.selectLatestStartedPhase(resumed.id())).isEqualTo("PLANNING");
|
||||
assertThat(new ObjectMapper().readTree(events.selectLatestMaterialResponseJson(projectId)))
|
||||
.isEqualTo(new ObjectMapper().readTree("{\"decisions\":[]}"));
|
||||
|
||||
assertThat(mapper.interruptRunning(resumed.id())).isEqualTo(1);
|
||||
assertThat(mapper.interruptRunning(resumed.id())).isZero();
|
||||
assertThat(store.isInterrupted(resumed.id())).isTrue();
|
||||
|
||||
AgentRunService.RunView restartCandidate = store.create(projectId, "RETRY", resumed.id());
|
||||
assertThat(mapper.interruptRunningAfterRestart()).isGreaterThanOrEqualTo(1);
|
||||
assertThat(mapper.selectOneById(restartCandidate.id()).getErrorCode()).isEqualTo("PROCESS_RESTARTED");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证规划草稿使用递增版本写入 JSONB,并且只有 DRAFT 可以原子确认。
|
||||
*
|
||||
* @throws Exception Mapper XML 初始化失败时抛出
|
||||
*/
|
||||
@Test
|
||||
void shouldSaveAndConfirmProjectPlanWithMyBatisFlex() throws Exception {
|
||||
JdbcClient jdbc = jdbc();
|
||||
UUID userId = UUID.randomUUID();
|
||||
UUID projectId = UUID.randomUUID();
|
||||
jdbc.sql("INSERT INTO app.app_user(id, username, password_hash, display_name) VALUES (:id, :name, 'x', 'test')")
|
||||
.param("id", userId).param("name", "plan-u-" + userId).update();
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.project(id, company_name, project_name, agui_thread_id, application_level, created_by)
|
||||
VALUES (:id, '规划企业', '规划项目', :thread, 'ADVANCED', :userId)
|
||||
""").param("id", projectId).param("thread", "plan-t-" + projectId).param("userId", userId).update();
|
||||
|
||||
UserService users = mock(UserService.class);
|
||||
when(users.requireUserId("admin")).thenReturn(userId);
|
||||
ProjectService service = new ProjectService(
|
||||
projectMapper(), projectPlanMapper(), users, new ObjectMapper());
|
||||
ObjectMapper json = new ObjectMapper();
|
||||
|
||||
ProjectService.PlanView draft = service.saveDraftPlan(
|
||||
projectId, json.createObjectNode().put("title", "第一版"), userId);
|
||||
ProjectService.PlanView confirmed = service.confirmPlan(
|
||||
projectId,
|
||||
draft.id(),
|
||||
json.createObjectNode().put("title", "确认版"),
|
||||
() -> "admin");
|
||||
|
||||
assertThat(draft.version()).isEqualTo(1);
|
||||
assertThat(service.currentPlan(projectId).id()).isEqualTo(draft.id());
|
||||
assertThat(confirmed.status()).isEqualTo("CONFIRMED");
|
||||
assertThat(confirmed.plan().path("title").asText()).isEqualTo("确认版");
|
||||
assertThat(service.require(projectId).status()).isEqualTo("WRITING");
|
||||
assertThat(service.require(projectId).version()).isEqualTo(2L);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证模型新增、保留旧密钥更新、默认分配及密钥解密读取。
|
||||
*
|
||||
* @throws Exception Mapper XML 初始化失败时抛出
|
||||
*/
|
||||
@Test
|
||||
void shouldManageEncryptedModelConfigurationWithMyBatisFlex() throws Exception {
|
||||
UUID userId = UUID.randomUUID();
|
||||
jdbc().sql("INSERT INTO app.app_user(id, username, password_hash, display_name) VALUES (:id, :name, 'x', 'test')")
|
||||
.param("id", userId).param("name", "model-u-" + userId).update();
|
||||
UserService users = mock(UserService.class);
|
||||
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),
|
||||
users,
|
||||
new KeyCipher(properties),
|
||||
properties,
|
||||
new ObjectMapper());
|
||||
Map<String, Object> capabilities = Map.of(
|
||||
"toolCalling", true, "reasoning", true, "contextWindow", 65_536);
|
||||
|
||||
ModelService.ModelView created = service.save(
|
||||
null,
|
||||
new ModelService.ModelInput(
|
||||
"测试模型", "https://model.example.test/", "model-v1", "secret-1234",
|
||||
Map.of("timeoutSeconds", 60), capabilities),
|
||||
() -> "admin");
|
||||
ModelService.ModelView updated = service.save(
|
||||
created.id(),
|
||||
new ModelService.ModelInput(
|
||||
"测试模型更新", "https://model.example.test", "model-v2", "",
|
||||
Map.of("timeoutSeconds", 120), capabilities),
|
||||
() -> "admin");
|
||||
service.setDefault(created.id(), () -> "admin");
|
||||
ModelService.ModelSecret secret = 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(jdbc().sql("SELECT COUNT(*) FROM app.model_assignment WHERE model_config_id = :id")
|
||||
.param("id", created.id()).query(Long.class).single()).isEqualTo(3L);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Skill 列表只读联查 AgentScope 表,而启停状态仅写应用自管表。
|
||||
*
|
||||
* @throws Exception Mapper XML 初始化失败时抛出
|
||||
*/
|
||||
@Test
|
||||
void shouldReadAgentScopeSkillsAndUpdateApplicationConfiguration() throws Exception {
|
||||
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));
|
||||
SkillService service = new SkillService(
|
||||
mapper,
|
||||
mock(PostgresSkillRepository.class),
|
||||
mock(SkillPackageReader.class),
|
||||
mock(UserService.class),
|
||||
properties);
|
||||
|
||||
List<SkillService.SkillView> skills = service.list();
|
||||
assertThat(skills).isNotEmpty();
|
||||
String name = skills.getFirst().name();
|
||||
service.setEnabled(name, false);
|
||||
|
||||
assertThat(service.enabledNames()).doesNotContain(name);
|
||||
assertThat(jdbc().sql("SELECT enabled FROM app.skill_config WHERE skill_name = :name")
|
||||
.param("name", name).query(Boolean.class).single()).isFalse();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 MyBatis-Flex 可以在 app schema 中插入并通过 Lambda QueryWrapper 查询用户。
|
||||
*/
|
||||
@Test
|
||||
void shouldPersistAndQueryUserWithMyBatisFlex() {
|
||||
PGSimpleDataSource source = dataSource();
|
||||
AppUserMapper mapper = new MybatisFlexBootstrap()
|
||||
.setDataSource(source)
|
||||
.addMapper(AppUserMapper.class)
|
||||
.start()
|
||||
.getMapper(AppUserMapper.class);
|
||||
UUID userId = UUID.randomUUID();
|
||||
AppUserEntity user = new AppUserEntity();
|
||||
user.setId(userId);
|
||||
user.setUsername("flex-" + userId);
|
||||
user.setPasswordHash("encoded");
|
||||
user.setDisplayName("Flex 测试用户");
|
||||
|
||||
TableInfo tableInfo = TableInfoFactory.ofEntityClass(AppUserEntity.class);
|
||||
assertThat(tableInfo.getPrimaryColumns()).containsExactly("id");
|
||||
assertThat(tableInfo.getInsertPrimaryKeys()).containsExactly("id");
|
||||
|
||||
// 主键由应用层提前生成;Generator 策略必须保留已有值,其他空字段交给数据库默认值。
|
||||
assertThat(mapper.insertSelectiveWithPk(user)).isEqualTo(1);
|
||||
|
||||
QueryWrapper query = QueryWrapper.create()
|
||||
.where(AppUserEntity::getUsername).eq(user.getUsername());
|
||||
AppUserEntity loaded = mapper.selectOneByQuery(query);
|
||||
assertThat(loaded.getId()).isEqualTo(userId);
|
||||
assertThat(loaded.getDisplayName()).isEqualTo("Flex 测试用户");
|
||||
assertThat(loaded.getEnabled()).isTrue();
|
||||
}
|
||||
|
||||
private JdbcClient jdbc() {
|
||||
return JdbcClient.create(dataSource());
|
||||
}
|
||||
|
||||
private PGSimpleDataSource dataSource() {
|
||||
PGSimpleDataSource source = new PGSimpleDataSource();
|
||||
source.setURL(POSTGRES.getJdbcUrl());
|
||||
source.setUser(POSTGRES.getUsername());
|
||||
source.setPassword(POSTGRES.getPassword());
|
||||
return JdbcClient.create(source);
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带 UUID、JSONB 类型处理器和显式 XML 语句的产物 Mapper。
|
||||
*
|
||||
* <p>生产环境由 Spring Boot 扫描类型处理器与 mapper-locations;此处使用轻量 Bootstrap,
|
||||
* 因而需要显式复现相同配置。</p>
|
||||
*
|
||||
* @return 可访问 Testcontainers PostgreSQL 的产物 Mapper
|
||||
* @throws Exception XML 资源读取或 Mapper 初始化失败时抛出
|
||||
*/
|
||||
private ArtifactMapper artifactMapper() throws Exception {
|
||||
PGSimpleDataSource source = dataSource();
|
||||
FlexDataSource flexDataSource = new FlexDataSource("artifact-integration-test", source);
|
||||
Environment environment = new Environment(
|
||||
"artifact-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||||
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||||
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||||
configuration.getTypeHandlerRegistry().register(JsonbStringTypeHandler.class);
|
||||
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||||
.setConfiguration(configuration)
|
||||
.setDataSource(flexDataSource)
|
||||
.addMapper(ArtifactMapper.class)
|
||||
.start();
|
||||
String resource = "mapper/ArtifactMapper.xml";
|
||||
try (InputStream input = Resources.getResourceAsStream(resource)) {
|
||||
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||||
}
|
||||
return bootstrap.getMapper(ArtifactMapper.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建加载事件原子写入和游标回放 SQL 的 Agent 事件 Mapper。
|
||||
*
|
||||
* @return Agent 事件 Mapper
|
||||
* @throws Exception XML 资源读取或 Mapper 初始化失败时抛出
|
||||
*/
|
||||
private AgentEventMapper agentEventMapper() throws Exception {
|
||||
PGSimpleDataSource source = dataSource();
|
||||
FlexDataSource flexDataSource = new FlexDataSource("agent-event-integration-test", source);
|
||||
Environment environment = new Environment(
|
||||
"agent-event-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||||
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||||
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||||
configuration.getTypeHandlerRegistry().register(JsonbStringTypeHandler.class);
|
||||
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||||
.setConfiguration(configuration)
|
||||
.setDataSource(flexDataSource)
|
||||
.addMapper(AgentEventMapper.class)
|
||||
.start();
|
||||
String resource = "mapper/AgentEventMapper.xml";
|
||||
try (InputStream input = Resources.getResourceAsStream(resource)) {
|
||||
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||||
}
|
||||
return bootstrap.getMapper(AgentEventMapper.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建加载 Run 状态机 SQL 及 UUID、JSONB 类型处理器的 Agent Run Mapper。
|
||||
*
|
||||
* @return Agent Run Mapper
|
||||
* @throws Exception XML 资源读取或 Mapper 初始化失败时抛出
|
||||
*/
|
||||
private AgentRunMapper agentRunMapper() throws Exception {
|
||||
PGSimpleDataSource source = dataSource();
|
||||
FlexDataSource flexDataSource = new FlexDataSource("agent-run-integration-test", source);
|
||||
Environment environment = new Environment(
|
||||
"agent-run-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||||
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||||
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||||
configuration.getTypeHandlerRegistry().register(JsonbStringTypeHandler.class);
|
||||
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||||
.setConfiguration(configuration)
|
||||
.setDataSource(flexDataSource)
|
||||
.addMapper(AgentRunMapper.class)
|
||||
.start();
|
||||
String resource = "mapper/AgentRunMapper.xml";
|
||||
try (InputStream input = Resources.getResourceAsStream(resource)) {
|
||||
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||||
}
|
||||
return bootstrap.getMapper(AgentRunMapper.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建加载了项目级联删除 XML 的项目 Mapper。
|
||||
*
|
||||
* @return 项目 Mapper
|
||||
* @throws Exception XML 资源读取或 Mapper 初始化失败时抛出
|
||||
*/
|
||||
private ProjectMapper projectMapper() throws Exception {
|
||||
PGSimpleDataSource source = dataSource();
|
||||
FlexDataSource flexDataSource = new FlexDataSource("project-integration-test", source);
|
||||
Environment environment = new Environment(
|
||||
"project-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||||
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||||
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||||
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||||
.setConfiguration(configuration)
|
||||
.setDataSource(flexDataSource)
|
||||
.addMapper(ProjectMapper.class)
|
||||
.start();
|
||||
String resource = "mapper/ProjectMapper.xml";
|
||||
try (InputStream input = Resources.getResourceAsStream(resource)) {
|
||||
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||||
}
|
||||
return bootstrap.getMapper(ProjectMapper.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建加载了规划版本 SQL 与 JSONB 处理器的规划 Mapper。
|
||||
*
|
||||
* @return 规划 Mapper
|
||||
* @throws Exception XML 资源读取或 Mapper 初始化失败时抛出
|
||||
*/
|
||||
private ProjectPlanMapper projectPlanMapper() throws Exception {
|
||||
PGSimpleDataSource source = dataSource();
|
||||
FlexDataSource flexDataSource = new FlexDataSource("plan-integration-test", source);
|
||||
Environment environment = new Environment(
|
||||
"plan-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||||
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||||
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||||
configuration.getTypeHandlerRegistry().register(JsonbStringTypeHandler.class);
|
||||
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||||
.setConfiguration(configuration)
|
||||
.setDataSource(flexDataSource)
|
||||
.addMapper(ProjectPlanMapper.class)
|
||||
.start();
|
||||
String resource = "mapper/ProjectPlanMapper.xml";
|
||||
try (InputStream input = Resources.getResourceAsStream(resource)) {
|
||||
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||||
}
|
||||
return bootstrap.getMapper(ProjectPlanMapper.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建使用 MyBatis-Flex Wrapper,并加载 JSONB 显式写入 SQL 的模型配置 Mapper。
|
||||
*
|
||||
* @return 模型配置 Mapper
|
||||
* @throws Exception XML 资源读取或 Mapper 初始化失败时抛出
|
||||
*/
|
||||
private ModelConfigMapper modelConfigMapper() throws Exception {
|
||||
PGSimpleDataSource source = dataSource();
|
||||
FlexDataSource flexDataSource = new FlexDataSource("model-config-integration-test", source);
|
||||
Environment environment = new Environment(
|
||||
"model-config-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||||
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||||
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||||
configuration.getTypeHandlerRegistry().register(JsonbStringTypeHandler.class);
|
||||
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||||
.setConfiguration(configuration)
|
||||
.setDataSource(flexDataSource)
|
||||
.addMapper(ModelConfigMapper.class)
|
||||
.start();
|
||||
String resource = "mapper/ModelConfigMapper.xml";
|
||||
try (InputStream input = Resources.getResourceAsStream(resource)) {
|
||||
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||||
}
|
||||
return bootstrap.getMapper(ModelConfigMapper.class);
|
||||
}
|
||||
|
||||
/** 创建加载角色 upsert SQL 的模型分配 Mapper。 */
|
||||
private ModelAssignmentMapper modelAssignmentMapper() throws Exception {
|
||||
PGSimpleDataSource source = dataSource();
|
||||
FlexDataSource flexDataSource = new FlexDataSource("model-assignment-integration-test", source);
|
||||
Environment environment = new Environment(
|
||||
"model-assignment-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||||
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||||
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||||
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||||
.setConfiguration(configuration)
|
||||
.setDataSource(flexDataSource)
|
||||
.addMapper(ModelAssignmentMapper.class)
|
||||
.start();
|
||||
String resource = "mapper/ModelAssignmentMapper.xml";
|
||||
try (InputStream input = Resources.getResourceAsStream(resource)) {
|
||||
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||||
}
|
||||
return bootstrap.getMapper(ModelAssignmentMapper.class);
|
||||
}
|
||||
|
||||
/** 创建加载 AgentScope 只读联查 SQL 的 Skill 配置 Mapper。 */
|
||||
private SkillConfigMapper skillConfigMapper() throws Exception {
|
||||
PGSimpleDataSource source = dataSource();
|
||||
FlexDataSource flexDataSource = new FlexDataSource("skill-config-integration-test", source);
|
||||
Environment environment = new Environment(
|
||||
"skill-config-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||||
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||||
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||||
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||||
.setConfiguration(configuration)
|
||||
.setDataSource(flexDataSource)
|
||||
.addMapper(SkillConfigMapper.class)
|
||||
.start();
|
||||
String resource = "mapper/SkillConfigMapper.xml";
|
||||
try (InputStream input = Resources.getResourceAsStream(resource)) {
|
||||
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||||
}
|
||||
return bootstrap.getMapper(SkillConfigMapper.class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package tech.easyflow.manuagent;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.mybatisflex.spring.boot.MybatisFlexAutoConfiguration;
|
||||
import java.util.Map;
|
||||
import org.apache.ibatis.session.SqlSessionFactory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import tech.easyflow.manuagent.config.MyBatisFlexConfiguration;
|
||||
import tech.easyflow.manuagent.entity.AgentEventEntity;
|
||||
import tech.easyflow.manuagent.entity.ArtifactEntity;
|
||||
import tech.easyflow.manuagent.entity.ProjectPlanEntity;
|
||||
import tech.easyflow.manuagent.mapper.AgentEventMapper;
|
||||
import tech.easyflow.manuagent.mapper.AgentRunMapper;
|
||||
import tech.easyflow.manuagent.mapper.ArtifactMapper;
|
||||
import tech.easyflow.manuagent.mapper.ModelConfigMapper;
|
||||
import tech.easyflow.manuagent.mapper.ProjectPlanMapper;
|
||||
import tech.easyflow.manuagent.mapper.SkillConfigMapper;
|
||||
|
||||
/**
|
||||
* 验证生产环境使用的 MyBatis-Flex 自动配置、Mapper 扫描和 XML 资源能够共同启动。
|
||||
*
|
||||
* <p>各 PostgreSQL 集成测试使用轻量 Bootstrap 单独加载 Mapper;本测试补充验证 Spring Boot
|
||||
* 实际配置路径,防止 mapper-locations 拼写、Bean 扫描或 XML statement 命名错误只在部署时暴露。</p>
|
||||
*/
|
||||
class MyBatisFlexContextTest {
|
||||
|
||||
/**
|
||||
* 加载最小 Spring 上下文并核对关键自定义 SQL statement。
|
||||
*
|
||||
* <p>测试 URL 不执行数据库连接;本用例只验证配置装配,实际 SQL 行为由 Testcontainers
|
||||
* PostgreSQL 17 集成测试负责。</p>
|
||||
*/
|
||||
@Test
|
||||
void shouldLoadMapperBeansAndXmlStatements() {
|
||||
new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(
|
||||
DataSourceAutoConfiguration.class,
|
||||
MybatisFlexAutoConfiguration.class))
|
||||
.withUserConfiguration(MyBatisFlexConfiguration.class)
|
||||
.withPropertyValues(
|
||||
"spring.datasource.url=jdbc:postgresql://127.0.0.1:1/config-only",
|
||||
"spring.datasource.username=test",
|
||||
"spring.datasource.password=test",
|
||||
"spring.datasource.hikari.initialization-fail-timeout=-1",
|
||||
"mybatis-flex.mapper-locations=classpath*:/mapper/**/*.xml",
|
||||
"mybatis-flex.type-aliases-package=tech.easyflow.manuagent.entity",
|
||||
"mybatis-flex.type-handlers-package=tech.easyflow.manuagent.typehandler",
|
||||
"mybatis-flex.configuration.map-underscore-to-camel-case=true",
|
||||
"mybatis-flex.configuration.cache-enabled=false",
|
||||
"mybatis-flex.configuration.local-cache-scope=statement")
|
||||
.run(context -> {
|
||||
assertThat(context.getStartupFailure()).isNull();
|
||||
assertThat(context.getBean(AgentEventMapper.class)).isNotNull();
|
||||
assertThat(context.getBean(AgentRunMapper.class)).isNotNull();
|
||||
assertThat(context.getBean(ArtifactMapper.class)).isNotNull();
|
||||
assertThat(context.getBean(ModelConfigMapper.class)).isNotNull();
|
||||
assertThat(context.getBean(ProjectPlanMapper.class)).isNotNull();
|
||||
assertThat(context.getBean(SkillConfigMapper.class)).isNotNull();
|
||||
|
||||
var configuration = context.getBean(SqlSessionFactory.class).getConfiguration();
|
||||
assertThat(configuration.hasStatement(
|
||||
"tech.easyflow.manuagent.mapper.AgentEventMapper.insertReturning")).isTrue();
|
||||
assertThat(configuration.hasStatement(
|
||||
"tech.easyflow.manuagent.mapper.AgentRunMapper.interruptRunningAfterRestart")).isTrue();
|
||||
assertThat(configuration.hasStatement(
|
||||
"tech.easyflow.manuagent.mapper.ModelConfigMapper.insertModel")).isTrue();
|
||||
assertThat(configuration.hasStatement(
|
||||
"tech.easyflow.manuagent.mapper.ModelConfigMapper.updateModel")).isTrue();
|
||||
assertThat(configuration.hasStatement(
|
||||
"tech.easyflow.manuagent.mapper.ModelConfigMapper.clearDefault")).isTrue();
|
||||
assertThat(configuration.hasStatement(
|
||||
"tech.easyflow.manuagent.mapper.ModelConfigMapper.setDefault")).isTrue();
|
||||
assertThat(configuration.hasStatement(
|
||||
"tech.easyflow.manuagent.mapper.ProjectPlanMapper.confirmDraft")).isTrue();
|
||||
assertThat(configuration.hasStatement(
|
||||
"tech.easyflow.manuagent.mapper.SkillConfigMapper.selectViews")).isTrue();
|
||||
assertThat(configuration.hasStatement(
|
||||
"tech.easyflow.manuagent.mapper.SkillConfigMapper.updateEnabled")).isTrue();
|
||||
|
||||
// 自定义 INSERT/UPDATE ... RETURNING 也应沿用迁移前视图字段,避免回传内部列。
|
||||
String eventReturning = returningClause(configuration
|
||||
.getMappedStatement("tech.easyflow.manuagent.mapper.AgentEventMapper.insertReturning")
|
||||
.getBoundSql(Map.of("event", new AgentEventEntity()))
|
||||
.getSql());
|
||||
assertThat(eventReturning)
|
||||
.contains("id", "project_id", "run_id", "event_type", "payload", "created_at")
|
||||
.doesNotContain("event_id");
|
||||
|
||||
String artifactReturning = returningClause(configuration
|
||||
.getMappedStatement("tech.easyflow.manuagent.mapper.ArtifactMapper.upsert")
|
||||
.getBoundSql(Map.of("artifact", new ArtifactEntity()))
|
||||
.getSql());
|
||||
assertThat(artifactReturning)
|
||||
.contains("metadata_json", "published_at", "size_bytes")
|
||||
.doesNotContain("relative_path", "mime_type", "sha256", "created_at");
|
||||
|
||||
String draftReturning = returningClause(configuration
|
||||
.getMappedStatement("tech.easyflow.manuagent.mapper.ProjectPlanMapper.insertNextDraft")
|
||||
.getBoundSql(Map.of("plan", new ProjectPlanEntity()))
|
||||
.getSql());
|
||||
assertPlanViewProjection(draftReturning);
|
||||
|
||||
String confirmReturning = returningClause(configuration
|
||||
.getMappedStatement("tech.easyflow.manuagent.mapper.ProjectPlanMapper.confirmDraft")
|
||||
.getBoundSql(Map.of())
|
||||
.getSql());
|
||||
assertPlanViewProjection(confirmReturning);
|
||||
|
||||
String currentPlanSql = configuration
|
||||
.getMappedStatement("tech.easyflow.manuagent.mapper.ProjectPlanMapper.selectCurrent")
|
||||
.getBoundSql(Map.of())
|
||||
.getSql()
|
||||
.toLowerCase(java.util.Locale.ROOT);
|
||||
assertPlanViewProjection(currentPlanSql.substring(0, currentPlanSql.indexOf("from")));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 截取自定义写语句的 RETURNING 字段部分,避免 INSERT/UPDATE 输入列干扰投影断言。
|
||||
*
|
||||
* @param sql 完整 Mapper SQL
|
||||
* @return 规范化为小写的 RETURNING 子句
|
||||
*/
|
||||
private static String returningClause(String sql) {
|
||||
String normalized = sql.toLowerCase(java.util.Locale.ROOT);
|
||||
int returning = normalized.lastIndexOf("returning");
|
||||
assertThat(returning).isGreaterThanOrEqualTo(0);
|
||||
return normalized.substring(returning);
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言规划查询仅返回接口视图所需字段。
|
||||
*
|
||||
* @param projection SELECT 或 RETURNING 字段片段
|
||||
*/
|
||||
private static void assertPlanViewProjection(String projection) {
|
||||
assertThat(projection)
|
||||
.contains("id", "project_id", "plan_version", "status", "plan_json", "confirmed_at", "created_at")
|
||||
.doesNotContain("created_by", "confirmed_by", "updated_at");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
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;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.mybatisflex.spring.boot.FlexTransactionAutoConfiguration;
|
||||
import com.mybatisflex.spring.boot.MybatisFlexAutoConfiguration;
|
||||
import java.util.UUID;
|
||||
import javax.sql.DataSource;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.postgresql.util.PSQLException;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.aop.support.AopUtils;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
import tech.easyflow.manuagent.auth.UserService;
|
||||
import tech.easyflow.manuagent.config.AppProperties;
|
||||
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.model.KeyCipher;
|
||||
import tech.easyflow.manuagent.model.ModelService;
|
||||
|
||||
/**
|
||||
* 使用真实 Spring 事务代理、MyBatis-Flex Mapper 和 PostgreSQL 验证跨语句回滚。
|
||||
*
|
||||
* <p>轻量 Mapper Bootstrap 只能证明 SQL 可执行;本测试额外证明生产配置中的 Mapper 调用
|
||||
* 与 {@code @Transactional} 共享同一个数据库事务。</p>
|
||||
*/
|
||||
@Testcontainers
|
||||
class MyBatisFlexTransactionIntegrationTest {
|
||||
|
||||
/** 为事务测试提供隔离的 PostgreSQL 17 数据库。 */
|
||||
@Container
|
||||
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:17-alpine");
|
||||
|
||||
/**
|
||||
* 在 Spring 上下文启动前建立与生产一致的应用表结构。
|
||||
*/
|
||||
@BeforeAll
|
||||
static void migrate() {
|
||||
Flyway.configure()
|
||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||
.locations("classpath:db/migration")
|
||||
.load()
|
||||
.migrate();
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色分配写入失败时,默认模型切换必须整体回滚,不能留下“没有默认模型”的中间状态。
|
||||
*
|
||||
* <p>目标模型刻意保持停用,用于验证 ORM 迁移没有新增原 JDBC 实现不存在的启用状态限制;
|
||||
* 用户 ID 则使用数据库中不存在的值,让后续角色分配稳定触发外键错误。</p>
|
||||
*/
|
||||
@Test
|
||||
void shouldRollbackDefaultModelSwitchWhenAssignmentFails() {
|
||||
new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(
|
||||
DataSourceAutoConfiguration.class,
|
||||
FlexTransactionAutoConfiguration.class,
|
||||
DataSourceTransactionManagerAutoConfiguration.class,
|
||||
TransactionAutoConfiguration.class,
|
||||
MybatisFlexAutoConfiguration.class))
|
||||
.withUserConfiguration(MyBatisFlexConfiguration.class, TransactionTestConfiguration.class)
|
||||
.withPropertyValues(
|
||||
"spring.datasource.url=" + POSTGRES.getJdbcUrl(),
|
||||
"spring.datasource.username=" + POSTGRES.getUsername(),
|
||||
"spring.datasource.password=" + POSTGRES.getPassword(),
|
||||
"mybatis-flex.mapper-locations=classpath*:/mapper/**/*.xml",
|
||||
"mybatis-flex.type-aliases-package=tech.easyflow.manuagent.entity",
|
||||
"mybatis-flex.type-handlers-package=tech.easyflow.manuagent.typehandler",
|
||||
"mybatis-flex.configuration.map-underscore-to-camel-case=true")
|
||||
.run(context -> {
|
||||
assertThat(context.getStartupFailure()).isNull();
|
||||
JdbcClient jdbc = JdbcClient.create(context.getBean(DataSource.class));
|
||||
UUID userId = UUID.randomUUID();
|
||||
UUID missingUserId = UUID.randomUUID();
|
||||
UUID currentDefaultId = UUID.randomUUID();
|
||||
UUID disabledTargetId = UUID.randomUUID();
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.app_user(id, username, password_hash, display_name)
|
||||
VALUES (:id, :username, 'encoded', '事务测试用户')
|
||||
""")
|
||||
.param("id", userId)
|
||||
.param("username", "tx-" + userId)
|
||||
.update();
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.model_config(
|
||||
id, name, provider, base_url, model_id, enabled, is_default)
|
||||
VALUES
|
||||
(:currentId, :currentName, 'OPENAI_COMPATIBLE', 'https://current.test',
|
||||
'current-model', TRUE, TRUE),
|
||||
(:targetId, :targetName, 'OPENAI_COMPATIBLE', 'https://target.test',
|
||||
'target-model', FALSE, FALSE)
|
||||
""")
|
||||
.param("currentId", currentDefaultId)
|
||||
.param("currentName", "current-" + currentDefaultId)
|
||||
.param("targetId", disabledTargetId)
|
||||
.param("targetName", "target-" + disabledTargetId)
|
||||
.update();
|
||||
UserService users = context.getBean(UserService.class);
|
||||
when(users.requireUserId("admin")).thenReturn(missingUserId);
|
||||
ModelService modelService = context.getBean(ModelService.class);
|
||||
assertThat(context.getBeansOfType(PlatformTransactionManager.class)).hasSize(1);
|
||||
assertThat(AopUtils.isAopProxy(modelService)).isTrue();
|
||||
|
||||
assertThatThrownBy(() -> modelService.setDefault(disabledTargetId, () -> "admin"))
|
||||
.hasRootCauseInstanceOf(PSQLException.class);
|
||||
|
||||
assertThat(jdbc.sql("SELECT is_default FROM app.model_config WHERE id = :id")
|
||||
.param("id", currentDefaultId)
|
||||
.query(Boolean.class)
|
||||
.single()).isTrue();
|
||||
assertThat(jdbc.sql("SELECT is_default FROM app.model_config WHERE id = :id")
|
||||
.param("id", disabledTargetId)
|
||||
.query(Boolean.class)
|
||||
.single()).isFalse();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅装配事务测试需要的服务边界,避免启动 Agent、文件系统和外部模型连接。
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
static class TransactionTestConfiguration {
|
||||
|
||||
/** 提供可按测试场景设置返回值的用户服务。 */
|
||||
@Bean
|
||||
UserService userService() {
|
||||
return mock(UserService.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 装配真实 Mapper 驱动的模型服务;未参与本场景的密钥与应用配置依赖使用边界 Mock。
|
||||
*/
|
||||
@Bean
|
||||
ModelService modelService(
|
||||
ModelConfigMapper modelMapper,
|
||||
ModelAssignmentMapper assignmentMapper,
|
||||
AppUserMapper userMapper,
|
||||
UserService userService) {
|
||||
return new ModelService(
|
||||
modelMapper,
|
||||
assignmentMapper,
|
||||
userMapper,
|
||||
userService,
|
||||
mock(KeyCipher.class),
|
||||
mock(AppProperties.class),
|
||||
new ObjectMapper());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package tech.easyflow.manuagent.agent;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
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 com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import tech.easyflow.manuagent.entity.AgentEventEntity;
|
||||
import tech.easyflow.manuagent.mapper.AgentEventMapper;
|
||||
|
||||
/**
|
||||
* 验证 Agent 事件回放查询在 ORM 迁移后保持原 JDBC 字段和游标语义。
|
||||
*/
|
||||
class AgentEventServiceQueryTest {
|
||||
|
||||
/**
|
||||
* 事件回放只读取响应所需字段,不加载仅用于外部追踪的事件标识。
|
||||
*/
|
||||
@Test
|
||||
void shouldSelectOnlyEventViewColumnsWhenListingAfterCursor() {
|
||||
AgentEventMapper mapper = mock(AgentEventMapper.class);
|
||||
AgentEventEntity event = new AgentEventEntity();
|
||||
event.setId(1L);
|
||||
event.setProjectId(UUID.randomUUID());
|
||||
event.setPayloadJson("{}");
|
||||
when(mapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of(event));
|
||||
AgentEventService service = new AgentEventService(mapper, new ObjectMapper());
|
||||
|
||||
service.listAfter(event.getProjectId(), 0L, 100);
|
||||
|
||||
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
|
||||
verify(mapper).selectListByQuery(queryCaptor.capture());
|
||||
String sql = queryCaptor.getValue().toSQL().toLowerCase(java.util.Locale.ROOT);
|
||||
assertThat(sql)
|
||||
.contains("project_id", "run_id", "event_type", "payload", "created_at")
|
||||
.doesNotContain("event_id");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package tech.easyflow.manuagent.agent;
|
||||
|
||||
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.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import java.util.UUID;
|
||||
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.mapper.AgentEventMapper;
|
||||
import tech.easyflow.manuagent.mapper.AgentRunMapper;
|
||||
import tech.easyflow.manuagent.mapper.ModelConfigMapper;
|
||||
|
||||
/**
|
||||
* 验证 Agent 执行热路径只读取判断运行状态所需的最小列。
|
||||
*/
|
||||
class AgentRunStoreQueryTest {
|
||||
|
||||
/**
|
||||
* {@code ensureRunning} 可能被每个 Agent 检查点调用,因此不得加载 JSONB 和错误详情等整行数据。
|
||||
*/
|
||||
@Test
|
||||
void shouldSelectOnlyStatusWhenCheckingRunningState() {
|
||||
AgentRunMapper runMapper = mock(AgentRunMapper.class);
|
||||
AgentRunEntity running = new AgentRunEntity();
|
||||
running.setStatus("RUNNING");
|
||||
when(runMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(running);
|
||||
AgentRunStore store = new AgentRunStore(
|
||||
runMapper,
|
||||
mock(AgentEventMapper.class),
|
||||
mock(ModelConfigMapper.class),
|
||||
new ObjectMapper());
|
||||
|
||||
store.ensureRunning(UUID.randomUUID());
|
||||
|
||||
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
|
||||
verify(runMapper).selectOneByQuery(queryCaptor.capture());
|
||||
String sql = queryCaptor.getValue().toSQL().toLowerCase(java.util.Locale.ROOT);
|
||||
assertThat(sql)
|
||||
.contains("status")
|
||||
.doesNotContain("pending_interrupt", "error_message", "trace_id");
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认模型缺失属于数据库配置异常,不应在 ORM 迁移中新增 409 业务错误。
|
||||
*/
|
||||
@Test
|
||||
void shouldKeepMissingDefaultModelAsUnexpectedTechnicalFailure() {
|
||||
AgentRunMapper runMapper = mock(AgentRunMapper.class);
|
||||
when(runMapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(0L);
|
||||
AgentRunStore store = new AgentRunStore(
|
||||
runMapper,
|
||||
mock(AgentEventMapper.class),
|
||||
mock(ModelConfigMapper.class),
|
||||
new ObjectMapper());
|
||||
|
||||
assertThatThrownBy(() -> store.create(UUID.randomUUID(), "INITIAL", null))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.isNotInstanceOf(ApiException.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 ID 强制读取或运行状态检查遇到不存在的 Run 时,不新增 404 或“已中断”业务语义。
|
||||
*/
|
||||
@Test
|
||||
void shouldKeepMissingRequiredRunAsUnexpectedTechnicalFailure() {
|
||||
AgentRunStore store = new AgentRunStore(
|
||||
mock(AgentRunMapper.class),
|
||||
mock(AgentEventMapper.class),
|
||||
mock(ModelConfigMapper.class),
|
||||
new ObjectMapper());
|
||||
UUID runId = UUID.randomUUID();
|
||||
|
||||
assertThatThrownBy(() -> store.require(runId))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.isNotInstanceOf(ApiException.class);
|
||||
assertThatThrownBy(() -> store.ensureRunning(runId))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.isNotInstanceOf(AgentExecutionService.RunInterruptedException.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package tech.easyflow.manuagent.artifact;
|
||||
|
||||
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.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import tech.easyflow.manuagent.entity.ArtifactEntity;
|
||||
import tech.easyflow.manuagent.mapper.ArtifactMapper;
|
||||
import tech.easyflow.manuagent.project.ProjectFileService;
|
||||
|
||||
/**
|
||||
* 验证产物查询在 MyBatis-Flex 迁移后保持原 JDBC SQL 的最小字段范围。
|
||||
*/
|
||||
class ArtifactServiceQueryTest {
|
||||
|
||||
/**
|
||||
* 产物列表只应读取接口视图字段,不加载下载路径、MIME、摘要和创建时间。
|
||||
*/
|
||||
@Test
|
||||
void shouldSelectOnlyArtifactViewColumnsWhenListing() {
|
||||
ArtifactMapper mapper = mock(ArtifactMapper.class);
|
||||
ArtifactEntity artifact = new ArtifactEntity();
|
||||
artifact.setId(UUID.randomUUID());
|
||||
artifact.setProjectId(UUID.randomUUID());
|
||||
artifact.setSizeBytes(1L);
|
||||
when(mapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of(artifact));
|
||||
ArtifactService service = new ArtifactService(
|
||||
mapper, mock(ProjectFileService.class), mock(DocxValidator.class));
|
||||
|
||||
service.list(artifact.getProjectId());
|
||||
|
||||
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
|
||||
verify(mapper).selectListByQuery(queryCaptor.capture());
|
||||
String sql = queryCaptor.getValue().toSQL().toLowerCase(java.util.Locale.ROOT);
|
||||
assertThat(sql)
|
||||
.contains("metadata_json", "published_at", "size_bytes")
|
||||
.doesNotContain("relative_path", "mime_type", "sha256", "created_at");
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载查询只应读取完整性校验和资源响应所需的六个字段。
|
||||
*/
|
||||
@Test
|
||||
void shouldSelectOnlyStoredArtifactColumnsWhenDownloading() {
|
||||
ArtifactMapper mapper = mock(ArtifactMapper.class);
|
||||
when(mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(null);
|
||||
ArtifactService service = new ArtifactService(
|
||||
mapper, mock(ProjectFileService.class), mock(DocxValidator.class));
|
||||
|
||||
assertThatThrownBy(() -> service.download(UUID.randomUUID()))
|
||||
.isInstanceOf(ApiException.class);
|
||||
|
||||
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
|
||||
verify(mapper).selectOneByQuery(queryCaptor.capture());
|
||||
String sql = queryCaptor.getValue().toSQL().toLowerCase(java.util.Locale.ROOT);
|
||||
assertThat(sql)
|
||||
.contains("project_id", "relative_path", "mime_type", "size_bytes", "sha256")
|
||||
.doesNotContain("metadata_json", "published_at", "created_at");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package tech.easyflow.manuagent.auth;
|
||||
|
||||
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.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import com.mybatisflex.core.FlexGlobalConfig;
|
||||
import com.mybatisflex.core.mybatis.FlexConfiguration;
|
||||
import javax.sql.DataSource;
|
||||
import java.util.UUID;
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import tech.easyflow.manuagent.config.AppProperties;
|
||||
import tech.easyflow.manuagent.entity.AppUserEntity;
|
||||
import tech.easyflow.manuagent.mapper.AppUserMapper;
|
||||
import tech.easyflow.manuagent.typehandler.UuidTypeHandler;
|
||||
|
||||
/**
|
||||
* 验证 {@link UserService} 在迁移到 MyBatis-Flex 后保持原有管理员初始化与认证语义。
|
||||
*/
|
||||
class UserServiceTest {
|
||||
|
||||
private AppUserMapper mapper;
|
||||
private PasswordEncoder passwordEncoder;
|
||||
private AppProperties properties;
|
||||
private UserService service;
|
||||
|
||||
/**
|
||||
* 为每个测试创建隔离的 Mapper 与安全组件替身。
|
||||
*/
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// 生产环境由 Spring Boot 在 Mapper 使用前注册 TypeHandler;纯单测需显式建立同等的元数据环境。
|
||||
Environment environment = new Environment(
|
||||
"user-service-unit-test", new JdbcTransactionFactory(), mock(DataSource.class));
|
||||
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||||
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||||
FlexGlobalConfig globalConfig = new FlexGlobalConfig();
|
||||
globalConfig.setConfiguration(configuration);
|
||||
FlexGlobalConfig.setDefaultConfig(globalConfig);
|
||||
|
||||
mapper = mock(AppUserMapper.class);
|
||||
passwordEncoder = mock(PasswordEncoder.class);
|
||||
properties = mock(AppProperties.class);
|
||||
service = new UserService(mapper, passwordEncoder, properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* 首次启动时应创建一个启用的管理员,并保存编码后的密码。
|
||||
*/
|
||||
@Test
|
||||
void shouldCreateInitialAdministratorWhenUserTableIsEmpty() {
|
||||
when(mapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(0L);
|
||||
when(properties.adminUsername()).thenReturn("admin");
|
||||
when(properties.adminPassword()).thenReturn("plain-password");
|
||||
when(passwordEncoder.encode("plain-password")).thenReturn("encoded-password");
|
||||
|
||||
service.run(null);
|
||||
|
||||
ArgumentCaptor<AppUserEntity> captor = ArgumentCaptor.forClass(AppUserEntity.class);
|
||||
verify(mapper).insertSelective(captor.capture());
|
||||
AppUserEntity created = captor.getValue();
|
||||
assertThat(created.getId()).isNotNull();
|
||||
assertThat(created.getUsername()).isEqualTo("admin");
|
||||
assertThat(created.getPasswordHash()).isEqualTo("encoded-password");
|
||||
assertThat(created.getDisplayName()).isEqualTo("管理员");
|
||||
}
|
||||
|
||||
/**
|
||||
* 已存在用户时不得重复创建默认管理员。
|
||||
*/
|
||||
@Test
|
||||
void shouldNotCreateAdministratorWhenAnyUserExists() {
|
||||
when(mapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(1L);
|
||||
|
||||
service.run(null);
|
||||
|
||||
verify(mapper, never()).insertSelective(any(AppUserEntity.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户查询结果应转换为 Spring Security 用户详情,并保留禁用状态。
|
||||
*/
|
||||
@Test
|
||||
void shouldLoadSecurityUserAndResolveUserId() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
AppUserEntity entity = new AppUserEntity();
|
||||
entity.setId(userId);
|
||||
entity.setUsername("admin");
|
||||
entity.setPasswordHash("encoded-password");
|
||||
entity.setEnabled(false);
|
||||
when(mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(entity);
|
||||
|
||||
var details = service.loadUserByUsername("admin");
|
||||
|
||||
assertThat(details.getUsername()).isEqualTo("admin");
|
||||
assertThat(details.getPassword()).isEqualTo("encoded-password");
|
||||
assertThat(details.isEnabled()).isFalse();
|
||||
assertThat(service.requireUserId("admin")).isEqualTo(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录认证查询应保持迁移前 SQL 的三个必要字段,避免读取展示名和审计时间。
|
||||
*/
|
||||
@Test
|
||||
void shouldSelectOnlyAuthenticationColumnsWhenLoadingUser() {
|
||||
AppUserEntity entity = new AppUserEntity();
|
||||
entity.setUsername("admin");
|
||||
entity.setPasswordHash("encoded-password");
|
||||
entity.setEnabled(true);
|
||||
when(mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(entity);
|
||||
|
||||
service.loadUserByUsername("admin");
|
||||
|
||||
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
|
||||
verify(mapper).selectOneByQuery(queryCaptor.capture());
|
||||
String sql = queryCaptor.getValue().toSQL().toLowerCase(java.util.Locale.ROOT);
|
||||
assertThat(sql)
|
||||
.contains("username", "password_hash", "enabled")
|
||||
.doesNotContain("display_name", "last_login_at", "created_at", "updated_at");
|
||||
}
|
||||
|
||||
/**
|
||||
* 不存在的登录名应分别维持认证层和接口层原有的异常类型。
|
||||
*/
|
||||
@Test
|
||||
void shouldRejectMissingUser() {
|
||||
when(mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(null);
|
||||
|
||||
assertThatThrownBy(() -> service.loadUserByUsername("missing"))
|
||||
.isInstanceOf(UsernameNotFoundException.class);
|
||||
assertThatThrownBy(() -> service.requireUserId("missing"))
|
||||
.isInstanceOf(ApiException.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package tech.easyflow.manuagent.model;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
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 com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import tech.easyflow.manuagent.auth.UserService;
|
||||
import tech.easyflow.manuagent.config.AppProperties;
|
||||
import tech.easyflow.manuagent.entity.ModelConfigEntity;
|
||||
import tech.easyflow.manuagent.mapper.AppUserMapper;
|
||||
import tech.easyflow.manuagent.mapper.ModelAssignmentMapper;
|
||||
import tech.easyflow.manuagent.mapper.ModelConfigMapper;
|
||||
|
||||
/**
|
||||
* 验证模型管理接口使用最小字段投影,不把加密 API Key 读入普通请求内存。
|
||||
*/
|
||||
class ModelServiceQueryTest {
|
||||
|
||||
/**
|
||||
* 模型列表只需要页面展示字段,查询 SQL 不得包含密文和密钥版本列。
|
||||
*/
|
||||
@Test
|
||||
void shouldExcludeEncryptedKeyFromModelListQuery() {
|
||||
ModelConfigMapper modelMapper = mock(ModelConfigMapper.class);
|
||||
when(modelMapper.selectListByQuery(any(QueryWrapper.class)))
|
||||
.thenReturn(List.of(modelViewEntity()));
|
||||
ModelService service = new ModelService(
|
||||
modelMapper,
|
||||
mock(ModelAssignmentMapper.class),
|
||||
mock(AppUserMapper.class),
|
||||
mock(UserService.class),
|
||||
mock(KeyCipher.class),
|
||||
mock(AppProperties.class),
|
||||
new ObjectMapper());
|
||||
|
||||
List<ModelService.ModelView> models = service.list();
|
||||
|
||||
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
|
||||
verify(modelMapper).selectListByQuery(queryCaptor.capture());
|
||||
String sql = queryCaptor.getValue().toSQL().toLowerCase(java.util.Locale.ROOT);
|
||||
assertThat(models).hasSize(1);
|
||||
assertThat(sql)
|
||||
.contains("api_key_hint", "capabilities_json", "is_default")
|
||||
.doesNotContain("api_key_ciphertext", "key_version");
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造包含全部展示字段的实体,避免测试依赖数据库或模型密钥解密逻辑。
|
||||
*
|
||||
* @return 模拟数据库返回的安全投影实体
|
||||
*/
|
||||
private ModelConfigEntity modelViewEntity() {
|
||||
ModelConfigEntity entity = new ModelConfigEntity();
|
||||
entity.setId(UUID.randomUUID());
|
||||
entity.setName("测试模型");
|
||||
entity.setProvider("OPENAI_COMPATIBLE");
|
||||
entity.setBaseUrl("https://example.test");
|
||||
entity.setModelId("model");
|
||||
entity.setApiKeyHint("••••1234");
|
||||
entity.setConfigJson("{}");
|
||||
entity.setCapabilitiesJson("{\"contextWindow\":8192}");
|
||||
entity.setEnabled(true);
|
||||
entity.setDefaultModel(true);
|
||||
entity.setUpdatedAt(OffsetDateTime.now());
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,25 @@ package tech.easyflow.manuagent.project;
|
||||
|
||||
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.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import tech.easyflow.manuagent.auth.UserService;
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import tech.easyflow.manuagent.config.AppProperties;
|
||||
import tech.easyflow.manuagent.entity.ProjectFileEntity;
|
||||
import tech.easyflow.manuagent.mapper.ProjectFileMapper;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
/**
|
||||
* 验证项目工作区文件操作。
|
||||
@@ -35,7 +42,7 @@ class ProjectFileServiceTest {
|
||||
"test-master", "admin", "admin", "https://example.test", "model",
|
||||
131_072, "runtime:test", "bridge", Duration.ofMinutes(1));
|
||||
ProjectFileService service = new ProjectFileService(
|
||||
mock(JdbcClient.class), mock(UserService.class), mock(ProjectService.class), properties);
|
||||
mock(ProjectFileMapper.class), mock(UserService.class), mock(ProjectService.class), properties);
|
||||
UUID projectId = UUID.randomUUID();
|
||||
Path file = service.projectRoot(projectId).resolve("inputs/company.txt");
|
||||
Files.createDirectories(file.getParent());
|
||||
@@ -72,17 +79,50 @@ class ProjectFileServiceTest {
|
||||
.isInstanceOf(ApiException.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件列表应保持迁移前 SQL 的字段范围,避免加载存储文件名、摘要和上传人等内部列。
|
||||
*/
|
||||
@Test
|
||||
void shouldSelectOnlyFileViewColumnsWhenListing() {
|
||||
ProjectFileMapper mapper = mock(ProjectFileMapper.class);
|
||||
ProjectService projectService = mock(ProjectService.class);
|
||||
ProjectFileEntity file = new ProjectFileEntity();
|
||||
file.setId(UUID.randomUUID());
|
||||
file.setProjectId(UUID.randomUUID());
|
||||
file.setSizeBytes(1L);
|
||||
when(mapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of(file));
|
||||
ProjectFileService service = new ProjectFileService(
|
||||
mapper, mock(UserService.class), projectService, properties());
|
||||
|
||||
service.list(file.getProjectId());
|
||||
|
||||
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
|
||||
verify(mapper).selectListByQuery(queryCaptor.capture());
|
||||
String sql = queryCaptor.getValue().toSQL().toLowerCase(java.util.Locale.ROOT);
|
||||
assertThat(sql)
|
||||
.contains("original_name", "relative_path", "mime_type", "size_bytes", "created_at")
|
||||
.doesNotContain("stored_name", "sha256", "uploaded_by", "updated_at");
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建使用临时数据目录的文件服务。
|
||||
*
|
||||
* @return 文件服务
|
||||
*/
|
||||
private ProjectFileService service() {
|
||||
AppProperties properties = new AppProperties(
|
||||
return new ProjectFileService(
|
||||
mock(ProjectFileMapper.class), mock(UserService.class), mock(ProjectService.class), properties());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建测试统一使用的应用配置。
|
||||
*
|
||||
* @return 指向临时数据目录的配置
|
||||
*/
|
||||
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));
|
||||
return new ProjectFileService(
|
||||
mock(JdbcClient.class), mock(UserService.class), mock(ProjectService.class), properties);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package tech.easyflow.manuagent.project;
|
||||
|
||||
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.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import tech.easyflow.manuagent.auth.UserService;
|
||||
import tech.easyflow.manuagent.entity.ProjectEntity;
|
||||
import tech.easyflow.manuagent.entity.ProjectPlanEntity;
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import tech.easyflow.manuagent.mapper.ProjectMapper;
|
||||
import tech.easyflow.manuagent.mapper.ProjectPlanMapper;
|
||||
|
||||
/**
|
||||
* 验证项目查询在 MyBatis-Flex 迁移后保持原 JDBC SQL 的字段范围。
|
||||
*/
|
||||
class ProjectServiceQueryTest {
|
||||
|
||||
/**
|
||||
* 项目列表只读取接口视图字段,不加载创建人等内部字段。
|
||||
*/
|
||||
@Test
|
||||
void shouldSelectOnlyProjectViewColumnsWhenListing() {
|
||||
ProjectMapper mapper = mock(ProjectMapper.class);
|
||||
ProjectEntity project = project();
|
||||
when(mapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of(project));
|
||||
ProjectService service = service(mapper);
|
||||
|
||||
service.list();
|
||||
|
||||
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
|
||||
verify(mapper).selectListByQuery(queryCaptor.capture());
|
||||
assertProjectViewProjection(queryCaptor.getValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* 单项目读取与列表共用同一接口投影,且仍按主键精确过滤。
|
||||
*/
|
||||
@Test
|
||||
void shouldSelectOnlyProjectViewColumnsWhenRequiringProject() {
|
||||
ProjectMapper mapper = mock(ProjectMapper.class);
|
||||
ProjectEntity project = project();
|
||||
when(mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(project);
|
||||
ProjectService service = service(mapper);
|
||||
|
||||
ProjectService.ProjectView view = service.require(project.getId());
|
||||
|
||||
assertThat(view.id()).isEqualTo(project.getId());
|
||||
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
|
||||
verify(mapper).selectOneByQuery(queryCaptor.capture());
|
||||
QueryWrapper query = queryCaptor.getValue();
|
||||
assertProjectViewProjection(query);
|
||||
assertThat(query.toSQL().toLowerCase(java.util.Locale.ROOT)).contains("where", "id");
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据库存量规划 JSON 损坏仍应进入统一未预期异常路径,不新增业务错误码。
|
||||
*/
|
||||
@Test
|
||||
void shouldKeepInvalidStoredPlanJsonAsUnexpectedTechnicalFailure() {
|
||||
ProjectPlanMapper planMapper = mock(ProjectPlanMapper.class);
|
||||
ProjectPlanEntity plan = new ProjectPlanEntity();
|
||||
plan.setPlanJson("{invalid-json");
|
||||
when(planMapper.selectCurrent(any(UUID.class))).thenReturn(plan);
|
||||
ProjectService service = new ProjectService(
|
||||
mock(ProjectMapper.class), planMapper, mock(UserService.class), new ObjectMapper());
|
||||
|
||||
assertThatThrownBy(() -> service.currentPlan(UUID.randomUUID()))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.isNotInstanceOf(ApiException.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建满足项目接口映射要求的最小实体。
|
||||
*
|
||||
* @return 项目实体
|
||||
*/
|
||||
private ProjectEntity project() {
|
||||
ProjectEntity project = new ProjectEntity();
|
||||
project.setId(UUID.randomUUID());
|
||||
project.setVersion(1L);
|
||||
return project;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建仅用于查询行为验证的项目服务。
|
||||
*
|
||||
* @param mapper 待验证的项目 Mapper
|
||||
* @return 项目服务
|
||||
*/
|
||||
private ProjectService service(ProjectMapper mapper) {
|
||||
return new ProjectService(
|
||||
mapper,
|
||||
mock(ProjectPlanMapper.class),
|
||||
mock(UserService.class),
|
||||
new ObjectMapper());
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言查询只包含迁移前项目视图 SQL 使用的字段。
|
||||
*
|
||||
* @param query 待检查的 MyBatis-Flex 查询
|
||||
*/
|
||||
private void assertProjectViewProjection(QueryWrapper query) {
|
||||
String sql = query.toSQL().toLowerCase(java.util.Locale.ROOT);
|
||||
assertThat(sql)
|
||||
.contains(
|
||||
"company_name",
|
||||
"project_name",
|
||||
"agui_thread_id",
|
||||
"application_level",
|
||||
"status",
|
||||
"version",
|
||||
"created_at",
|
||||
"updated_at")
|
||||
.doesNotContain("created_by");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user