ForcePilot/test/test_skill_service.py
肖泽涛 46b676db07 feat(skills): add skills management module
实现 Skills 管理功能,支持超级管理员导入、编辑、导出和删除技能包。

- 新增 Skill 数据模型(元数据存数据库,内容存文件系统)
- 新增 /api/system/skills/* 路由(权限分级:admin 可列表,superadmin 可管理)
- 新增 SelectedSkillsReadonlyBackend,运行时只读挂载到 /skills
- Agent 配置新增 skills 字段,运行时注入技能提示词
- 前端新增 Skills 管理页面(设置弹窗 + 独立路由)
- 新增测试覆盖:service/router/backend 单测

未实现功能:
 - 关联使用其他技能
 - 沙箱执行代码
 - 在技能中绑定工具
2026-02-21 02:48:20 +08:00

196 lines
5.6 KiB
Python

from __future__ import annotations
import io
import zipfile
from pathlib import Path
import pytest
from src.services import skill_service as svc
from src.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 = (
"---\n"
"name: demo-skill\n"
"description: 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_get_skill_prompt_metadata_by_slugs_dedup_and_skip_missing(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
svc,
"_skill_prompt_metadata_cache",
{
"alpha": {"name": "alpha", "description": "a", "path": "/skills/alpha/SKILL.md"},
"beta": {"name": "beta", "description": "b", "path": "/skills/beta/SKILL.md"},
},
)
result = svc.get_skill_prompt_metadata_by_slugs(["beta", "missing", "alpha", "beta"])
assert [item["name"] for item in result] == ["beta", "alpha"]
assert [item["path"] for item in result] == ["/skills/beta/SKILL.md", "/skills/alpha/SKILL.md"]
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,
dir_path: str,
created_by: str | None,
) -> Skill:
item = Skill(
slug=slug,
name=name,
description=description,
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
async def list_all(self) -> list[Skill]:
return [self.__class__.created_item] if self.__class__.created_item else []
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
zip_bytes = _build_zip(
{
"demo/SKILL.md": (
"---\n"
"name: demo\n"
"description: 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
async def list_all(self) -> list[Skill]:
return [item]
monkeypatch.setattr(svc, "get_skill_or_raise", fake_get_skill_or_raise)
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
new_content = (
"---\n"
"name: demo\n"
"description: 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