ForcePilot/backend/test/test_skill_service.py
Wenjie Zhang e4ae12dc75 refactor: 重构并清理多个模块的测试
- 移除了文件系统和沙盒路由器的过时集成测试。
- 更新了文件系统路由器测试,确保正确的身份验证和线程所有权检查。
- 调整了沙盒端到端测试,以改进附件处理和命令执行。
- 修改了设置路由器测试,对未授权访问返回404而不是401/403。
- 增强了任务路由器测试,以处理精简模式场景并确保任务创建和查询按预期工作。
- 优化了查看器文件系统测试,使用附件上传代替直接文件写入。
- 改进了技能后端测试中的错误处理,为越界访问引发适当的异常。
2026-03-25 06:32:14 +08:00

480 lines
15 KiB
Python

from __future__ import annotations
import io
import zipfile
from pathlib import Path
from types import SimpleNamespace
import pytest
from yuxi.services import skill_service as svc
from yuxi.services import tool_service
from yuxi.storage.postgres.models_business import Skill
def _build_zip(files: dict[str, str]) -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for path, content in files.items():
zf.writestr(path, content)
return buf.getvalue()
def test_parse_skill_markdown_ok():
content = "---\nname: demo-skill\ndescription: demo description\n---\n# Demo\n"
name, desc, meta = svc._parse_skill_markdown(content)
assert name == "demo-skill"
assert desc == "demo description"
assert meta["name"] == "demo-skill"
def test_parse_skill_markdown_requires_frontmatter():
with pytest.raises(ValueError, match="frontmatter"):
svc._parse_skill_markdown("# missing")
def test_is_valid_skill_slug():
# Test valid slugs
assert svc.is_valid_skill_slug("demo-skill") is True
assert svc.is_valid_skill_slug("valid-name-123") is True
# Test invalid slugs
assert svc.is_valid_skill_slug("../bad") is False
assert svc.is_valid_skill_slug("Invalid") is False # uppercase not allowed
assert svc.is_valid_skill_slug("") is False
@pytest.mark.asyncio
async def test_get_skill_dependency_options(monkeypatch: pytest.MonkeyPatch):
# Mock get_tool_metadata to return tool list
def fake_get_tool_metadata(category=None):
return [
{"id": "calculator", "name": "Calculator"},
{"id": "search", "name": "Search"},
]
monkeypatch.setattr(tool_service, "get_tool_metadata", fake_get_tool_metadata)
monkeypatch.setattr(svc, "get_mcp_server_names", lambda: ["mcp-a", "mcp-b"])
class FakeRepo:
def __init__(self, _db):
pass
async def list_all(self):
return [
Skill(slug="alpha", name="alpha", description="a", dir_path="skills/alpha"),
Skill(slug="beta", name="beta", description="b", dir_path="skills/beta"),
]
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
result = await svc.get_skill_dependency_options(None)
assert result["tools"] == [{"id": "calculator", "name": "Calculator"}, {"id": "search", "name": "Search"}]
assert result["mcps"] == ["mcp-a", "mcp-b"]
assert result["skills"] == ["alpha", "beta"]
def test_resolve_relative_path_blocks_traversal(tmp_path: Path):
skill_dir = tmp_path / "skill"
skill_dir.mkdir(parents=True, exist_ok=True)
with pytest.raises(ValueError, match="上级路径"):
svc._resolve_relative_path(skill_dir, "../outside.txt")
@pytest.mark.asyncio
async def test_import_skill_zip_conflict_rewrite_name(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))
class FakeRepo:
existing_slugs = {"demo"}
created_item: Skill | None = None
def __init__(self, _db):
pass
async def exists_slug(self, slug: str) -> bool:
return slug in self.__class__.existing_slugs
async def create(
self,
*,
slug: str,
name: str,
description: str,
tool_dependencies: list[str] | None,
mcp_dependencies: list[str] | None,
skill_dependencies: list[str] | None,
dir_path: str,
created_by: str | None,
) -> Skill:
item = Skill(
slug=slug,
name=name,
description=description,
tool_dependencies=tool_dependencies or [],
mcp_dependencies=mcp_dependencies or [],
skill_dependencies=skill_dependencies or [],
dir_path=dir_path,
created_by=created_by,
updated_by=created_by,
)
self.__class__.existing_slugs.add(slug)
self.__class__.created_item = item
return item
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
zip_bytes = _build_zip(
{
"demo/SKILL.md": ("---\nname: demo\ndescription: this is demo\n---\n# Demo\n"),
"demo/prompts/system.md": "You are demo skill",
}
)
item = await svc.import_skill_zip(
None,
filename="demo.zip",
file_bytes=zip_bytes,
created_by="root",
)
assert item.slug == "demo-v2"
assert item.name == "demo-v2"
skill_md = (tmp_path / "skills" / "demo-v2" / "SKILL.md").read_text(encoding="utf-8")
assert "name: demo-v2" in skill_md
@pytest.mark.asyncio
async def test_update_skill_md_syncs_metadata(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))
skill_dir = tmp_path / "skills" / "demo"
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: demo\ndescription: old\n---\n# old\n",
encoding="utf-8",
)
item = Skill(
slug="demo",
name="demo",
description="old",
dir_path="skills/demo",
created_by="root",
updated_by="root",
)
async def fake_get_skill_or_raise(_db, _slug: str):
return item
updates: dict[str, str | None] = {}
class FakeRepo:
def __init__(self, _db):
pass
async def update_metadata(
self,
_item: Skill,
*,
name: str,
description: str,
updated_by: str | None,
) -> Skill:
updates["name"] = name
updates["description"] = description
updates["updated_by"] = updated_by
return item
monkeypatch.setattr(svc, "get_skill_or_raise", fake_get_skill_or_raise)
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
new_content = "---\nname: demo\ndescription: updated desc\n---\n# updated\n"
await svc.update_skill_file(
None,
slug="demo",
relative_path="SKILL.md",
content=new_content,
updated_by="admin",
)
assert updates["name"] == "demo"
assert updates["description"] == "updated desc"
assert updates["updated_by"] == "admin"
saved_content = (skill_dir / "SKILL.md").read_text(encoding="utf-8")
assert "description: updated desc" in saved_content
@pytest.mark.asyncio
async def test_update_skill_dependencies(monkeypatch: pytest.MonkeyPatch):
item = Skill(
slug="alpha",
name="alpha",
description="alpha",
dir_path="skills/alpha",
tool_dependencies=[],
mcp_dependencies=[],
skill_dependencies=[],
)
# Mock get_tool_metadata to return tool list
def fake_get_tool_metadata(category=None):
return [{"id": "calculator", "name": "Calculator"}]
monkeypatch.setattr(tool_service, "get_tool_metadata", fake_get_tool_metadata)
monkeypatch.setattr(svc, "get_mcp_server_names", lambda: ["mcp-a"])
async def fake_get_skill_or_raise(_db, slug: str):
assert slug == "alpha"
return item
captured: dict[str, list[str] | str | None] = {}
class FakeRepo:
def __init__(self, _db):
pass
async def list_all(self):
return [
item,
Skill(
slug="beta",
name="beta",
description="beta",
dir_path="skills/beta",
tool_dependencies=[],
mcp_dependencies=[],
skill_dependencies=[],
),
]
async def update_dependencies(
self,
_item: Skill,
*,
tool_dependencies: list[str],
mcp_dependencies: list[str],
skill_dependencies: list[str],
updated_by: str | None,
):
captured["tool_dependencies"] = tool_dependencies
captured["mcp_dependencies"] = mcp_dependencies
captured["skill_dependencies"] = skill_dependencies
captured["updated_by"] = updated_by
_item.tool_dependencies = tool_dependencies
_item.mcp_dependencies = mcp_dependencies
_item.skill_dependencies = skill_dependencies
return _item
monkeypatch.setattr(svc, "get_skill_or_raise", fake_get_skill_or_raise)
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
updated = await svc.update_skill_dependencies(
None,
slug="alpha",
tool_dependencies=["calculator", "calculator"],
mcp_dependencies=["mcp-a", "mcp-a"],
skill_dependencies=["beta", "beta"],
updated_by="root",
)
assert captured["tool_dependencies"] == ["calculator"]
assert captured["mcp_dependencies"] == ["mcp-a"]
assert captured["skill_dependencies"] == ["beta"]
assert captured["updated_by"] == "root"
assert updated.skill_dependencies == ["beta"]
@pytest.mark.asyncio
async def test_init_builtin_skills_create_missing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))
source_dir = tmp_path / "builtin-skills" / "reporter"
source_dir.mkdir(parents=True, exist_ok=True)
(source_dir / "SKILL.md").write_text(
"---\n"
"name: reporter\n"
"description: SQL report\n"
"---\n"
"# SQL Reporter\n",
encoding="utf-8",
)
(source_dir / "prompts").mkdir(parents=True, exist_ok=True)
(source_dir / "prompts" / "system.md").write_text("prompt", encoding="utf-8")
monkeypatch.setattr(
svc,
"get_builtin_skill_specs",
lambda: [
SimpleNamespace(
slug="reporter",
source_dir=source_dir,
description="SQL report from python",
tool_dependencies=("mysql_query",),
mcp_dependencies=("charts",),
skill_dependencies=("common-report",),
)
],
)
class FakeRepo:
created: list[dict] = []
def __init__(self, _db):
pass
async def get_by_slug(self, slug: str):
return None
async def create(
self,
*,
slug: str,
name: str,
description: str,
tool_dependencies: list[str] | None,
mcp_dependencies: list[str] | None,
skill_dependencies: list[str] | None,
dir_path: str,
created_by: str | None,
) -> Skill:
self.__class__.created.append(
{
"slug": slug,
"name": name,
"description": description,
"tool_dependencies": tool_dependencies,
"mcp_dependencies": mcp_dependencies,
"skill_dependencies": skill_dependencies,
"dir_path": dir_path,
"created_by": created_by,
}
)
return Skill(
slug=slug,
name=name,
description=description,
dir_path=dir_path,
tool_dependencies=tool_dependencies or [],
mcp_dependencies=mcp_dependencies or [],
skill_dependencies=skill_dependencies or [],
created_by=created_by,
updated_by=created_by,
)
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
await svc.init_builtin_skills(None)
assert len(FakeRepo.created) == 1
created = FakeRepo.created[0]
assert created["slug"] == "reporter"
assert created["name"] == "reporter"
assert created["description"] == "SQL report from python"
assert created["tool_dependencies"] == ["mysql_query"]
assert created["mcp_dependencies"] == ["charts"]
assert created["skill_dependencies"] == ["common-report"]
assert created["created_by"] == svc.BUILTIN_SKILL_OPERATOR
target_dir = tmp_path / "skills" / "reporter"
assert target_dir.exists()
assert target_dir.is_dir()
assert (target_dir / "SKILL.md").exists()
@pytest.mark.asyncio
async def test_init_builtin_skills_updates_existing_record(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))
source_dir = tmp_path / "builtin-skills" / "reporter"
source_dir.mkdir(parents=True, exist_ok=True)
(source_dir / "SKILL.md").write_text(
"---\n"
"name: reporter\n"
"description: old\n"
"---\n"
"# SQL Reporter\n",
encoding="utf-8",
)
monkeypatch.setattr(
svc,
"get_builtin_skill_specs",
lambda: [
SimpleNamespace(
slug="reporter",
source_dir=source_dir,
description="new description",
tool_dependencies=("mysql_query",),
mcp_dependencies=("charts",),
skill_dependencies=(),
)
],
)
existing_item = Skill(
slug="reporter",
name="reporter",
description="old description",
dir_path="skills/reporter",
tool_dependencies=[],
mcp_dependencies=[],
skill_dependencies=[],
created_by="system",
updated_by="system",
)
captured: dict[str, list[str] | str | None] = {}
class FakeRepo:
def __init__(self, _db):
pass
async def get_by_slug(self, slug: str):
return existing_item
async def update_metadata(
self,
item: Skill,
*,
name: str,
description: str,
updated_by: str | None,
) -> Skill:
item.name = name
item.description = description
captured["name"] = name
captured["description"] = description
captured["updated_by"] = updated_by
return item
async def update_dependencies(
self,
item: Skill,
*,
tool_dependencies: list[str],
mcp_dependencies: list[str],
skill_dependencies: list[str],
updated_by: str | None,
) -> Skill:
item.tool_dependencies = tool_dependencies
item.mcp_dependencies = mcp_dependencies
item.skill_dependencies = skill_dependencies
captured["tool_dependencies"] = tool_dependencies
captured["mcp_dependencies"] = mcp_dependencies
captured["skill_dependencies"] = skill_dependencies
captured["updated_by_deps"] = updated_by
return item
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
await svc.init_builtin_skills(None, created_by="release-bot")
target_dir = tmp_path / "skills" / "reporter"
assert target_dir.exists()
assert target_dir.is_dir()
assert captured["description"] == "new description"
assert captured["tool_dependencies"] == ["mysql_query"]
assert captured["mcp_dependencies"] == ["charts"]
assert captured["skill_dependencies"] == []
assert captured["updated_by"] == "release-bot"
assert captured["updated_by_deps"] == "release-bot"