This commit is contained in:
2026-08-29 13:32:57 +08:00
commit c56aa6e752
81 changed files with 14319 additions and 0 deletions

View File

@@ -0,0 +1,238 @@
package cn.alphaline.smartfactory;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import cn.alphaline.smartfactory.agent.AgentEventService;
import cn.alphaline.smartfactory.artifact.ArtifactService;
import cn.alphaline.smartfactory.artifact.DocxValidator;
import cn.alphaline.smartfactory.project.ProjectService;
import cn.alphaline.smartfactory.project.ProjectFileService;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.DriverManager;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.postgresql.ds.PGSimpleDataSource;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
/**
* 验证 PostgreSQL 17 全量迁移及事件游标回放。
*/
@Testcontainers
class DatabaseAndEventIntegrationTest {
@Container
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:17-alpine");
@TempDir
private Path temporaryDirectory;
/**
* 在干净 PostgreSQL 17 实例执行并校验全部 Flyway 迁移。
*/
@BeforeAll
static void migrate() {
Flyway flyway = Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.load();
flyway.migrate();
flyway.validate();
assertThat(flyway.migrate().migrationsExecuted).isZero();
}
/**
* 验证核心表、索引和约束已建立。
*
* @throws Exception 数据库访问失败时抛出
*/
@Test
void shouldCreateCoreSchemaOnPostgres17() throws Exception {
try (var connection = DriverManager.getConnection(
POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword());
var statement = connection.createStatement();
var result = statement.executeQuery("""
SELECT count(*) FROM information_schema.tables
WHERE table_schema IN ('app', 'agentscope')
""")) {
assertThat(result.next()).isTrue();
assertThat(result.getInt(1)).isEqualTo(12);
}
}
/**
* 验证事件按项目全局 ID 增量回放且不重复。
*/
@Test
void shouldReplayEventsAfterCursorInOrder() {
JdbcClient jdbc = jdbc();
UUID userId = UUID.randomUUID();
UUID modelId = UUID.randomUUID();
UUID projectId = UUID.randomUUID();
UUID runId = 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", "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)
""").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)
VALUES (:id, '企业', '项目', :thread, 'ADVANCED', :userId)
""").param("id", projectId).param("thread", "t-" + projectId).param("userId", userId).update();
jdbc.sql("""
INSERT INTO app.agent_run(id, project_id, model_config_id, trigger_type, status, trace_id)
VALUES (:id, :projectId, :modelId, 'INITIAL', 'RUNNING', :trace)
""").param("id", runId).param("projectId", projectId).param("modelId", modelId)
.param("trace", UUID.randomUUID().toString()).update();
AgentEventService service = new AgentEventService(jdbc, 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();
assertThat(service.listAfter(projectId, first, 100))
.extracting(AgentEventService.EventView::id)
.containsExactly(second, third);
var next = service.streamAfter(projectId, third)
.filter(event -> !"HEARTBEAT".equals(event.type()))
.next()
.toFuture();
long pushed = service.append(projectId, runId, "TEXT_MESSAGE_CONTENT", Map.of("delta", "推送")).id();
assertThat(next.orTimeout(2, TimeUnit.SECONDS).join().id()).isEqualTo(pushed);
}
/**
* 验证重试生成同一路径产物时更新登记信息,避免唯一约束导致成功 Run 被标记失败。
*
* @throws Exception 临时文件写入失败时抛出
*/
@Test
void shouldReplaceArtifactMetadataForSameProjectPath() throws Exception {
JdbcClient jdbc = jdbc();
UUID userId = UUID.randomUUID();
UUID modelId = UUID.randomUUID();
UUID projectId = UUID.randomUUID();
UUID firstRunId = UUID.randomUUID();
UUID secondRunId = 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", "u-" + userId).update();
jdbc.sql("""
INSERT INTO app.model_config(id, name, provider, base_url, model_id)
VALUES (:id, :name, 'OPENAI_COMPATIBLE', 'https://example.test', 'model')
""").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)
VALUES (:id, '企业', '项目', :thread, 'ADVANCED', :userId)
""").param("id", projectId).param("thread", "t-" + projectId).param("userId", userId).update();
for (UUID runId : List.of(firstRunId, secondRunId)) {
jdbc.sql("""
INSERT INTO app.agent_run(
id, project_id, model_config_id, trigger_type, status, trace_id, ended_at)
VALUES (:id, :projectId, :modelId, 'RETRY', 'COMPLETED', :trace, CURRENT_TIMESTAMP)
""").param("id", runId).param("projectId", projectId).param("modelId", modelId)
.param("trace", UUID.randomUUID().toString()).update();
}
Path document = temporaryDirectory.resolve("draft.docx");
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());
ObjectMapper mapper = new ObjectMapper();
ArtifactService.ArtifactView first = artifacts.publish(
projectId, firstRunId, "DOCX", "draft.docx", "artifacts/draft.docx",
mapper.createObjectNode().put("version", 1));
Files.writeString(document, "second version");
ArtifactService.ArtifactView second = artifacts.publish(
projectId, secondRunId, "DOCX", "draft.docx", "artifacts/draft.docx",
mapper.createObjectNode().put("version", 2));
assertThat(second.id()).isEqualTo(first.id());
assertThat(second.runId()).isEqualTo(secondRunId);
assertThat(second.sizeBytes()).isEqualTo(Files.size(document));
assertThat(artifacts.list(projectId)).hasSize(1);
}
/**
* 验证项目真删除会清除所有关联业务记录。
*/
@Test
void shouldDeleteProjectRecords() {
JdbcClient jdbc = jdbc();
UUID userId = UUID.randomUUID();
UUID projectId = UUID.randomUUID();
UUID runId = 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", "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", "t-" + projectId).param("userId", userId).update();
jdbc.sql("""
INSERT INTO app.agent_run(id, project_id, trigger_type, status, trace_id, ended_at)
VALUES (:id, :projectId, 'INITIAL', 'COMPLETED', :trace, CURRENT_TIMESTAMP)
""").param("id", runId).param("projectId", projectId)
.param("trace", UUID.randomUUID().toString()).update();
jdbc.sql("""
INSERT INTO app.agent_event(project_id, run_id, event_type, payload)
VALUES (:projectId, :runId, 'RUN_FINISHED', '{}'::jsonb)
""").param("projectId", projectId).param("runId", runId).update();
jdbc.sql("""
INSERT INTO app.project_plan(id, project_id, plan_version, status, plan_json, created_by)
VALUES (:id, :projectId, 1, 'DRAFT', '{}'::jsonb, :userId)
""").param("id", UUID.randomUUID()).param("projectId", projectId).param("userId", userId).update();
jdbc.sql("""
INSERT INTO app.project_file(
id, project_id, original_name, stored_name, relative_path, mime_type,
extension, size_bytes, sha256, uploaded_by)
VALUES (:id, :projectId, 'input.txt', 'input.txt', 'inputs/input.txt',
'text/plain', 'txt', 1, :sha, :userId)
""").param("id", UUID.randomUUID()).param("projectId", projectId)
.param("sha", "0".repeat(64)).param("userId", userId).update();
jdbc.sql("""
INSERT INTO app.artifact(
id, project_id, run_id, kind, name, relative_path, mime_type, size_bytes, sha256)
VALUES (:id, :projectId, :runId, 'OTHER', 'result.txt', 'artifacts/result.txt',
'text/plain', 1, :sha)
""").param("id", UUID.randomUUID()).param("projectId", projectId).param("runId", runId)
.param("sha", "0".repeat(64)).update();
ProjectService service = new ProjectService(jdbc, mock(cn.alphaline.smartfactory.auth.UserService.class), new ObjectMapper());
service.delete(projectId);
for (String table : List.of("agent_event", "artifact", "project_plan", "project_file", "agent_run")) {
Long count = jdbc.sql("SELECT COUNT(*) FROM app." + table + " WHERE project_id = :projectId")
.param("projectId", projectId)
.query(Long.class)
.single();
assertThat(count).as(table).isZero();
}
assertThat(jdbc.sql("SELECT COUNT(*) FROM app.project WHERE id = :projectId")
.param("projectId", projectId)
.query(Long.class)
.single()).isZero();
}
private JdbcClient jdbc() {
PGSimpleDataSource source = new PGSimpleDataSource();
source.setURL(POSTGRES.getJdbcUrl());
source.setUser(POSTGRES.getUsername());
source.setPassword(POSTGRES.getPassword());
return JdbcClient.create(source);
}
}

View File

@@ -0,0 +1,35 @@
package cn.alphaline.smartfactory;
import static org.assertj.core.api.Assertions.assertThat;
import cn.alphaline.smartfactory.config.AppProperties;
import cn.alphaline.smartfactory.model.KeyCipher;
import java.nio.file.Path;
import java.time.Duration;
import org.junit.jupiter.api.Test;
/**
* 验证模型密钥保护。
*/
class KeyCipherAndShellTest {
/**
* 验证密钥可恢复且相同明文每次产生不同密文。
*/
@Test
void shouldEncryptModelKeyWithRandomIv() {
KeyCipher cipher = new KeyCipher(properties());
byte[] first = cipher.encrypt("local-test-key");
byte[] second = cipher.encrypt("local-test-key");
assertThat(first).isNotEqualTo(second);
assertThat(cipher.decrypt(first)).isEqualTo("local-test-key");
}
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));
}
}

View File

@@ -0,0 +1,27 @@
package cn.alphaline.smartfactory.agent;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
/**
* 验证 Agent 事件持久化的精简规则。
*/
class AgentExecutionServiceTest {
/**
* 验证文档视觉结果保留图片路径元数据并丢弃 Base64 正文。
*/
@Test
void shouldStripInlineImageDataFromPersistedToolResult() {
String content = """
document_view_result={"images":[{"path":"work/tmp/document-view/a/render-1.png"}]}
{"type":"image","source":{"media_type":"image/png","data":"very-large-base64"}}
""";
String result = AgentExecutionService.stripInlineImageData(content);
assertThat(result).contains("render-1.png");
assertThat(result).doesNotContain("very-large-base64");
}
}

View File

@@ -0,0 +1,81 @@
package cn.alphaline.smartfactory.agent;
import static org.assertj.core.api.Assertions.assertThat;
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.util.Map;
import org.junit.jupiter.api.Test;
/**
* 验证 Agent Run 的模型重连边界。
*/
class AgentRunServiceTest {
/**
* 网络故障和服务端错误允许重连,参数错误保持原始失败。
*/
@Test
void shouldRetryOnlyRecoverableModelFailures() {
assertThat(AgentExecutionService.MAX_MODEL_RECONNECTS).isEqualTo(5);
assertThat(AgentExecutionService.isRetryableModelFailure(
new RuntimeException(new HttpTransportException("disconnected")))).isTrue();
assertThat(AgentExecutionService.isRetryableModelFailure(
new HttpTransportException("unavailable", 503, ""))).isTrue();
assertThat(AgentExecutionService.isRetryableModelFailure(
new HttpTransportException("invalid request", 400, ""))).isFalse();
assertThat(AgentExecutionService.isRetryableModelFailure(
new IllegalArgumentException("invalid prompt"))).isFalse();
}
/**
* 验证上下文压缩只按模型窗口 90% Token 触发。
*/
@Test
void shouldCompactAtNinetyPercentTokensOnly() {
var config = AgentFactory.compactionFor(100_000, "summary");
assertThat(config.getTriggerTokens()).isEqualTo(90_000);
assertThat(config.getTriggerMessages()).isZero();
assertThat(config.getSummaryPrompt()).isEqualTo("summary");
}
/**
* 验证 Agent 调用 Skill 时使用无来源后缀的名称,并完整保留仓库信息。
*/
@Test
void shouldExposeCanonicalSkillIdWithoutLosingSkillInformation() {
AgentSkill source = new AgentSkill(
Map.of("name", "pdf", "description", "读取 PDF", "version", "1.0"),
"使用说明",
Map.of("references/guide.md", "参考内容"),
"imported",
Path.of("/skills/pdf"));
AgentSkill canonical = AgentFactory.canonicalSkill(source);
assertThat(canonical.getSkillId()).isEqualTo("pdf");
assertThat(canonical.getMetadata()).isEqualTo(source.getMetadata());
assertThat(canonical.getSkillContent()).isEqualTo(source.getSkillContent());
assertThat(canonical.getResources()).isEqualTo(source.getResources());
assertThat(canonical.getSource()).isEqualTo("imported");
assertThat(canonical.getOriginDir()).isEqualTo(source.getOriginDir());
}
/**
* 验证模型输出年份数组时可归一化为前端和确认接口使用的规划年数。
*/
@Test
void shouldNormalizePlanningYearArray() {
ObjectNode plan = new ObjectMapper().createObjectNode();
plan.putArray("planningYears").add("2026").add("2027");
AgentOutputService.normalizePlanningYears(plan);
assertThat(plan.path("planningYears").asInt()).isEqualTo(2);
assertThat(plan.path("planningPeriod").asText()).isEqualTo("2026-2027");
}
}

View File

@@ -0,0 +1,28 @@
package cn.alphaline.smartfactory.agent;
import static org.assertj.core.api.Assertions.assertThat;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.agentscope.core.model.ToolSchema;
import io.agentscope.core.tool.Toolkit;
import org.junit.jupiter.api.Test;
/**
* 验证文档视觉工具可以被 AgentScope 注册并暴露结构化参数。
*/
class DocumentViewToolTest {
/**
* 验证多视图参数能够进入模型工具定义。
*/
@Test
void shouldRegisterMultiViewSchema() {
Toolkit toolkit = new Toolkit();
toolkit.registerTool(new DocumentViewTool(new ObjectMapper()));
ToolSchema schema = toolkit.getToolSchemas().getFirst();
assertThat(schema.getName()).isEqualTo("document_view");
assertThat(schema.getParameters().toString()).contains("views", "path", "page", "sheet", "range");
}
}

View File

@@ -0,0 +1,66 @@
package cn.alphaline.smartfactory.agent;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.harness.agent.filesystem.model.ExecuteResponse;
import io.agentscope.harness.agent.filesystem.sandbox.AbstractSandboxFilesystem;
import io.agentscope.harness.agent.workspace.WorkspacePathNormalizer;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import org.junit.jupiter.api.Test;
/**
* 验证文件读取工具不会静默丢失后续内容。
*/
class PagedReadFileToolTest {
/**
* 沙箱返回分页元数据时,应将下一页位置明确告知 Agent。
*/
@Test
void shouldTellAgentHowToContinueReading() {
AbstractSandboxFilesystem filesystem = mock(AbstractSandboxFilesystem.class);
String content = Base64.getEncoder().encodeToString("第一行\n第二行".getBytes(StandardCharsets.UTF_8));
when(filesystem.execute(any(RuntimeContext.class), anyString(), isNull()))
.thenReturn(new ExecuteResponse(
"{\"ok\":true,\"truncated\":true,\"nextOffset\":2,\"returnedLines\":2,\"lineTooLong\":false}\n"
+ content,
0,
false));
PagedReadFileTool tool = new PagedReadFileTool(
filesystem, WorkspacePathNormalizer.of("/workspace"), new ObjectMapper());
String result = tool.readFile(RuntimeContext.empty(), "/workspace/work/report.txt", 0, 2);
assertThat(result).contains("第一行\n第二行");
assertThat(result).contains("内容未读完");
assertThat(result).contains("offset=2");
}
/**
* 沙箱自身发生截断时,应明确提示重试,不能把残缺内容当作完整结果。
*/
@Test
void shouldExposeUnexpectedSandboxTruncation() {
AbstractSandboxFilesystem filesystem = mock(AbstractSandboxFilesystem.class);
when(filesystem.execute(any(RuntimeContext.class), anyString(), isNull()))
.thenReturn(new ExecuteResponse(
"{\"ok\":true,\"truncated\":true,\"nextOffset\":10,\"returnedLines\":10,\"lineTooLong\":false}\n",
0,
true));
PagedReadFileTool tool = new PagedReadFileTool(
filesystem, WorkspacePathNormalizer.of("/workspace"), new ObjectMapper());
String result = tool.readFile(RuntimeContext.empty(), "work/report.txt", 0, 10);
assertThat(result).contains("内容未读完");
assertThat(result).contains("offset=10");
}
}

View File

@@ -0,0 +1,125 @@
package cn.alphaline.smartfactory.artifact;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import cn.alphaline.smartfactory.common.ApiException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/**
* 验证 DOCX 发布边界会拒绝伪装文件和批注错锚。
*/
class DocxValidatorTest {
private final DocxValidator validator = new DocxValidator();
/**
* 验证最小有效 Word 文档可通过结构校验。
*
* @param directory 临时目录
* @throws IOException DOCX 写入失败时抛出
*/
@Test
void shouldValidateMinimalDocx(@TempDir Path directory) throws IOException {
Path docx = writeDocx(directory.resolve("valid.docx"), Map.of());
DocxValidator.ValidationResult result = validator.validate(docx);
assertThat(result.entryCount()).isEqualTo(3);
assertThat(result.commentCount()).isZero();
}
/**
* 验证批注锚点与批注定义不一致时拒绝发布。
*
* @param directory 临时目录
* @throws IOException DOCX 写入失败时抛出
*/
@Test
void shouldRejectBrokenCommentAnchor(@TempDir Path directory) throws IOException {
Map<String, String> extras = new LinkedHashMap<>();
extras.put("word/comments.xml", """
<?xml version="1.0" encoding="UTF-8"?>
<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:comment w:id="9"><w:p><w:r><w:t>待确认</w:t></w:r></w:p></w:comment>
</w:comments>
""");
extras.put("word/_rels/document.xml.rels", """
<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="comments" Target="comments.xml"/>
</Relationships>
""");
Path docx = writeDocx(directory.resolve("broken.docx"), extras);
assertThatThrownBy(() -> validator.validate(docx))
.isInstanceOf(ApiException.class)
.hasMessageContaining("批注");
}
/**
* 验证同一批注编号存在多个正文锚点时拒绝发布。
*
* @param directory 临时目录
* @throws IOException DOCX 写入失败时抛出
*/
@Test
void shouldRejectDuplicateCommentAnchor(@TempDir Path directory) throws IOException {
Map<String, String> extras = new LinkedHashMap<>();
extras.put("word/document.xml", """
<?xml version="1.0" encoding="UTF-8"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body><w:p>
<w:commentRangeStart w:id="9"/><w:r><w:t>待确认</w:t></w:r><w:commentRangeEnd w:id="9"/>
<w:r><w:commentReference w:id="9"/></w:r><w:r><w:commentReference w:id="9"/></w:r>
</w:p></w:body>
</w:document>
""");
extras.put("word/comments.xml", """
<?xml version="1.0" encoding="UTF-8"?>
<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:comment w:id="9"><w:p><w:r><w:t>待确认</w:t></w:r></w:p></w:comment>
</w:comments>
""");
extras.put("word/_rels/document.xml.rels", """
<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="comments" Target="comments.xml"/>
</Relationships>
""");
assertThatThrownBy(() -> validator.validate(writeDocx(directory.resolve("duplicate.docx"), extras)))
.isInstanceOf(ApiException.class)
.hasMessageContaining("重复");
}
private Path writeDocx(Path path, Map<String, String> extras) throws IOException {
Map<String, String> entries = new LinkedHashMap<>();
entries.put("[Content_Types].xml", "<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"/>");
entries.put("_rels/.rels", "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"/>");
entries.put("word/document.xml", """
<?xml version="1.0" encoding="UTF-8"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body><w:p><w:r><w:t>智能工厂申报书</w:t></w:r></w:p></w:body>
</w:document>
""");
entries.putAll(extras);
try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(path))) {
for (Map.Entry<String, String> entry : entries.entrySet()) {
zip.putNextEntry(new ZipEntry(entry.getKey()));
zip.write(entry.getValue().getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
}
}
return path;
}
}

View File

@@ -0,0 +1,43 @@
package cn.alphaline.smartfactory.auth;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
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 cn.alphaline.smartfactory.common.GlobalExceptionHandler;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
/**
* 验证登录边界返回稳定的认证错误码。
*/
class AuthControllerTest {
/**
* 验证错误密码返回 401且不会落入 500 兜底。
*
* @throws Exception MockMvc 执行失败时抛出
*/
@Test
void shouldReturnUnauthorizedForWrongPassword() throws Exception {
AuthenticationManager manager = mock(AuthenticationManager.class);
when(manager.authenticate(any())).thenThrow(new BadCredentialsException("bad credentials"));
MockMvc mvc = MockMvcBuilders.standaloneSetup(new AuthController(manager))
.setControllerAdvice(new GlobalExceptionHandler())
.build();
mvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"admin\",\"password\":\"wrong-password\"}"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("AUTHENTICATION_FAILED"))
.andExpect(jsonPath("$.message").value("用户名或密码错误"));
}
}

View File

@@ -0,0 +1,24 @@
package cn.alphaline.smartfactory.common;
import static org.assertj.core.api.Assertions.assertThatCode;
import org.junit.jupiter.api.Test;
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
/**
* 验证统一异常边界对流式连接终止的处理。
*/
class GlobalExceptionHandlerTest {
/**
* 客户端断开已提交的流式响应时不再尝试写入 JSON 错误体。
*/
@Test
void shouldIgnoreExpectedStreamDisconnect() {
GlobalExceptionHandler handler = new GlobalExceptionHandler();
assertThatCode(() -> handler.handleClientDisconnect(
new AsyncRequestNotUsableException("ServletOutputStream failed to flush")))
.doesNotThrowAnyException();
}
}

View File

@@ -0,0 +1,87 @@
package cn.alphaline.smartfactory.project;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import cn.alphaline.smartfactory.auth.UserService;
import cn.alphaline.smartfactory.config.AppProperties;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.jdbc.core.simple.JdbcClient;
/**
* 验证项目工作区文件操作。
*/
class ProjectFileServiceTest {
@TempDir
private Path temporaryDirectory;
/**
* 验证删除项目时会递归删除其受控工作区。
*
* @throws Exception 测试文件创建失败时抛出
*/
@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));
ProjectFileService service = new ProjectFileService(
mock(JdbcClient.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());
Files.writeString(file, "企业材料");
service.deleteWorkspace(projectId);
assertThat(service.projectRoot(projectId)).doesNotExist();
}
/**
* 验证上传路径保留文件夹与原文件名。
*/
@Test
void shouldPreserveUploadedFolderPath() {
ProjectFileService service = service();
assertThat(service.normalizeUploadPath("测试输入/场景一/设备清单.xlsx", "设备清单.xlsx"))
.isEqualTo("inputs/测试输入/场景一/设备清单.xlsx");
assertThat(service.normalizeUploadPath(null, "企业材料.pdf"))
.isEqualTo("inputs/企业材料.pdf");
}
/**
* 验证上传路径不能越出 inputs 工作区。
*/
@Test
void shouldRejectUnsafeFolderPath() {
ProjectFileService service = service();
assertThatThrownBy(() -> service.normalizeUploadPath("../企业材料.pdf", "企业材料.pdf"))
.isInstanceOf(cn.alphaline.smartfactory.common.ApiException.class);
assertThatThrownBy(() -> service.normalizeUploadPath("其他文件.pdf", "企业材料.pdf"))
.isInstanceOf(cn.alphaline.smartfactory.common.ApiException.class);
}
/**
* 创建使用临时数据目录的文件服务。
*
* @return 文件服务
*/
private ProjectFileService service() {
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));
return new ProjectFileService(
mock(JdbcClient.class), mock(UserService.class), mock(ProjectService.class), properties);
}
}

View File

@@ -0,0 +1,73 @@
package cn.alphaline.smartfactory.skill;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.web.MockMultipartFile;
/**
* 验证 Skill ZIP 的目录识别与无关文件过滤。
*/
class SkillArchiveTest {
@TempDir
private Path temporaryDirectory;
/**
* 验证 macOS 元数据与 Python 缓存不会阻止有效 Skill 导入。
*
* @throws Exception ZIP 构造或解压失败时抛出
*/
@Test
void shouldLocateSkillAndIgnoreGeneratedFiles() throws Exception {
Map<String, byte[]> files = new LinkedHashMap<>();
files.put("sample/SKILL.md", "---\nname: sample\ndescription: 示例 Skill\n---\n规则".getBytes(StandardCharsets.UTF_8));
files.put("sample/scripts/run.py", "print('ok')".getBytes(StandardCharsets.UTF_8));
files.put("__MACOSX/._sample", new byte[]{0, 1});
files.put("sample/.DS_Store", new byte[]{0, 1});
files.put("sample/scripts/__pycache__/run.pyc", new byte[]{0, 1});
MockMultipartFile archive = new MockMultipartFile(
"file", "sample.zip", "application/zip", zip(files));
Path extracted = temporaryDirectory.resolve("extracted");
Files.createDirectories(extracted);
SkillService.unzip(archive, extracted);
Path skillRoot = SkillService.locateSkillRoot(extracted);
SkillPackageReader.SkillPackage skillPackage = new SkillPackageReader().read(skillRoot, "imported");
assertThat(skillRoot.getFileName().toString()).isEqualTo("sample");
assertThat(skillPackage.skill().getName()).isEqualTo("sample");
assertThat(skillPackage.skill().getResourcePaths()).containsExactly("scripts/run.py");
assertThat(extracted.resolve("__MACOSX")).doesNotExist();
assertThat(skillRoot.resolve("scripts/__pycache__")).doesNotExist();
}
/**
* 构造测试 ZIP。
*
* @param files ZIP 内文件
* @return ZIP 字节
* @throws Exception ZIP 写入失败时抛出
*/
private byte[] zip(Map<String, byte[]> files) throws Exception {
ByteArrayOutputStream output = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(output, StandardCharsets.UTF_8)) {
for (Map.Entry<String, byte[]> file : files.entrySet()) {
zip.putNextEntry(new ZipEntry(file.getKey()));
zip.write(file.getValue());
zip.closeEntry();
}
}
return output.toByteArray();
}
}