mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-12 12:32:11 -03:00
Deleting a folder a file manager had already moved away answered "Folder no longer
exists" and stopped there: `remove_known_folder()` only ran after `shutil.rmtree()`
succeeded, so the records keeping the node in the tree could never be dropped, and
the modal's missing state hid its confirm button. The sidebar was a dead end for
exactly the case the folder list cannot self-heal.
* `ModelMoveService.forget_folder()` drops a folder's records (the recorded entry
plus the cached models under it) and refuses with `code: "exists"` while any root
still holds the directory, because then the caller wants a delete.
* `delete_folder()` forgets the records instead of failing when the directory is
already gone. The dry run stays a pure read and keeps answering `missing`, which
is what makes the dialog offer the removal. `POST`/`GET
/api/lm/{prefix}/forget-folder` is the endpoint for a node that maps to no
directory at all (the GET form is for the browser extension, which is GET-only).
* The sidebar falls back to it from the delete flow, offers "Remove from list" in
the missing state, and reports the folder as removed from the sidebar rather than
as a delete that never happened. An `exists` conflict says the directory is back
and should be deleted instead.
Verified: 3811 backend tests, 1543 JS tests, 96 Vue tests, 20 i18n tests.
465 lines
13 KiB
Python
465 lines
13 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
|
|
self.received_folder = 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 forget_folder(self, folder):
|
|
self.received_folder = folder
|
|
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
|
|
|
|
def resolve_folder(self, folder):
|
|
self.received_folder = folder
|
|
return self._result
|
|
|
|
|
|
class FakeRequest:
|
|
method = "POST"
|
|
|
|
def __init__(self, payload):
|
|
self._payload = payload
|
|
# A real aiohttp request always carries a query string too.
|
|
self.query = {}
|
|
|
|
async def json(self):
|
|
return self._payload
|
|
|
|
|
|
class FakeGetRequest:
|
|
"""GET stand-in carrying only a query string, as the extension sends it."""
|
|
|
|
method = "GET"
|
|
|
|
def __init__(self, query):
|
|
self.query = query
|
|
|
|
|
|
class FakeQueryRequest:
|
|
"""Request stand-in exposing only the query string."""
|
|
|
|
def __init__(self, query):
|
|
self.query = query
|
|
|
|
|
|
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_resolve_folder_returns_the_matching_roots():
|
|
handler, service = _make_handler(
|
|
{
|
|
"success": True,
|
|
"folder": "recipes",
|
|
"candidates": [
|
|
{
|
|
"folder_path": "/library/recipes",
|
|
"root": "/library",
|
|
"is_symlink": False,
|
|
}
|
|
],
|
|
}
|
|
)
|
|
|
|
response = await handler.resolve_folder(FakeQueryRequest({"folder": "recipes"}))
|
|
|
|
assert response.status == 200
|
|
payload = json.loads(response.text)
|
|
assert payload["success"] is True
|
|
assert payload["candidates"][0]["folder_path"] == "/library/recipes"
|
|
assert service.received_folder == "recipes"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_folder_maps_a_refused_path_to_400():
|
|
handler, _service = _make_handler(
|
|
{"success": False, "error": "Folder path is required"}
|
|
)
|
|
|
|
response = await handler.resolve_folder(FakeQueryRequest({}))
|
|
|
|
assert response.status == 400
|
|
assert json.loads(response.text)["success"] is False
|
|
|
|
|
|
@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_reports_a_vanished_directory_as_missing():
|
|
handler, _service = _make_handler(
|
|
{"success": False, "code": "missing", "error": "Folder no longer exists"}
|
|
)
|
|
|
|
response = await handler.delete_folder(
|
|
FakeRequest({"folder_path": "/library/gone"})
|
|
)
|
|
|
|
assert response.status == 400
|
|
assert json.loads(response.text)["code"] == "missing"
|
|
|
|
|
|
@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
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_forget_folder_success():
|
|
handler, service = _make_handler({"success": True, "folder": "Krea2/Women"})
|
|
|
|
response = await handler.forget_folder(FakeRequest({"folder": "Krea2/Women"}))
|
|
|
|
assert response.status == 200
|
|
assert json.loads(response.text)["folder"] == "Krea2/Women"
|
|
assert service.received_folder == "Krea2/Women"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_forget_folder_accepts_get_with_query_params():
|
|
"""Forgetting is a state change, but the extension is GET-only by convention."""
|
|
handler, service = _make_handler({"success": True, "folder": "Krea2/Women"})
|
|
|
|
response = await handler.forget_folder(
|
|
FakeGetRequest({"folder": "Krea2/Women"})
|
|
)
|
|
|
|
assert response.status == 200
|
|
assert service.received_folder == "Krea2/Women"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_forget_folder_requires_a_folder():
|
|
handler, service = _make_handler({"success": True})
|
|
|
|
response = await handler.forget_folder(FakeRequest({}))
|
|
|
|
assert response.status == 400
|
|
assert json.loads(response.text)["success"] is False
|
|
assert service.received_folder is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_forget_folder_existing_directory_maps_to_409():
|
|
"""The tree the client rendered is stale: the folder must be deleted."""
|
|
handler, _service = _make_handler(
|
|
{
|
|
"success": False,
|
|
"code": "exists",
|
|
"error": "The folder still exists on disk",
|
|
}
|
|
)
|
|
|
|
response = await handler.forget_folder(FakeRequest({"folder": "Krea2/Women"}))
|
|
|
|
assert response.status == 409
|