"""Tests for ModelMoveService folder creation.""" from __future__ import annotations from pathlib import Path from typing import List import pytest from py.services.model_file_service import ModelMoveService class FakeScanner: def __init__(self, roots: List[Path]) -> None: self._roots = [str(root) for root in roots] self.known_folders: List[str] = [] self.removed_folders: List[str] = [] def get_model_roots(self) -> List[str]: return list(self._roots) async def add_known_folder(self, folder: str) -> None: self.known_folders.append(folder) async def remove_known_folder(self, folder: str) -> None: self.removed_folders.append(folder) @pytest.mark.asyncio async def test_create_folder_creates_directory_and_registers_it(tmp_path: Path): scanner = FakeScanner([tmp_path]) service = ModelMoveService(scanner, "lora") target = tmp_path / "characters" / "anime" result = await service.create_folder(str(target)) assert result["success"] is True assert result["created"] is True assert result["folder"] == "characters/anime" assert target.is_dir() assert scanner.known_folders == ["characters/anime"] @pytest.mark.asyncio async def test_create_folder_existing_directory_reports_not_created(tmp_path: Path): (tmp_path / "existing").mkdir() scanner = FakeScanner([tmp_path]) service = ModelMoveService(scanner, "lora") result = await service.create_folder(str(tmp_path / "existing")) assert result["success"] is True assert result["created"] is False assert result["folder"] == "existing" @pytest.mark.asyncio async def test_create_folder_rejects_paths_outside_roots(tmp_path: Path): root = tmp_path / "library" root.mkdir() scanner = FakeScanner([root]) service = ModelMoveService(scanner, "lora") outside = tmp_path / "outside" result = await service.create_folder(str(outside)) assert result["success"] is False assert "error" in result assert not outside.exists() assert scanner.known_folders == [] @pytest.mark.asyncio async def test_create_folder_rejects_traversal_outside_roots(tmp_path: Path): root = tmp_path / "library" root.mkdir() scanner = FakeScanner([root]) service = ModelMoveService(scanner, "lora") result = await service.create_folder(str(root / ".." / "escape")) assert result["success"] is False assert not (tmp_path / "escape").exists() assert scanner.known_folders == [] @pytest.mark.asyncio async def test_create_folder_requires_path(tmp_path: Path): service = ModelMoveService(FakeScanner([tmp_path]), "lora") result = await service.create_folder("") assert result["success"] is False def _make_nested(root: Path) -> Path: target = root / "characters" / "anime" target.mkdir(parents=True) return target @pytest.mark.asyncio async def test_delete_folder_removes_empty_directory_and_forgets_it(tmp_path: Path): target = _make_nested(tmp_path) scanner = FakeScanner([tmp_path]) service = ModelMoveService(scanner, "lora") result = await service.delete_folder(str(target)) assert result["success"] is True assert result["folder"] == "characters/anime" assert result["model_count"] == 0 assert result["restorable"] is True assert not target.exists() assert scanner.removed_folders == ["characters/anime"] @pytest.mark.asyncio async def test_delete_folder_reports_non_model_leftovers_as_not_restorable(tmp_path: Path): target = _make_nested(tmp_path) (target / "notes.txt").write_text("keep me?", encoding="utf-8") (target / "nested").mkdir() scanner = FakeScanner([tmp_path]) service = ModelMoveService(scanner, "lora") result = await service.delete_folder(str(target)) assert result["success"] is True assert result["file_count"] == 1 assert result["dir_count"] == 1 assert result["restorable"] is False assert not target.exists() @pytest.mark.asyncio async def test_delete_folder_refuses_when_models_live_below(tmp_path: Path): target = _make_nested(tmp_path) model_file = target / "model.safetensors" model_file.write_text("weights", encoding="utf-8") scanner = FakeScanner([tmp_path]) service = ModelMoveService(scanner, "lora") result = await service.delete_folder(str(target)) assert result["success"] is False assert result["code"] == "not_empty" assert result["manifest"]["model_count"] == 1 assert target.exists() assert model_file.exists() assert scanner.removed_folders == [] @pytest.mark.asyncio async def test_delete_folder_refuses_while_a_staged_delete_is_pending(tmp_path: Path): target = _make_nested(tmp_path) (target / ".lm-pending-delete").mkdir() scanner = FakeScanner([tmp_path]) service = ModelMoveService(scanner, "lora") result = await service.delete_folder(str(target)) assert result["success"] is False assert result["code"] == "busy" assert target.exists() @pytest.mark.asyncio async def test_delete_folder_refuses_the_library_root_itself(tmp_path: Path): scanner = FakeScanner([tmp_path]) service = ModelMoveService(scanner, "lora") result = await service.delete_folder(str(tmp_path)) assert result["success"] is False assert "root" in result["error"].lower() assert tmp_path.exists() @pytest.mark.asyncio async def test_delete_folder_rejects_paths_outside_roots(tmp_path: Path): root = tmp_path / "library" root.mkdir() outside = tmp_path / "outside" outside.mkdir() scanner = FakeScanner([root]) service = ModelMoveService(scanner, "lora") result = await service.delete_folder(str(outside)) assert result["success"] is False assert "error" in result assert outside.exists() assert scanner.removed_folders == [] @pytest.mark.asyncio async def test_delete_folder_reports_missing_directory(tmp_path: Path): scanner = FakeScanner([tmp_path]) service = ModelMoveService(scanner, "lora") result = await service.delete_folder(str(tmp_path / "gone")) assert result["success"] is False assert "no longer exists" in result["error"] @pytest.mark.asyncio async def test_delete_folder_requires_path(tmp_path: Path): service = ModelMoveService(FakeScanner([tmp_path]), "lora") result = await service.delete_folder("") assert result["success"] is False @pytest.mark.asyncio async def test_delete_folder_dry_run_reports_without_removing(tmp_path: Path): target = _make_nested(tmp_path) (target / "leftover.webp").write_text("preview", encoding="utf-8") scanner = FakeScanner([tmp_path]) service = ModelMoveService(scanner, "lora") result = await service.delete_folder(str(target), dry_run=True) assert result["success"] is True assert result["dry_run"] is True assert result["file_count"] == 1 assert target.exists() assert scanner.removed_folders == [] @pytest.mark.asyncio async def test_delete_folder_refuses_symlinked_directory(tmp_path: Path): real = tmp_path / "real" real.mkdir() link = tmp_path / "link" try: link.symlink_to(real, target_is_directory=True) except (OSError, NotImplementedError): # pragma: no cover - platform guard pytest.skip("symlinks are not supported on this platform") scanner = FakeScanner([tmp_path]) service = ModelMoveService(scanner, "lora") result = await service.delete_folder(str(link)) assert result["success"] is False assert "symlink" in result["error"].lower() assert link.is_symlink() assert real.is_dir() @pytest.mark.asyncio async def test_delete_folder_counts_nested_symlinks_without_following_them(tmp_path: Path): target = _make_nested(tmp_path) real = tmp_path / "real" real.mkdir() (real / "model.safetensors").write_text("weights", encoding="utf-8") try: (target / "linked").symlink_to(real, target_is_directory=True) except (OSError, NotImplementedError): # pragma: no cover - platform guard pytest.skip("symlinks are not supported on this platform") scanner = FakeScanner([tmp_path]) service = ModelMoveService(scanner, "lora") result = await service.delete_folder(str(target)) assert result["success"] is True assert result["symlink_count"] == 1 # The linked model is not part of the subtree being deleted assert result["model_count"] == 0 assert (real / "model.safetensors").exists()