Files
ComfyUI-Lora-Manager/tests/routes/test_model_move_handler.py
T
Will Miao 9bbe57ee85 feat(sidebar): rename folders from the sidebar (#999)
Follows the folder create/delete work: a typo'd directory could be
removed but not corrected, and for a folder holding models the only fix
was to move every model out by hand.

Adds POST /api/lm/{prefix}/rename-folder. Unlike the delete path this one
deliberately works on folders that hold models — a rename keeps every
file, so nothing is cascaded over: the directory is renamed on disk and
the scanner re-keys the records that pointed at the old prefix (recorded
folder list, cache file_path/folder/preview_url, hash and autov3 index
paths, excluded-model paths, and the metadata sidecars that travelled
with the directory). Ancestors are never touched, and only the leaf name
is accepted so a rename can never escape its parent.

Library roots, top-level symlinks and folders holding a staged delete are
refused; the last because a staging manifest records absolute
original/staged paths, so moving it would break undo and purge. A name
collision is a 409 target_exists conflict.

The sidebar reuses the inline-row idiom from folder creation: prefilled
with the current name, inserted in place of the node with that node
hidden while editing, Enter confirms and Escape/blur cancels. The
persisted selection and the expanded set are re-keyed across the rename
so the user keeps their place in the refreshed tree.
2026-09-15 20:10:36 +08:00

334 lines
9.3 KiB
Python

import json
import logging
import pytest
from py.routes.handlers.model_handlers import ModelMoveHandler
class FakeMoveService:
def __init__(self, result):
self._result = result
self.received_path = None
self.received_dry_run = None
self.received_new_name = None
async def create_folder(self, folder_path):
self.received_path = folder_path
return self._result
async def delete_folder(self, folder_path, dry_run=False):
self.received_path = folder_path
self.received_dry_run = dry_run
return self._result
async def rename_folder(self, folder_path, new_name):
self.received_path = folder_path
self.received_new_name = new_name
return self._result
class FakeRequest:
def __init__(self, payload):
self._payload = payload
async def json(self):
return self._payload
def _make_handler(result):
service = FakeMoveService(result)
handler = ModelMoveHandler(
move_service=service, logger=logging.getLogger(__name__)
)
return handler, service
@pytest.mark.asyncio
async def test_create_folder_success():
handler, service = _make_handler(
{"success": True, "folder": "characters/anime", "created": True}
)
response = await handler.create_folder(
FakeRequest({"folder_path": "/library/characters/anime"})
)
assert response.status == 200
payload = json.loads(response.text)
assert payload["success"] is True
assert payload["folder"] == "characters/anime"
assert service.received_path == "/library/characters/anime"
@pytest.mark.asyncio
async def test_create_folder_missing_path():
handler, service = _make_handler({"success": True})
response = await handler.create_folder(FakeRequest({}))
assert response.status == 400
payload = json.loads(response.text)
assert payload["success"] is False
assert service.received_path is None
@pytest.mark.asyncio
async def test_create_folder_service_failure_maps_to_400():
handler, _service = _make_handler(
{
"success": False,
"error": "Folder path '/etc/evil' is outside configured library directories",
}
)
response = await handler.create_folder(FakeRequest({"folder_path": "/etc/evil"}))
assert response.status == 400
payload = json.loads(response.text)
assert payload["success"] is False
assert "outside configured library" in payload["error"]
@pytest.mark.asyncio
async def test_create_folder_invalid_json_body():
class BadJsonRequest:
async def json(self):
raise ValueError("bad json")
handler, _service = _make_handler({"success": True})
response = await handler.create_folder(BadJsonRequest())
assert response.status == 400
payload = json.loads(response.text)
assert payload["success"] is False
@pytest.mark.asyncio
async def test_delete_folder_success():
handler, service = _make_handler(
{
"success": True,
"folder": "characters/anime",
"model_count": 0,
"restorable": True,
}
)
response = await handler.delete_folder(
FakeRequest({"folder_path": "/library/characters/anime"})
)
assert response.status == 200
payload = json.loads(response.text)
assert payload["success"] is True
assert payload["restorable"] is True
assert service.received_path == "/library/characters/anime"
assert service.received_dry_run is False
@pytest.mark.asyncio
async def test_delete_folder_forwards_dry_run():
handler, service = _make_handler({"success": True, "dry_run": True})
response = await handler.delete_folder(
FakeRequest({"folder_path": "/library/empty", "dry_run": True})
)
assert response.status == 200
assert service.received_dry_run is True
assert json.loads(response.text)["dry_run"] is True
@pytest.mark.asyncio
async def test_delete_folder_missing_path():
handler, service = _make_handler({"success": True})
response = await handler.delete_folder(FakeRequest({}))
assert response.status == 400
payload = json.loads(response.text)
assert payload["success"] is False
assert service.received_path is None
@pytest.mark.asyncio
async def test_delete_folder_not_empty_maps_to_409():
handler, _service = _make_handler(
{
"success": False,
"code": "not_empty",
"error": "Folder still contains 2 model file(s); delete or move them first",
"manifest": {"model_count": 2},
}
)
response = await handler.delete_folder(
FakeRequest({"folder_path": "/library/full"})
)
assert response.status == 409
payload = json.loads(response.text)
assert payload["success"] is False
assert payload["code"] == "not_empty"
assert payload["manifest"]["model_count"] == 2
@pytest.mark.asyncio
async def test_delete_folder_busy_maps_to_409():
handler, _service = _make_handler(
{"success": False, "code": "busy", "error": "staged delete pending"}
)
response = await handler.delete_folder(
FakeRequest({"folder_path": "/library/full"})
)
assert response.status == 409
assert json.loads(response.text)["code"] == "busy"
@pytest.mark.asyncio
async def test_delete_folder_containment_failure_maps_to_400():
handler, _service = _make_handler(
{
"success": False,
"error": "Folder path '/etc/evil' is outside configured library directories",
}
)
response = await handler.delete_folder(FakeRequest({"folder_path": "/etc/evil"}))
assert response.status == 400
payload = json.loads(response.text)
assert payload["success"] is False
assert "outside configured library" in payload["error"]
@pytest.mark.asyncio
async def test_delete_folder_invalid_json_body():
class BadJsonRequest:
async def json(self):
raise ValueError("bad json")
handler, _service = _make_handler({"success": True})
response = await handler.delete_folder(BadJsonRequest())
assert response.status == 400
assert json.loads(response.text)["success"] is False
@pytest.mark.asyncio
async def test_rename_folder_success():
handler, service = _make_handler(
{
"success": True,
"renamed": True,
"folder": "characters/animation",
"previous_folder": "characters/anime",
}
)
response = await handler.rename_folder(
FakeRequest(
{"folder_path": "/library/characters/anime", "new_name": "animation"}
)
)
assert response.status == 200
payload = json.loads(response.text)
assert payload["success"] is True
assert payload["folder"] == "characters/animation"
assert service.received_path == "/library/characters/anime"
assert service.received_new_name == "animation"
@pytest.mark.asyncio
async def test_rename_folder_missing_path():
handler, service = _make_handler({"success": True})
response = await handler.rename_folder(FakeRequest({"new_name": "animation"}))
assert response.status == 400
assert json.loads(response.text)["success"] is False
assert service.received_path is None
@pytest.mark.asyncio
async def test_rename_folder_missing_name():
handler, service = _make_handler({"success": True})
response = await handler.rename_folder(
FakeRequest({"folder_path": "/library/characters/anime"})
)
assert response.status == 400
payload = json.loads(response.text)
assert payload["success"] is False
assert service.received_new_name is None
@pytest.mark.asyncio
async def test_rename_folder_target_exists_maps_to_409():
handler, _service = _make_handler(
{
"success": False,
"code": "target_exists",
"error": 'A folder named "animation" already exists here',
}
)
response = await handler.rename_folder(
FakeRequest(
{"folder_path": "/library/characters/anime", "new_name": "animation"}
)
)
assert response.status == 409
payload = json.loads(response.text)
assert payload["code"] == "target_exists"
@pytest.mark.asyncio
async def test_rename_folder_busy_maps_to_409():
handler, _service = _make_handler(
{"success": False, "code": "busy", "error": "staged delete pending"}
)
response = await handler.rename_folder(
FakeRequest({"folder_path": "/library/full", "new_name": "renamed"})
)
assert response.status == 409
assert json.loads(response.text)["code"] == "busy"
@pytest.mark.asyncio
async def test_rename_folder_invalid_name_maps_to_400():
handler, _service = _make_handler(
{"success": False, "error": "Invalid characters in folder name"}
)
response = await handler.rename_folder(
FakeRequest({"folder_path": "/library/full", "new_name": "a/b"})
)
assert response.status == 400
assert json.loads(response.text)["success"] is False
@pytest.mark.asyncio
async def test_rename_folder_invalid_json_body():
class BadJsonRequest:
async def json(self):
raise ValueError("bad json")
handler, _service = _make_handler({"success": True})
response = await handler.rename_folder(BadJsonRequest())
assert response.status == 400
assert json.loads(response.text)["success"] is False