from __future__ import annotations import asyncio import io import zipfile from pathlib import Path from types import SimpleNamespace import pytest from yuxi.agents.skills import service as svc from yuxi.agents.toolkits import service as 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_allowed_skill_access_levels_by_role(): assert svc.get_allowed_skill_access_levels(_user(role="user")) == ["user"] assert svc.get_allowed_skill_access_levels(_user(role="admin")) == ["global", "department", "user"] assert svc.get_allowed_skill_access_levels(_user(role="superadmin")) == ["global", "department", "user"] @pytest.mark.asyncio async def test_list_visible_skills_for_management_includes_owned_disabled_and_enabled_shared( monkeypatch: pytest.MonkeyPatch, ): items = [ Skill(slug="owned-disabled", name="owned-disabled", description="", created_by="root", enabled=False), Skill( slug="shared-enabled", name="shared-enabled", description="", created_by="other", enabled=True, share_config={"access_level": "user", "department_ids": [], "user_uids": ["root"]}, ), Skill( slug="shared-disabled", name="shared-disabled", description="", created_by="other", enabled=False, share_config={"access_level": "user", "department_ids": [], "user_uids": ["root"]}, ), Skill(slug="unrelated", name="unrelated", description="", created_by="other", enabled=True), ] class FakeRepo: def __init__(self, _db): pass async def list_all(self): return items monkeypatch.setattr(svc, "SkillRepository", FakeRepo) visible = await svc.list_visible_skills_for_management(None, _user("root", role="user")) assert [item.slug for item in visible] == ["owned-disabled", "shared-enabled"] @pytest.mark.asyncio @pytest.mark.parametrize( "skill,operator", [ ( Skill(slug="owned-disabled", name="owned-disabled", description="", created_by="root", enabled=False), _user("root", role="user"), ), ( Skill(slug="admin-disabled", name="admin-disabled", description="", created_by="other", enabled=False), _user("root", role="admin"), ), ( Skill( slug="shared-enabled", name="shared-enabled", description="", created_by="other", enabled=True, share_config={"access_level": "user", "department_ids": [], "user_uids": ["root"]}, ), _user("root", role="user"), ), ], ) async def test_management_readable_skill_allows_manageable_disabled_and_enabled_shared( monkeypatch: pytest.MonkeyPatch, skill: Skill, operator: User, ): class FakeRepo: def __init__(self, _db): pass async def get_by_slug(self, slug: str): assert slug == skill.slug return skill monkeypatch.setattr(svc, "SkillRepository", FakeRepo) result = await svc.get_management_readable_skill_or_raise(None, operator, skill.slug) assert result is skill @pytest.mark.asyncio async def test_management_readable_skill_rejects_disabled_shared_readonly(monkeypatch: pytest.MonkeyPatch): skill = Skill( slug="shared-disabled", name="shared-disabled", description="", created_by="other", enabled=False, share_config={"access_level": "user", "department_ids": [], "user_uids": ["root"]}, ) class FakeRepo: def __init__(self, _db): pass async def get_by_slug(self, slug: str): assert slug == skill.slug return skill monkeypatch.setattr(svc, "SkillRepository", FakeRepo) with pytest.raises(ValueError, match="不存在或无权访问"): await svc.get_management_readable_skill_or_raise(None, _user("root", role="user"), skill.slug) @pytest.mark.asyncio async def test_runtime_access_still_excludes_disabled_shared_skill(monkeypatch: pytest.MonkeyPatch): skill = Skill( slug="shared-disabled", name="shared-disabled", description="", created_by="other", enabled=False, share_config={"access_level": "user", "department_ids": [], "user_uids": ["root"]}, ) class FakeRepo: def __init__(self, _db): pass async def list_enabled(self): return [] monkeypatch.setattr(svc, "SkillRepository", FakeRepo) assert svc.user_can_access_skill(_user("root", role="user"), skill) is False assert await svc.list_accessible_skills(None, _user("root", role="user")) == [] @pytest.mark.asyncio async def test_normal_user_skill_upload_draft_defaults_to_user_share(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path)) class FakeRepo: def __init__(self, _db): pass async def exists_slug(self, _slug: str) -> bool: return False monkeypatch.setattr(svc, "SkillRepository", FakeRepo) draft = await svc.prepare_skill_upload( None, filename="SKILL.md", file_bytes=b"---\nname: demo\ndescription: demo skill\n---\n# Demo\n", operator=_user("normal-user", role="user"), ) assert draft["default_share_config"] == { "access_level": "user", "department_ids": [], "user_uids": ["normal-user"], } assert draft["allowed_access_levels"] == ["user"] @pytest.mark.parametrize( "share_config", [ {"access_level": "global", "department_ids": [], "user_uids": []}, {"access_level": "department", "department_ids": [1], "user_uids": []}, ], ) @pytest.mark.asyncio async def test_normal_user_confirm_skill_draft_rejects_wider_share_scope( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, share_config: dict, ): monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path)) class FakeRepo: def __init__(self, _db): pass async def exists_slug(self, _slug: str) -> bool: return False async def create(self, **_kwargs) -> Skill: raise AssertionError("普通用户的越权共享范围应在创建前被拒绝") monkeypatch.setattr(svc, "SkillRepository", FakeRepo) operator = _user("normal-user", role="user") draft = await svc.prepare_skill_upload( None, filename="SKILL.md", file_bytes=b"---\nname: demo\ndescription: demo skill\n---\n# Demo\n", operator=operator, ) with pytest.raises(ValueError, match="无权使用该 Skill 共享范围"): await svc.confirm_skill_install_draft( None, draft_id=draft["draft_id"], share_config=share_config, operator=operator, ) def test_parse_skill_markdown_ok(): content = "---\nname: demo-skill\ndescription: demo description\n---\n# Demo\n" slug, name, desc, meta = svc._parse_skill_markdown(content) assert slug == "demo-skill" assert name == "demo-skill" assert desc == "demo description" assert meta["name"] == "demo-skill" def test_parse_skill_markdown_supports_display_name_with_slug(): content = ( "---\n" "name: Word / DOCX\n" "slug: word-docx\n" "version: 1.0.2\n" "homepage: https://clawic.com/skills/word-docx\n" "description: Create, inspect, and edit Microsoft Word documents.\n" 'metadata: {"clawdbot":{"emoji":"📘","os":["linux","darwin","win32"]}}\n' "---\n" "# Word / DOCX\n" ) slug, name, desc, meta = svc._parse_skill_markdown(content) assert slug == "word-docx" assert name == "Word / DOCX" assert desc == "Create, inspect, and edit Microsoft Word documents." assert meta["version"] == "1.0.2" def test_parse_skill_markdown_requires_frontmatter(): with pytest.raises(ValueError, match="frontmatter"): svc._parse_skill_markdown("# missing") def test_image_gen_builtin_skill_spec(): specs = {spec["slug"]: spec for spec in svc.list_builtin_skill_specs()} assert "image-gen" in specs image_gen = specs["image-gen"] assert image_gen["name"] == "image-gen" assert image_gen["tool_dependencies"] == ["present_artifacts"] assert (image_gen["source_dir"] / "SKILL.md").exists() def test_knowledge_base_builtin_skill_spec(): specs = {spec["slug"]: spec for spec in svc.list_builtin_skill_specs()} assert "knowledge-base" in specs knowledge_base = specs["knowledge-base"] assert knowledge_base["name"] == "knowledge-base" assert knowledge_base["tool_dependencies"] == [ "list_kbs", "query_kb", "find_kb_document", "open_kb_document", "get_mindmap", "search_file", ] assert (knowledge_base["source_dir"] / "SKILL.md").exists() def test_mysql_reporter_builtin_skill_spec_replaces_reporter_and_deep_reporter(): specs = {spec["slug"]: spec for spec in svc.list_builtin_skill_specs()} assert "reporter" not in specs assert "deep-reporter" not in specs assert "mysql-reporter" in specs mysql_reporter = specs["mysql-reporter"] assert mysql_reporter["name"] == "mysql reporter" assert mysql_reporter["tool_dependencies"] == [] assert mysql_reporter["mcp_dependencies"] == ["mcp-server-chart"] assert (mysql_reporter["source_dir"] / "SKILL.md").exists() for script_name in ("list_tables.py", "describe_table.py", "query.py"): script_path = mysql_reporter["source_dir"] / "scripts" / script_name assert script_path.exists() assert script_path.read_text(encoding="utf-8").startswith("# /// script") 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_zip_import_uses_skill_md_name_not_zip_or_root_dir(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) zip_bytes = _build_zip( { "Bad--Archive-Name/SKILL.md": ("---\nname: valid-skill\ndescription: this is valid\n---\n# Valid\n"), "Bad--Archive-Name/prompts/system.md": "Use valid skill metadata.", } ) operator = _user("root") draft = await svc.prepare_skill_upload( None, filename="Bad--Archive-Name.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 draft["items"][0]["original_name"] == "valid-skill" assert draft["items"][0]["slug"] == "valid-skill" assert results[0]["success"] is True assert results[0]["slug"] == "valid-skill" assert FakeRepo.created_item.slug == "valid-skill" assert (tmp_path / "skills" / "valid-skill" / "SKILL.md").exists() @pytest.mark.asyncio async def test_skill_zip_import_validates_skill_md_name_not_zip_filename( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path)) class FakeRepo: def __init__(self, _db): pass async def exists_slug(self, _slug: str) -> bool: return False monkeypatch.setattr(svc, "SkillRepository", FakeRepo) zip_bytes = _build_zip( { "valid-archive/SKILL.md": ("---\nname: invalid--skill\ndescription: invalid name\n---\n# Invalid\n"), } ) with pytest.raises(ValueError, match="SKILL.md frontmatter.name 必须是小写字母/数字/短横线"): await svc.prepare_skill_upload( None, filename="valid-archive.zip", file_bytes=zip_bytes, operator=_user("root"), ) @pytest.mark.asyncio async def test_skill_zip_import_uses_frontmatter_slug_and_keeps_display_name( 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) zip_bytes = _build_zip( { "Word Skill/SKILL.md": ( "---\n" "name: Word / DOCX\n" "slug: word-docx\n" "version: 1.0.2\n" "homepage: https://clawic.com/skills/word-docx\n" "description: Create, inspect, and edit Microsoft Word documents.\n" "changelog: Tightened review workflows.\n" 'metadata: {"clawdbot":{"emoji":"📘","os":["linux","darwin","win32"]}}\n' "---\n" "# Word / DOCX\n" ) } ) operator = _user("root") draft = await svc.prepare_skill_upload( None, filename="Word Skill.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 draft["items"][0]["slug"] == "word-docx" assert draft["items"][0]["name"] == "Word / DOCX" assert results[0]["success"] is True assert results[0]["slug"] == "word-docx" assert FakeRepo.created_item.slug == "word-docx" assert FakeRepo.created_item.name == "Word / DOCX" @pytest.mark.asyncio async def test_skill_zip_import_rewrites_conflicting_slug_not_display_name( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path)) class FakeRepo: existing_slugs = {"word-docx"} 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( { "Word Skill/SKILL.md": ( "---\n" "name: Word / DOCX\n" "slug: word-docx\n" "description: Create, inspect, and edit Microsoft Word documents.\n" "---\n" "# Word / DOCX\n" ) } ) operator = _user("root") draft = await svc.prepare_skill_upload( None, filename="Word Skill.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"] == "word-docx-v2" assert results[0]["success"] is True assert FakeRepo.created_item.name == "Word / DOCX" skill_md = (tmp_path / "skills" / "word-docx-v2" / "SKILL.md").read_text(encoding="utf-8") assert "name: Word / DOCX" in skill_md assert "slug: word-docx-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()