from __future__ import annotations import asyncio 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, User 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 _user(uid: str = "root", role: str = "admin") -> User: return User(username=uid, uid=uid, password_hash="x", role=role, department_id=1) 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 def test_sync_thread_readable_skills_none_keeps_no_skills(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path)) skills_root = tmp_path / "skills" (skills_root / "alpha").mkdir(parents=True, exist_ok=True) (skills_root / "alpha" / "SKILL.md").write_text("alpha", encoding="utf-8") thread_root = svc.sync_thread_readable_skills("thread_1", None) assert list(thread_root.iterdir()) == [] def test_sync_thread_readable_skills_only_keeps_selected(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path)) skills_root = tmp_path / "skills" (skills_root / "alpha").mkdir(parents=True, exist_ok=True) (skills_root / "alpha" / "SKILL.md").write_text("alpha", encoding="utf-8") (skills_root / "beta").mkdir(parents=True, exist_ok=True) (skills_root / "beta" / "SKILL.md").write_text("beta", encoding="utf-8") thread_root = svc.sync_thread_readable_skills("thread_1", ["alpha", "missing", "alpha"]) assert thread_root == tmp_path / "threads" / "thread_1" / "skills" assert sorted(path.name for path in thread_root.iterdir()) == ["alpha"] assert (thread_root / "alpha").is_dir() assert not (thread_root / "alpha").is_symlink() assert (thread_root / "alpha" / "SKILL.md").read_text(encoding="utf-8") == "alpha" svc.sync_thread_readable_skills("thread_1", ["beta"]) assert sorted(path.name for path in thread_root.iterdir()) == ["beta"] assert (thread_root / "beta" / "SKILL.md").read_text(encoding="utf-8") == "beta" @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 [ {"slug": "calculator", "name": "Calculator"}, {"slug": "search", "name": "Search"}, ] monkeypatch.setattr(tool_service, "get_tool_metadata", fake_get_tool_metadata) async def fake_get_enabled_mcp_server_slugs(db=None): del db return ["mcp-a", "mcp-b"] monkeypatch.setattr(svc, "get_enabled_mcp_server_slugs", fake_get_enabled_mcp_server_slugs) user = SimpleNamespace(uid="user") async def fake_list_skill_slugs(_db, *, user): assert user.uid == "user" return ["alpha", "beta"] monkeypatch.setattr(svc, "list_skill_slugs", fake_list_skill_slugs) result = await svc.get_skill_dependency_options(None, user) assert result["tools"] == [{"slug": "calculator", "name": "Calculator"}, {"slug": "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_skill_upload_prepare_confirm_rewrites_conflicting_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, **kwargs) -> Skill: item = Skill(**kwargs, updated_by=kwargs["created_by"]) self.__class__.existing_slugs.add(item.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", } ) operator = _user("root") draft = await svc.prepare_skill_upload( None, filename="demo.zip", file_bytes=zip_bytes, operator=operator, ) results = await svc.confirm_skill_install_draft( None, draft_id=draft["draft_id"], share_config=draft["default_share_config"], operator=operator, ) assert results[0]["slug"] == "demo-v2" assert results[0]["success"] is True assert FakeRepo.created_item.slug == "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_skill_md_prepare_confirm_creates_single_file_skill(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path)) class FakeRepo: created_item: Skill | None = None def __init__(self, _db): pass async def exists_slug(self, slug: str) -> bool: return False async def create(self, **kwargs) -> Skill: item = Skill(**kwargs, updated_by=kwargs["created_by"]) self.__class__.created_item = item return item monkeypatch.setattr(svc, "SkillRepository", FakeRepo) skill_md = "---\nname: demo\ndescription: this is demo\n---\n# Demo\n" operator = _user("root") draft = await svc.prepare_skill_upload( None, filename="SKILL.md", file_bytes=skill_md.encode("utf-8"), operator=operator, ) results = await svc.confirm_skill_install_draft( None, draft_id=draft["draft_id"], share_config=draft["default_share_config"], operator=operator, ) assert results[0]["slug"] == "demo" assert results[0]["success"] is True assert FakeRepo.created_item.name == "demo" assert (tmp_path / "skills" / "demo" / "SKILL.md").read_text(encoding="utf-8") == skill_md @pytest.mark.asyncio async def test_import_skill_dir_requires_root_skill_md(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path)) source_dir = tmp_path / "source-skill" source_dir.mkdir(parents=True, exist_ok=True) with pytest.raises(ValueError, match="根级 SKILL.md"): await svc.import_skill_dir( None, source_dir=source_dir, created_by="root", ) @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", source_type="upload", dir_path="skills/alpha", share_config={"access_level": "user", "department_ids": [], "user_uids": ["root"]}, enabled=True, tool_dependencies=[], mcp_dependencies=[], skill_dependencies=[], ) dependency = Skill( slug="beta", name="beta", description="beta", source_type="upload", dir_path="skills/beta", share_config={"access_level": "user", "department_ids": [], "user_uids": ["root"]}, enabled=True, tool_dependencies=[], mcp_dependencies=[], skill_dependencies=[], ) # Mock get_tool_metadata to return tool list def fake_get_tool_metadata(category=None): return [{"slug": "calculator", "name": "Calculator"}] monkeypatch.setattr(tool_service, "get_tool_metadata", fake_get_tool_metadata) async def fake_get_enabled_mcp_server_slugs(db=None): del db return ["mcp-a"] monkeypatch.setattr(svc, "get_enabled_mcp_server_slugs", fake_get_enabled_mcp_server_slugs) 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, dependency] 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 async def fake_list_accessible_skills(_db, _operator): return [item, dependency] monkeypatch.setattr(svc, "get_skill_or_raise", fake_get_skill_or_raise) monkeypatch.setattr(svc, "list_accessible_skills", fake_list_accessible_skills) 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"], operator=_user("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( "---\nname: reporter\ndescription: 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_payload: dict | None = None def __init__(self, _db): pass async def get_by_slug(self, slug: str): assert slug == "reporter" return None async def create(self, **kwargs) -> Skill: self.__class__.created_payload = kwargs return Skill(**kwargs, updated_by=kwargs["created_by"]) monkeypatch.setattr(svc, "SkillRepository", FakeRepo) items = await svc.init_builtin_skills(None) assert len(items) == 1 assert items[0].slug == "reporter" assert FakeRepo.created_payload["source_type"] == "builtin" assert FakeRepo.created_payload["share_config"] == svc.BUILTIN_SKILL_SHARE_CONFIG assert FakeRepo.created_payload["enabled"] is True assert FakeRepo.created_payload["created_by"] == "system" assert FakeRepo.created_payload["tool_dependencies"] == ["mysql_query"] assert FakeRepo.created_payload["mcp_dependencies"] == ["charts"] assert FakeRepo.created_payload["skill_dependencies"] == ["common-report"] assert (tmp_path / "skills" / "reporter" / "SKILL.md").exists() assert (tmp_path / "skills" / "reporter" / "prompts" / "system.md").read_text(encoding="utf-8") == "prompt" @pytest.mark.asyncio async def test_init_builtin_skills_updates_existing_record_and_preserves_disabled( 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( "---\nname: reporter\ndescription: new markdown description\n---\n# SQL Reporter\n", encoding="utf-8", ) (source_dir / "prompt.md").write_text("new builtin content", encoding="utf-8") target_dir = tmp_path / "skills" / "reporter" target_dir.mkdir(parents=True, exist_ok=True) (target_dir / "prompt.md").write_text("old content", encoding="utf-8") monkeypatch.setattr( svc, "get_builtin_skill_specs", lambda: [ SimpleNamespace( slug="reporter", source_dir=source_dir, description="new description", version="1.0.1", tool_dependencies=("mysql_query",), mcp_dependencies=("charts",), skill_dependencies=(), ) ], ) existing_item = Skill( slug="reporter", name="reporter", description="old description", dir_path="skills/reporter", source_type="builtin", tool_dependencies=[], mcp_dependencies=[], skill_dependencies=[], share_config={"access_level": "global", "department_ids": [], "user_uids": []}, enabled=False, version="1.0.0", content_hash="old-hash", created_by="system", updated_by="system", ) captured: dict[str, object] = {} class FakeRepo: def __init__(self, _db): pass async def get_by_slug(self, slug: str): assert slug == "reporter" 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["metadata"] = {"name": name, "description": description, "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["dependencies"] = { "tool_dependencies": tool_dependencies, "mcp_dependencies": mcp_dependencies, "skill_dependencies": skill_dependencies, "updated_by": updated_by, } return item async def update_builtin_install( self, item: Skill, *, version: str, content_hash: str, updated_by: str | None, ) -> Skill: item.version = version item.content_hash = content_hash item.source_type = "builtin" item.share_config = svc.BUILTIN_SKILL_SHARE_CONFIG.copy() item.updated_by = updated_by captured["install"] = {"version": version, "content_hash": content_hash, "updated_by": updated_by} return item monkeypatch.setattr(svc, "SkillRepository", FakeRepo) items = await svc.init_builtin_skills(None, created_by="release-bot") assert len(items) == 1 assert items[0].enabled is False assert items[0].version == "1.0.1" assert (target_dir / "prompt.md").read_text(encoding="utf-8") == "new builtin content" assert captured["metadata"] == { "name": "reporter", "description": "new description", "updated_by": "release-bot", } assert captured["dependencies"] == { "tool_dependencies": ["mysql_query"], "mcp_dependencies": ["charts"], "skill_dependencies": [], "updated_by": "release-bot", } assert captured["install"]["updated_by"] == "release-bot" @pytest.mark.asyncio async def test_init_builtin_skills_rejects_non_builtin_conflict(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path)) source_dir = tmp_path / "builtin" / "reporter" source_dir.mkdir(parents=True, exist_ok=True) (source_dir / "SKILL.md").write_text( "---\nname: reporter\ndescription: SQL report\n---\n# SQL Reporter\n", encoding="utf-8", ) monkeypatch.setattr( svc, "list_builtin_skill_specs", lambda: [ { "slug": "reporter", "name": "reporter", "description": "SQL report", "version": "1.0.0", "tool_dependencies": [], "mcp_dependencies": [], "skill_dependencies": [], "content_hash": "hash-v1", "source_dir": source_dir, } ], ) class FakeRepo: def __init__(self, _db): pass async def get_by_slug(self, slug: str): return Skill(slug=slug, name=slug, description="uploaded", dir_path=f"skills/{slug}", source_type="upload") monkeypatch.setattr(svc, "SkillRepository", FakeRepo) with pytest.raises(ValueError, match="非内置 skill 冲突"): await svc.init_builtin_skills(None) @pytest.mark.asyncio async def test_update_skill_enabled_allows_builtin(monkeypatch: pytest.MonkeyPatch): builtin_item = Skill( slug="reporter", name="reporter", description="builtin", dir_path="skills/reporter", source_type="builtin", enabled=True, ) async def fake_get_manageable_skill_or_raise(_db, user, slug: str): assert user.uid == "root" assert slug == "reporter" return builtin_item class FakeRepo: def __init__(self, _db): pass async def update_enabled(self, item: Skill, *, enabled: bool, updated_by: str | None): item.enabled = enabled item.updated_by = updated_by return item monkeypatch.setattr(svc, "get_manageable_skill_or_raise", fake_get_manageable_skill_or_raise) monkeypatch.setattr(svc, "SkillRepository", FakeRepo) updated = await svc.update_skill_enabled(None, slug="reporter", enabled=False, operator=_user("root")) assert updated.enabled is False assert updated.updated_by == "root" @pytest.mark.asyncio async def test_builtin_skill_file_edit_blocked(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path)) target_dir = tmp_path / "skills" / "reporter" target_dir.mkdir(parents=True, exist_ok=True) (target_dir / "SKILL.md").write_text( "---\nname: reporter\ndescription: builtin\n---\n# Reporter\n", encoding="utf-8", ) builtin_item = Skill( slug="reporter", name="reporter", description="builtin", dir_path="skills/reporter", source_type="builtin", ) async def fake_get_skill_or_raise(_db, _slug: str): return builtin_item monkeypatch.setattr(svc, "get_skill_or_raise", fake_get_skill_or_raise) with pytest.raises(ValueError, match="内置 skill 不允许直接修改文件"): await svc.update_skill_file( None, slug="reporter", relative_path="SKILL.md", content="new content", updated_by="root", ) @pytest.mark.asyncio async def test_delete_skills_batch_ok(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path)) # 模拟两个已安装的技能 (tmp_path / "skills" / "skill-a").mkdir(parents=True, exist_ok=True) (tmp_path / "skills" / "skill-b").mkdir(parents=True, exist_ok=True) item_a = Skill(slug="skill-a", name="skill-a", description="a", dir_path="skills/skill-a") item_b = Skill(slug="skill-b", name="skill-b", description="b", dir_path="skills/skill-b") db_items = {"skill-a": item_a, "skill-b": item_b} deleted_slugs = [] class FakeRepo: def __init__(self, _db): pass async def get_by_slug(self, slug: str, *, for_update: bool = False): return db_items.get(slug) async def delete(self, item: Skill): deleted_slugs.append(item.slug) db_items.pop(item.slug, None) monkeypatch.setattr(svc, "SkillRepository", FakeRepo) # 执行批量删除,skill-a, skill-b, skill-c (不存在) results = await svc.delete_skills_batch(None, slugs=["skill-a", "skill-b", "skill-c"]) assert results == [ {"slug": "skill-a", "success": True}, {"slug": "skill-b", "success": True}, {"slug": "skill-c", "success": False, "error": "技能 'skill-c' 不存在"}, ] assert deleted_slugs == ["skill-a", "skill-b"] assert not (tmp_path / "skills" / "skill-a").exists() assert not (tmp_path / "skills" / "skill-b").exists() @pytest.mark.asyncio async def test_delete_skills_batch_limit_exceeded(): slugs = [f"skill-{i}" for i in range(51)] with pytest.raises(ValueError, match="批量删除的技能数量不能超过 50 个"): await svc.delete_skills_batch(None, slugs=slugs) @pytest.mark.asyncio async def test_delete_skill_concurrent_lock(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path)) (tmp_path / "skills" / "concurrent-skill").mkdir(parents=True, exist_ok=True) item = Skill( slug="concurrent-skill", name="concurrent-skill", description="desc", dir_path="skills/concurrent-skill" ) db_items = {"concurrent-skill": item} lock_active = asyncio.Lock() class FakeRepo: def __init__(self, _db): pass async def get_by_slug(self, slug: str, *, for_update: bool = False): # 用 asyncio.Lock 模拟 with_for_update() 的排他锁 if for_update: await lock_active.acquire() try: return db_items.get(slug) finally: pass else: return db_items.get(slug) async def delete(self, item: Skill): db_items.pop(item.slug, None) if lock_active.locked(): lock_active.release() monkeypatch.setattr(svc, "SkillRepository", FakeRepo) # 同时发起两个 delete_skill 调用 task1 = asyncio.create_task(svc.delete_skill(None, slug="concurrent-skill")) task2 = asyncio.create_task(svc.delete_skill(None, slug="concurrent-skill")) results = await asyncio.gather(task1, task2, return_exceptions=True) success_count = 0 error_count = 0 for r in results: if r is None: success_count += 1 elif isinstance(r, ValueError) and "不存在" in str(r): error_count += 1 assert success_count == 1 assert error_count == 1 assert not (tmp_path / "skills" / "concurrent-skill").exists()