mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-14 01:33:21 -03:00
1099 lines
39 KiB
Python
1099 lines
39 KiB
Python
"""Tests for the pending-delete undo endpoint.
|
|
|
|
Covers ``POST /api/lm/undo-delete`` (``py/routes/handlers/
|
|
pending_delete_handler.py``) and its route registration
|
|
(``py/routes/pending_delete_routes.py``): model/recipe cache restoration,
|
|
per-type scanner resolution, restart-safe and rescan-stale undo, tag-count
|
|
restoration, broadcast, error responses, malformed input, and exactly-once
|
|
registration in both plugin and standalone modes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import time
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import Any, Dict, List, Optional, Sequence
|
|
|
|
import pytest
|
|
from aiohttp import web
|
|
|
|
from py.routes.pending_delete_routes import PendingDeleteRoutes
|
|
from py.services.pending_delete_service import (
|
|
PENDING_DELETE_DIR_NAME,
|
|
PendingDeleteService,
|
|
_reset_pending_delete_service,
|
|
)
|
|
from py.utils import settings_paths
|
|
|
|
UNDO_DELETE_PATH = "/api/lm/undo-delete"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Doubles
|
|
# ---------------------------------------------------------------------------
|
|
class FakeRequest:
|
|
"""Request double exposing the async ``json()`` the handler awaits."""
|
|
|
|
def __init__(self, *, json_data: Any = None, raise_json_error: bool = False) -> None:
|
|
self._json_data = json_data
|
|
self._raise_json_error = raise_json_error
|
|
|
|
async def json(self) -> Any:
|
|
if self._raise_json_error:
|
|
raise ValueError("invalid json body")
|
|
return self._json_data
|
|
|
|
|
|
class FakeHashIndex:
|
|
"""Hash index double recording ``add_entry`` calls."""
|
|
|
|
def __init__(self) -> None:
|
|
self.entries: List[tuple[Any, ...]] = []
|
|
|
|
def add_entry(self, sha256: str, file_path: str, autov3: Optional[str] = None) -> None:
|
|
self.entries.append((sha256, file_path, autov3))
|
|
|
|
|
|
class FakeCache:
|
|
"""Cache double exposing the raw_data/version-index surface the undo uses."""
|
|
|
|
def __init__(self, items: Optional[Sequence[Dict[str, Any]]] = None) -> None:
|
|
self.raw_data: List[Dict[str, Any]] = list(items or [])
|
|
self.version_index: Dict[Any, Any] = {}
|
|
self.model_id_index: Dict[Any, Any] = {}
|
|
self.rebuild_calls = 0
|
|
self.resort_calls = 0
|
|
|
|
def rebuild_version_index(self) -> None:
|
|
self.rebuild_calls += 1
|
|
self.version_index = {}
|
|
self.model_id_index = {}
|
|
|
|
async def resort(self) -> None:
|
|
self.resort_calls += 1
|
|
|
|
|
|
class FakeScanner:
|
|
"""Scanner double exposing the attributes the undo cache-restore uses."""
|
|
|
|
def __init__(
|
|
self,
|
|
root: Path,
|
|
*,
|
|
model_type: str = "lora",
|
|
cache: Optional[FakeCache] = None,
|
|
) -> None:
|
|
self._root = os.path.abspath(str(root))
|
|
self.model_type = model_type
|
|
self._cache = cache or FakeCache()
|
|
self._hash_index = FakeHashIndex()
|
|
self._tags_count: Dict[str, int] = {}
|
|
self.cache_version = 0
|
|
self.bump_calls = 0
|
|
self.persist_calls = 0
|
|
|
|
def get_model_roots(self) -> List[str]:
|
|
return [self._root]
|
|
|
|
def _find_root_for_file(self, file_path: Optional[str]) -> Optional[str]:
|
|
if not file_path:
|
|
return None
|
|
normalized = os.path.abspath(os.path.normpath(file_path))
|
|
if normalized == self._root or normalized.startswith(self._root + os.sep):
|
|
return self._root
|
|
return None
|
|
|
|
async def get_cached_data(self, force_refresh: bool = False) -> FakeCache:
|
|
return self._cache
|
|
|
|
def bump_cache_version(self) -> None:
|
|
self.cache_version += 1
|
|
self.bump_calls += 1
|
|
|
|
async def _persist_current_cache(self) -> None:
|
|
self.persist_calls += 1
|
|
|
|
|
|
class FakeRecipeScanner:
|
|
"""Recipe scanner double mirroring add_recipe's tolerant path-map read."""
|
|
|
|
def __init__(self) -> None:
|
|
self.added: List[Dict[str, Any]] = []
|
|
self._json_path_map: Dict[str, str] = {}
|
|
|
|
async def add_recipe(self, recipe_data: Dict[str, Any]) -> None:
|
|
# The real scanner only READS _json_path_map here (the row may carry an
|
|
# empty json_path until the forced frontend refresh) - never crashes.
|
|
recipe_id = str(recipe_data.get("id", ""))
|
|
self._json_path_map.get(recipe_id, "")
|
|
self.added.append(dict(recipe_data))
|
|
|
|
def force_refresh(self) -> None:
|
|
"""Simulate ``window.recipeManager.loadRecipes(true)`` rebuilding the map."""
|
|
for recipe in self.added:
|
|
recipe_id = str(recipe.get("id", ""))
|
|
file_path = recipe.get("file_path")
|
|
if recipe_id and file_path:
|
|
self._json_path_map[recipe_id] = str(file_path)
|
|
|
|
|
|
class _DummyRoutes:
|
|
@staticmethod
|
|
def setup_routes(app_: web.Application, **kwargs: Any) -> None:
|
|
return None
|
|
|
|
|
|
class _DummyWSManager:
|
|
async def handle_connection(self, request): # pragma: no cover - interface stub
|
|
return None
|
|
|
|
async def handle_download_connection(self, request): # pragma: no cover - interface stub
|
|
return None
|
|
|
|
async def handle_init_connection(self, request): # pragma: no cover - interface stub
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures / helpers
|
|
# ---------------------------------------------------------------------------
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_service_singleton() -> Iterator[None]:
|
|
"""Reset the pending-delete singleton before and after each test."""
|
|
_reset_pending_delete_service()
|
|
yield
|
|
_reset_pending_delete_service()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _stub_scanner_registry(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Prevent scanner resolution from instantiating real singletons."""
|
|
from py.services.service_registry import ServiceRegistry
|
|
|
|
async def _none(*_args: Any, **_kwargs: Any) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(ServiceRegistry, "get_lora_scanner", _none)
|
|
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _none)
|
|
monkeypatch.setattr(ServiceRegistry, "get_embedding_scanner", _none)
|
|
monkeypatch.setattr(ServiceRegistry, "get_recipe_scanner", _none)
|
|
|
|
|
|
async def _register_scanners(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
*,
|
|
lora: Any = None,
|
|
checkpoint: Any = None,
|
|
embedding: Any = None,
|
|
recipe: Any = None,
|
|
) -> None:
|
|
"""Point the ServiceRegistry getters at per-test scanner doubles."""
|
|
from py.services.service_registry import ServiceRegistry
|
|
|
|
def _make(scanner: Any):
|
|
async def _getter(*_args: Any, **_kwargs: Any) -> Any:
|
|
return scanner
|
|
|
|
return _getter
|
|
|
|
monkeypatch.setattr(ServiceRegistry, "get_lora_scanner", _make(lora))
|
|
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _make(checkpoint))
|
|
monkeypatch.setattr(ServiceRegistry, "get_embedding_scanner", _make(embedding))
|
|
monkeypatch.setattr(ServiceRegistry, "get_recipe_scanner", _make(recipe))
|
|
|
|
|
|
def _make_undo_request(**json_data: Any) -> FakeRequest:
|
|
return FakeRequest(json_data=json_data)
|
|
|
|
|
|
def _json_payload(response: web.Response) -> Dict[str, Any]:
|
|
"""Decode the JSON body of a web.Response, asserting it is not null."""
|
|
text = response.text
|
|
assert text is not None
|
|
return json.loads(text)
|
|
|
|
|
|
def _write_batch_manifest(
|
|
batch_dir: Path,
|
|
*,
|
|
batch_id: str,
|
|
kind: str,
|
|
expires_at: int,
|
|
entries: Sequence[Dict[str, Any]],
|
|
model_type: Optional[str] = None,
|
|
state: str = "staged",
|
|
model_snapshot: Any = None,
|
|
recipe_snapshot: Any = None,
|
|
) -> None:
|
|
"""Write a manifest.json with the shape the service reads."""
|
|
manifest: Dict[str, Any] = {
|
|
"batch_id": batch_id,
|
|
"kind": kind,
|
|
"model_type": model_type if kind == "model" else None,
|
|
"state": state,
|
|
"expires_at": int(expires_at),
|
|
"entries": list(entries),
|
|
"model_snapshot": model_snapshot if kind == "model" else None,
|
|
"recipe_snapshot": recipe_snapshot if kind == "recipe" else None,
|
|
}
|
|
(batch_dir / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
|
|
|
|
|
def _undo_delete_routes(app: web.Application) -> List[Any]:
|
|
"""The POST /api/lm/undo-delete routes currently registered on *app*."""
|
|
return [
|
|
route
|
|
for route in app.router.routes()
|
|
if route.method == "POST"
|
|
and getattr(route.resource, "canonical", "") == UNDO_DELETE_PATH
|
|
]
|
|
|
|
|
|
async def _drain_broadcast() -> None:
|
|
"""Yield to the loop so the fire-and-forget broadcast task can run."""
|
|
for _ in range(10):
|
|
await asyncio.sleep(0)
|
|
|
|
|
|
# (a) undo of a staged model batch (loras) -> files + cache + hash + broadcast
|
|
# ---------------------------------------------------------------------------
|
|
async def test_a_undo_staged_model_batch_restores_files_cache_and_broadcasts(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
root = tmp_path / "loras"
|
|
root.mkdir()
|
|
model = root / "model.safetensors"
|
|
model.write_bytes(b"model-data")
|
|
snapshot = {
|
|
"file_path": str(model),
|
|
"sha256": "a" * 64,
|
|
"tags": ["alpha"],
|
|
"model_name": "model",
|
|
}
|
|
|
|
scanner = FakeScanner(root, model_type="lora")
|
|
scanner._cache.raw_data = [dict(snapshot)]
|
|
scanner._tags_count = {"alpha": 1}
|
|
await _register_scanners(monkeypatch, lora=scanner)
|
|
|
|
service = await PendingDeleteService.get_instance()
|
|
batch_id = await service.stage_model_delete(
|
|
scanner=scanner,
|
|
target_dir=str(root),
|
|
file_name="model",
|
|
main_extension=".safetensors",
|
|
original_file_path=str(model),
|
|
cached_entry=dict(snapshot),
|
|
)
|
|
assert batch_id is not None
|
|
|
|
# Simulate the delete-time cache mutation: entry removed, tags decremented.
|
|
scanner._cache.raw_data = []
|
|
scanner._tags_count = {}
|
|
|
|
sent: List[Dict[str, Any]] = []
|
|
|
|
async def fake_broadcast(data: Dict[str, Any]) -> None:
|
|
sent.append(data)
|
|
|
|
monkeypatch.setattr("py.services.websocket_manager.ws_manager.broadcast", fake_broadcast)
|
|
|
|
from py.routes.handlers.pending_delete_handler import PendingDeleteHandler
|
|
|
|
response = await PendingDeleteHandler().undo_delete(
|
|
_make_undo_request(batch_id=batch_id) # pyright: ignore[reportArgumentType]
|
|
)
|
|
|
|
assert response.status == 200
|
|
payload = _json_payload(response)
|
|
assert payload["success"] is True
|
|
assert payload["kind"] == "model"
|
|
assert payload["restored"] == [str(model)]
|
|
|
|
# Files restored to the original path.
|
|
assert model.read_bytes() == b"model-data"
|
|
# Cache entry restored exactly once.
|
|
assert len(scanner._cache.raw_data) == 1
|
|
assert scanner._cache.raw_data[0]["file_path"] == str(model)
|
|
# Version-index rebuild + resort + persist + cache bump all ran.
|
|
assert scanner._cache.rebuild_calls >= 1
|
|
assert scanner._cache.resort_calls >= 1
|
|
assert scanner.persist_calls >= 1
|
|
assert scanner.bump_calls >= 1
|
|
# Hash index has the path.
|
|
assert any(entry[1] == str(model) for entry in scanner._hash_index.entries)
|
|
# Tag counts re-incremented.
|
|
assert scanner._tags_count == {"alpha": 1}
|
|
# Broadcast fired exactly once with models_changed.
|
|
await _drain_broadcast()
|
|
assert sent == [{"type": "models_changed"}]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (b) undo of a staged CHECKPOINT batch -> resolves the checkpoint scanner
|
|
# ---------------------------------------------------------------------------
|
|
async def test_b_undo_checkpoint_batch_updates_checkpoint_cache_only(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
ckpt_root = tmp_path / "checkpoints"
|
|
ckpt_root.mkdir()
|
|
model = ckpt_root / "model.safetensors"
|
|
model.write_bytes(b"ckpt-data")
|
|
snapshot = {
|
|
"file_path": str(model),
|
|
"sha256": "b" * 64,
|
|
"tags": [],
|
|
"model_name": "ckpt",
|
|
}
|
|
|
|
lora_scanner = FakeScanner(tmp_path / "loras", model_type="lora")
|
|
ckpt_scanner = FakeScanner(ckpt_root, model_type="checkpoint")
|
|
lora_scanner._cache.raw_data = [{"file_path": "/loras/other.safetensors", "sha256": "x"}]
|
|
ckpt_scanner._cache.raw_data = [dict(snapshot)]
|
|
await _register_scanners(monkeypatch, lora=lora_scanner, checkpoint=ckpt_scanner)
|
|
|
|
service = await PendingDeleteService.get_instance()
|
|
batch_id = await service.stage_model_delete(
|
|
scanner=ckpt_scanner,
|
|
target_dir=str(ckpt_root),
|
|
file_name="model",
|
|
main_extension=".safetensors",
|
|
original_file_path=str(model),
|
|
cached_entry=dict(snapshot),
|
|
)
|
|
assert batch_id is not None
|
|
# Simulate the delete-time cache mutation on the checkpoint cache.
|
|
ckpt_scanner._cache.raw_data = []
|
|
|
|
from py.routes.handlers.pending_delete_handler import PendingDeleteHandler
|
|
|
|
response = await PendingDeleteHandler().undo_delete(
|
|
_make_undo_request(batch_id=batch_id) # pyright: ignore[reportArgumentType]
|
|
)
|
|
assert response.status == 200
|
|
|
|
# The CHECKPOINT cache was updated, not the lora cache.
|
|
assert len(ckpt_scanner._cache.raw_data) == 1
|
|
assert ckpt_scanner._cache.raw_data[0]["file_path"] == str(model)
|
|
assert len(lora_scanner._cache.raw_data) == 1 # untouched
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (c) undo of a staged recipe batch -> files restored + add_recipe called
|
|
# ---------------------------------------------------------------------------
|
|
async def test_c_undo_recipe_batch_restores_files_and_calls_add_recipe(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
recipe_json = tmp_path / "my_recipe.recipe.json"
|
|
recipe_data = {"id": "r1", "name": "Recipe One", "file_path": str(recipe_json)}
|
|
recipe_json.write_text(json.dumps(recipe_data), encoding="utf-8")
|
|
|
|
recipe_scanner = FakeRecipeScanner()
|
|
await _register_scanners(monkeypatch, recipe=recipe_scanner)
|
|
|
|
service = await PendingDeleteService.get_instance()
|
|
batch_id = await service.stage_recipe_delete(
|
|
recipe_json_path=str(recipe_json),
|
|
image_path=None,
|
|
recipe_data=dict(recipe_data),
|
|
)
|
|
assert batch_id is not None
|
|
# The todo-4 caller removes the originals after staging.
|
|
recipe_json.unlink()
|
|
|
|
sent: List[Dict[str, Any]] = []
|
|
|
|
async def fake_broadcast(data: Dict[str, Any]) -> None:
|
|
sent.append(data)
|
|
|
|
monkeypatch.setattr("py.services.websocket_manager.ws_manager.broadcast", fake_broadcast)
|
|
|
|
from py.routes.handlers.pending_delete_handler import PendingDeleteHandler
|
|
|
|
response = await PendingDeleteHandler().undo_delete(
|
|
_make_undo_request(batch_id=batch_id) # pyright: ignore[reportArgumentType]
|
|
)
|
|
|
|
assert response.status == 200
|
|
payload = _json_payload(response)
|
|
assert payload["success"] is True
|
|
assert payload["kind"] == "recipe"
|
|
assert payload["restored"] == [str(recipe_json)]
|
|
|
|
# Files restored and the recipe re-added to the scanner.
|
|
assert recipe_json.read_text(encoding="utf-8") == json.dumps(recipe_data)
|
|
assert recipe_scanner.added == [recipe_data]
|
|
# Recipe undo is client-refresh only - no models_changed broadcast.
|
|
await _drain_broadcast()
|
|
assert sent == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (d) unknown / expired / occupied batch -> 404 responses
|
|
# ---------------------------------------------------------------------------
|
|
async def test_d1_undo_unknown_batch_returns_404(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _register_scanners(
|
|
monkeypatch, lora=FakeScanner(tmp_path / "loras", model_type="lora")
|
|
)
|
|
|
|
from py.routes.handlers.pending_delete_handler import PendingDeleteHandler
|
|
|
|
response = await PendingDeleteHandler().undo_delete(
|
|
_make_undo_request(batch_id="f" * 32) # pyright: ignore[reportArgumentType]
|
|
)
|
|
|
|
assert response.status == 404
|
|
payload = _json_payload(response)
|
|
assert payload["success"] is False
|
|
assert "batch" in payload["error"].lower()
|
|
|
|
|
|
async def test_d2_undo_expired_batch_returns_404(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
root = tmp_path / "loras"
|
|
root.mkdir()
|
|
model = root / "model.safetensors"
|
|
model.write_bytes(b"data")
|
|
|
|
scanner = FakeScanner(root, model_type="lora")
|
|
await _register_scanners(monkeypatch, lora=scanner)
|
|
|
|
service = await PendingDeleteService.get_instance()
|
|
|
|
# Isolate the expiry check from the opportunistic purge.
|
|
async def _no_purge() -> int:
|
|
return 0
|
|
|
|
monkeypatch.setattr(service, "purge_expired", _no_purge)
|
|
|
|
batch_id = await service.stage_model_delete(
|
|
scanner=scanner,
|
|
target_dir=str(root),
|
|
file_name="model",
|
|
main_extension=".safetensors",
|
|
original_file_path=str(model),
|
|
cached_entry={"file_path": str(model)},
|
|
)
|
|
assert batch_id is not None
|
|
batch_dir = root / PENDING_DELETE_DIR_NAME / batch_id
|
|
manifest_path = batch_dir / "manifest.json"
|
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
manifest["expires_at"] = int(time.time()) - 10
|
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
|
|
|
from py.routes.handlers.pending_delete_handler import PendingDeleteHandler
|
|
|
|
response = await PendingDeleteHandler().undo_delete(
|
|
_make_undo_request(batch_id=batch_id) # pyright: ignore[reportArgumentType]
|
|
)
|
|
assert response.status == 404
|
|
assert "expired" in _json_payload(response)["error"].lower()
|
|
|
|
|
|
async def test_d3_undo_occupied_path_returns_404(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
root = tmp_path / "loras"
|
|
root.mkdir()
|
|
model = root / "model.safetensors"
|
|
model.write_bytes(b"data")
|
|
|
|
scanner = FakeScanner(root, model_type="lora")
|
|
await _register_scanners(monkeypatch, lora=scanner)
|
|
|
|
service = await PendingDeleteService.get_instance()
|
|
batch_id = await service.stage_model_delete(
|
|
scanner=scanner,
|
|
target_dir=str(root),
|
|
file_name="model",
|
|
main_extension=".safetensors",
|
|
original_file_path=str(model),
|
|
cached_entry={"file_path": str(model)},
|
|
)
|
|
assert batch_id is not None
|
|
# A re-download now occupies the original path.
|
|
model.write_bytes(b"new-file")
|
|
|
|
from py.routes.handlers.pending_delete_handler import PendingDeleteHandler
|
|
|
|
response = await PendingDeleteHandler().undo_delete(
|
|
_make_undo_request(batch_id=batch_id) # pyright: ignore[reportArgumentType]
|
|
)
|
|
assert response.status == 404
|
|
assert "occupied" in _json_payload(response)["error"].lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (d-adjacent) malformed input -> 400
|
|
# ---------------------------------------------------------------------------
|
|
async def test_d4_undo_malformed_body_returns_400(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
from py.routes.handlers.pending_delete_handler import PendingDeleteHandler
|
|
|
|
handler = PendingDeleteHandler()
|
|
|
|
# Missing batch_id.
|
|
response = await handler.undo_delete(_make_undo_request()) # pyright: ignore[reportArgumentType]
|
|
assert response.status == 400
|
|
assert "batch_id" in _json_payload(response)["error"].lower()
|
|
|
|
# Non-dict JSON body.
|
|
response = await handler.undo_delete(FakeRequest(json_data="not-a-dict")) # pyright: ignore[reportArgumentType]
|
|
assert response.status == 400
|
|
|
|
# Invalid JSON body.
|
|
response = await handler.undo_delete(FakeRequest(raise_json_error=True)) # pyright: ignore[reportArgumentType]
|
|
assert response.status == 400
|
|
|
|
# (VAL-1) path-traversal style batch_id -> 400.
|
|
response = await handler.undo_delete(_make_undo_request(batch_id="../evil")) # pyright: ignore[reportArgumentType]
|
|
assert response.status == 400
|
|
assert "Invalid batch_id" in _json_payload(response)["error"]
|
|
|
|
# (VAL-2) non-hex batch_id -> 400.
|
|
response = await handler.undo_delete(_make_undo_request(batch_id="not-a-hex-id")) # pyright: ignore[reportArgumentType]
|
|
assert response.status == 400
|
|
assert "Invalid batch_id" in _json_payload(response)["error"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (e) route registered exactly ONCE in both modes
|
|
# ---------------------------------------------------------------------------
|
|
def test_e1_routes_class_registers_undo_route_exactly_once() -> None:
|
|
app = web.Application()
|
|
PendingDeleteRoutes.setup_routes(app)
|
|
|
|
assert len(_undo_delete_routes(app)) == 1
|
|
|
|
|
|
def test_e2_plugin_mode_registers_undo_route_exactly_once(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
from py import lora_manager
|
|
|
|
app = web.Application()
|
|
app._handler_args = {"max_field_size": 1024}
|
|
monkeypatch.setattr(lora_manager.PromptServer, "instance", SimpleNamespace(app=app))
|
|
|
|
monkeypatch.setattr(lora_manager, "register_default_model_types", lambda: None)
|
|
monkeypatch.setattr(
|
|
lora_manager.ModelServiceFactory, "setup_all_routes", lambda app_: None
|
|
)
|
|
monkeypatch.setattr(
|
|
lora_manager.ModelServiceFactory, "get_registered_types", lambda: ["dummy"]
|
|
)
|
|
for name in (
|
|
"StatsRoutes",
|
|
"RecipeRoutes",
|
|
"UpdateRoutes",
|
|
"MiscRoutes",
|
|
"ExampleImagesRoutes",
|
|
"PreviewRoutes",
|
|
):
|
|
monkeypatch.setattr(lora_manager, name, _DummyRoutes)
|
|
monkeypatch.setattr(lora_manager, "ws_manager", _DummyWSManager())
|
|
monkeypatch.setattr(
|
|
lora_manager.settings,
|
|
"get",
|
|
lambda key, default=None: str(tmp_path) if key == "example_images_path" else default,
|
|
)
|
|
monkeypatch.setattr(app.router, "add_static", lambda *a, **k: SimpleNamespace())
|
|
|
|
lora_manager.LoraManager.add_routes()
|
|
|
|
assert len(_undo_delete_routes(app)) == 1
|
|
|
|
|
|
def test_e3_standalone_mode_registers_undo_route_exactly_once(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
import standalone as standalone_module
|
|
|
|
app = web.Application()
|
|
locales_dir = tmp_path / "locales"
|
|
locales_dir.mkdir()
|
|
static_dir = tmp_path / "static"
|
|
static_dir.mkdir()
|
|
monkeypatch.setattr(
|
|
standalone_module,
|
|
"config",
|
|
SimpleNamespace(i18n_path=str(locales_dir), static_path=str(static_dir)),
|
|
)
|
|
|
|
import py.services.model_service_factory as factory_module
|
|
|
|
monkeypatch.setattr(factory_module, "register_default_model_types", lambda: None)
|
|
monkeypatch.setattr(
|
|
factory_module.ModelServiceFactory,
|
|
"setup_all_routes",
|
|
classmethod(lambda cls, app_arg: None),
|
|
)
|
|
monkeypatch.setattr("py.routes.recipe_routes.RecipeRoutes", _DummyRoutes)
|
|
monkeypatch.setattr("py.routes.update_routes.UpdateRoutes", _DummyRoutes)
|
|
monkeypatch.setattr("py.routes.misc_routes.MiscRoutes", _DummyRoutes)
|
|
monkeypatch.setattr("py.routes.stats_routes.StatsRoutes", _DummyRoutes)
|
|
monkeypatch.setattr("py.routes.example_images_routes.ExampleImagesRoutes", _DummyRoutes)
|
|
monkeypatch.setattr("py.routes.preview_routes.PreviewRoutes", _DummyRoutes)
|
|
|
|
async def _noop_ws_handler(request: Any) -> web.Response:
|
|
return web.Response(status=204)
|
|
|
|
ws_stub = SimpleNamespace(
|
|
handle_connection=_noop_ws_handler,
|
|
handle_download_connection=_noop_ws_handler,
|
|
handle_init_connection=_noop_ws_handler,
|
|
)
|
|
monkeypatch.setattr("py.services.websocket_manager.ws_manager", ws_stub)
|
|
|
|
server = SimpleNamespace(app=app)
|
|
standalone_module.StandaloneLoraManager.add_routes(server)
|
|
|
|
assert len(_undo_delete_routes(app)) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (f) RESTART-SAFE UNDO (T8): staged batch on disk, no in-process timer
|
|
# ---------------------------------------------------------------------------
|
|
async def test_f_restart_safe_undo_restores_files_and_cache(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
root = tmp_path / "loras"
|
|
root.mkdir()
|
|
model = root / "model.safetensors"
|
|
# NOTE: the original file does NOT exist - it lives in the staging batch
|
|
# (a delete moved it away before the restart).
|
|
snapshot = {"file_path": str(model), "sha256": "c" * 64, "tags": ["beta"]}
|
|
|
|
# Simulate a post-restart state: the batch + manifest exist on disk but
|
|
# nothing was staged in-process (no timer task, no _known_roots entry).
|
|
restart_batch_id = "b" * 32
|
|
batch_dir = root / PENDING_DELETE_DIR_NAME / restart_batch_id
|
|
batch_dir.mkdir(parents=True)
|
|
(batch_dir / "model.safetensors").write_bytes(b"survived-restart")
|
|
_write_batch_manifest(
|
|
batch_dir,
|
|
batch_id=restart_batch_id,
|
|
kind="model",
|
|
model_type="loras",
|
|
expires_at=int(time.time()) + 3600,
|
|
entries=[
|
|
{
|
|
"staged": str(batch_dir / "model.safetensors"),
|
|
"original": str(model),
|
|
"restored": False,
|
|
}
|
|
],
|
|
model_snapshot=dict(snapshot),
|
|
)
|
|
|
|
scanner = FakeScanner(root, model_type="lora")
|
|
await _register_scanners(monkeypatch, lora=scanner)
|
|
|
|
from py.routes.handlers.pending_delete_handler import PendingDeleteHandler
|
|
|
|
response = await PendingDeleteHandler().undo_delete(
|
|
_make_undo_request(batch_id=restart_batch_id) # pyright: ignore[reportArgumentType]
|
|
)
|
|
|
|
assert response.status == 200
|
|
assert model.read_bytes() == b"survived-restart"
|
|
assert not batch_dir.exists()
|
|
assert len(scanner._cache.raw_data) == 1
|
|
assert scanner._cache.raw_data[0]["file_path"] == str(model)
|
|
assert scanner._tags_count == {"beta": 1}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (g) RESCAN STALENESS (T7): stale raw_data entry replaced by the snapshot
|
|
# ---------------------------------------------------------------------------
|
|
async def test_g_rescan_stale_cache_entry_replaced_by_snapshot(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
root = tmp_path / "loras"
|
|
root.mkdir()
|
|
model = root / "model.safetensors"
|
|
model.write_bytes(b"model-data")
|
|
snapshot = {
|
|
"file_path": str(model),
|
|
"sha256": "d" * 64,
|
|
"tags": ["gamma"],
|
|
"model_name": "model",
|
|
}
|
|
|
|
scanner = FakeScanner(root, model_type="lora")
|
|
await _register_scanners(monkeypatch, lora=scanner)
|
|
|
|
service = await PendingDeleteService.get_instance()
|
|
batch_id = await service.stage_model_delete(
|
|
scanner=scanner,
|
|
target_dir=str(root),
|
|
file_name="model",
|
|
main_extension=".safetensors",
|
|
original_file_path=str(model),
|
|
cached_entry=dict(snapshot),
|
|
)
|
|
assert batch_id is not None
|
|
|
|
# A rescan between delete and undo re-added a STALE entry for the same path.
|
|
stale = {
|
|
"file_path": str(model),
|
|
"sha256": "z" * 64,
|
|
"tags": ["stale-tag"],
|
|
"model_name": "stale",
|
|
}
|
|
scanner._cache.raw_data = [stale]
|
|
|
|
from py.routes.handlers.pending_delete_handler import PendingDeleteHandler
|
|
|
|
response = await PendingDeleteHandler().undo_delete(
|
|
_make_undo_request(batch_id=batch_id) # pyright: ignore[reportArgumentType]
|
|
)
|
|
assert response.status == 200
|
|
|
|
# Exactly one entry remains and it equals the snapshot.
|
|
assert len(scanner._cache.raw_data) == 1
|
|
assert scanner._cache.raw_data[0] == snapshot
|
|
assert scanner._tags_count == {"gamma": 1}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (h) RECIPE RE-DELETE AFTER UNDO (T9): path map rebuilt by forced refresh
|
|
# ---------------------------------------------------------------------------
|
|
async def test_h_recipe_redo_delete_after_undo_and_refresh_succeeds(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
recipe_json = tmp_path / "my_recipe.recipe.json"
|
|
recipe_data = {"id": "r1", "name": "Recipe One", "file_path": str(recipe_json)}
|
|
recipe_json.write_text(json.dumps(recipe_data), encoding="utf-8")
|
|
|
|
recipe_scanner = FakeRecipeScanner()
|
|
await _register_scanners(monkeypatch, recipe=recipe_scanner)
|
|
|
|
service = await PendingDeleteService.get_instance()
|
|
batch_id = await service.stage_recipe_delete(
|
|
recipe_json_path=str(recipe_json),
|
|
image_path=None,
|
|
recipe_data=dict(recipe_data),
|
|
)
|
|
assert batch_id is not None
|
|
recipe_json.unlink()
|
|
|
|
from py.routes.handlers.pending_delete_handler import PendingDeleteHandler
|
|
|
|
handler = PendingDeleteHandler()
|
|
response = await handler.undo_delete(_make_undo_request(batch_id=batch_id)) # pyright: ignore[reportArgumentType]
|
|
assert response.status == 200
|
|
assert recipe_json.exists()
|
|
assert recipe_scanner.added == [recipe_data]
|
|
|
|
# Forced frontend refresh rebuilds the scanner path map.
|
|
recipe_scanner.force_refresh()
|
|
assert recipe_scanner._json_path_map.get("r1") == str(recipe_json)
|
|
|
|
# Immediately deleting the same recipe again succeeds.
|
|
batch_id2 = await service.stage_recipe_delete(
|
|
recipe_json_path=str(recipe_json),
|
|
image_path=None,
|
|
recipe_data=dict(recipe_data),
|
|
)
|
|
assert batch_id2 is not None
|
|
recipe_json.unlink()
|
|
response2 = await handler.undo_delete(_make_undo_request(batch_id=batch_id2)) # pyright: ignore[reportArgumentType]
|
|
assert response2.status == 200
|
|
assert recipe_json.exists()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (i) recipe undo WITHOUT prior refresh still restores files (no crash)
|
|
# ---------------------------------------------------------------------------
|
|
async def test_i_recipe_undo_without_refresh_still_restores_files(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
recipe_json = tmp_path / "no_refresh.recipe.json"
|
|
recipe_data = {"id": "r2", "name": "No Refresh"}
|
|
recipe_json.write_text(json.dumps(recipe_data), encoding="utf-8")
|
|
|
|
recipe_scanner = FakeRecipeScanner()
|
|
await _register_scanners(monkeypatch, recipe=recipe_scanner)
|
|
|
|
service = await PendingDeleteService.get_instance()
|
|
batch_id = await service.stage_recipe_delete(
|
|
recipe_json_path=str(recipe_json),
|
|
image_path=None,
|
|
recipe_data=dict(recipe_data),
|
|
)
|
|
assert batch_id is not None
|
|
recipe_json.unlink()
|
|
|
|
from py.routes.handlers.pending_delete_handler import PendingDeleteHandler
|
|
|
|
response = await PendingDeleteHandler().undo_delete(
|
|
_make_undo_request(batch_id=batch_id) # pyright: ignore[reportArgumentType]
|
|
)
|
|
|
|
assert response.status == 200
|
|
assert recipe_json.read_text(encoding="utf-8") == json.dumps(recipe_data)
|
|
assert recipe_scanner.added == [recipe_data]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (j) TAG COUNTS: bulk-delete 2 tagged models, undo restores pre-delete counts
|
|
# ---------------------------------------------------------------------------
|
|
async def test_j_undo_restores_tag_counts_after_bulk_delete(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
root = tmp_path / "loras"
|
|
root.mkdir()
|
|
model_a = root / "alpha.safetensors"
|
|
model_a.write_bytes(b"alpha")
|
|
model_b = root / "beta.safetensors"
|
|
model_b.write_bytes(b"beta")
|
|
|
|
snapshot_a = {"file_path": str(model_a), "sha256": "e" * 64, "tags": ["alpha"]}
|
|
snapshot_b = {"file_path": str(model_b), "sha256": "f" * 64, "tags": ["beta"]}
|
|
|
|
scanner = FakeScanner(root, model_type="lora")
|
|
await _register_scanners(monkeypatch, lora=scanner)
|
|
|
|
service = await PendingDeleteService.get_instance()
|
|
batch_a = await service.stage_model_delete(
|
|
scanner=scanner,
|
|
target_dir=str(root),
|
|
file_name="alpha",
|
|
main_extension=".safetensors",
|
|
original_file_path=str(model_a),
|
|
cached_entry=dict(snapshot_a),
|
|
)
|
|
batch_b = await service.stage_model_delete(
|
|
scanner=scanner,
|
|
target_dir=str(root),
|
|
file_name="beta",
|
|
main_extension=".safetensors",
|
|
original_file_path=str(model_b),
|
|
cached_entry=dict(snapshot_b),
|
|
)
|
|
assert batch_a is not None
|
|
assert batch_b is not None
|
|
|
|
# Simulate the bulk-delete cache mutation (mirror of
|
|
# _batch_update_cache_for_deleted_models): entries removed, tags decremented.
|
|
scanner._cache.raw_data = [dict(snapshot_a), dict(snapshot_b)]
|
|
scanner._tags_count = {"alpha": 1, "beta": 1}
|
|
for model in (dict(snapshot_a), dict(snapshot_b)):
|
|
for tag in model.get("tags", []):
|
|
if tag in scanner._tags_count:
|
|
scanner._tags_count[tag] = max(0, scanner._tags_count[tag] - 1)
|
|
if scanner._tags_count[tag] == 0:
|
|
del scanner._tags_count[tag]
|
|
scanner._cache.raw_data = []
|
|
pre_delete_counts = {"alpha": 1, "beta": 1}
|
|
|
|
from py.routes.handlers.pending_delete_handler import PendingDeleteHandler
|
|
|
|
handler = PendingDeleteHandler()
|
|
# Sequential undo of the (unmerged) constituent batches - the batch_ids
|
|
# fallback path used by the frontend for cross-volume bulks.
|
|
assert (await handler.undo_delete(_make_undo_request(batch_id=batch_a))).status == 200 # pyright: ignore[reportArgumentType]
|
|
assert (await handler.undo_delete(_make_undo_request(batch_id=batch_b))).status == 200 # pyright: ignore[reportArgumentType]
|
|
|
|
assert scanner._tags_count == pre_delete_counts
|
|
assert {item["file_path"] for item in scanner._cache.raw_data} == {
|
|
str(model_a),
|
|
str(model_b),
|
|
}
|
|
assert model_a.read_bytes() == b"alpha"
|
|
assert model_b.read_bytes() == b"beta"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (k) EMBEDDINGS UNDO: resolves the embedding scanner, not the lora scanner
|
|
# ---------------------------------------------------------------------------
|
|
async def test_k_undo_embeddings_batch_updates_embedding_cache_only(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
emb_root = tmp_path / "embeddings"
|
|
emb_root.mkdir()
|
|
model = emb_root / "my_embedding.safetensors"
|
|
model.write_bytes(b"emb-data")
|
|
snapshot = {
|
|
"file_path": str(model),
|
|
"sha256": "g" * 64,
|
|
"tags": [],
|
|
"model_name": "my_embedding",
|
|
}
|
|
|
|
lora_scanner = FakeScanner(tmp_path / "loras", model_type="lora")
|
|
emb_scanner = FakeScanner(emb_root, model_type="embedding")
|
|
lora_scanner._cache.raw_data = [{"file_path": "/loras/other.safetensors", "sha256": "x"}]
|
|
emb_scanner._cache.raw_data = [dict(snapshot)]
|
|
await _register_scanners(monkeypatch, lora=lora_scanner, embedding=emb_scanner)
|
|
|
|
service = await PendingDeleteService.get_instance()
|
|
batch_id = await service.stage_model_delete(
|
|
scanner=emb_scanner,
|
|
target_dir=str(emb_root),
|
|
file_name="my_embedding",
|
|
main_extension=".safetensors",
|
|
original_file_path=str(model),
|
|
cached_entry=dict(snapshot),
|
|
)
|
|
assert batch_id is not None
|
|
emb_scanner._cache.raw_data = []
|
|
|
|
from py.routes.handlers.pending_delete_handler import PendingDeleteHandler
|
|
|
|
response = await PendingDeleteHandler().undo_delete(
|
|
_make_undo_request(batch_id=batch_id) # pyright: ignore[reportArgumentType]
|
|
)
|
|
assert response.status == 200
|
|
|
|
# The EMBEDDINGS cache was updated, not the lora cache.
|
|
assert len(emb_scanner._cache.raw_data) == 1
|
|
assert emb_scanner._cache.raw_data[0]["file_path"] == str(model)
|
|
assert len(lora_scanner._cache.raw_data) == 1 # untouched
|
|
assert model.read_bytes() == b"emb-data"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (BULK-UNDO) MERGED bulk undo restores ALL cache entries (not just the winner)
|
|
# ---------------------------------------------------------------------------
|
|
async def test_bulk_undo_after_merge_restores_all_cache_entries(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
root = tmp_path / "loras"
|
|
root.mkdir()
|
|
model_a = root / "alpha.safetensors"
|
|
model_a.write_bytes(b"alpha")
|
|
model_b = root / "beta.safetensors"
|
|
model_b.write_bytes(b"beta")
|
|
|
|
snapshot_a = {
|
|
"file_path": str(model_a),
|
|
"sha256": "e" * 64,
|
|
"tags": ["alpha"],
|
|
"model_name": "alpha",
|
|
}
|
|
snapshot_b = {
|
|
"file_path": str(model_b),
|
|
"sha256": "f" * 64,
|
|
"tags": ["beta"],
|
|
"model_name": "beta",
|
|
}
|
|
|
|
scanner = FakeScanner(root, model_type="lora")
|
|
await _register_scanners(monkeypatch, lora=scanner)
|
|
|
|
service = await PendingDeleteService.get_instance()
|
|
batch_a = await service.stage_model_delete(
|
|
scanner=scanner,
|
|
target_dir=str(root),
|
|
file_name="alpha",
|
|
main_extension=".safetensors",
|
|
original_file_path=str(model_a),
|
|
cached_entry=dict(snapshot_a),
|
|
)
|
|
batch_b = await service.stage_model_delete(
|
|
scanner=scanner,
|
|
target_dir=str(root),
|
|
file_name="beta",
|
|
main_extension=".safetensors",
|
|
original_file_path=str(model_b),
|
|
cached_entry=dict(snapshot_b),
|
|
)
|
|
assert batch_a is not None
|
|
assert batch_b is not None
|
|
merged = await service.merge_batches([batch_a, batch_b])
|
|
assert merged == batch_a
|
|
|
|
# Simulate the bulk-delete cache mutation (entries removed, tags cleared).
|
|
scanner._cache.raw_data = []
|
|
scanner._tags_count = {}
|
|
scanner._hash_index = FakeHashIndex()
|
|
|
|
from py.routes.handlers.pending_delete_handler import PendingDeleteHandler
|
|
|
|
response = await PendingDeleteHandler().undo_delete(
|
|
_make_undo_request(batch_id=merged) # pyright: ignore[reportArgumentType]
|
|
)
|
|
assert response.status == 200
|
|
|
|
# BOTH entries restored - the loser's file_path present in raw_data.
|
|
assert {item["file_path"] for item in scanner._cache.raw_data} == {
|
|
str(model_a),
|
|
str(model_b),
|
|
}
|
|
# Hash index carries both paths.
|
|
assert {entry[1] for entry in scanner._hash_index.entries} == {
|
|
str(model_a),
|
|
str(model_b),
|
|
}
|
|
# Tag counts match pre-delete counts for tagged models.
|
|
assert scanner._tags_count == {"alpha": 1, "beta": 1}
|
|
# Files restored byte-identical.
|
|
assert model_a.read_bytes() == b"alpha"
|
|
assert model_b.read_bytes() == b"beta"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (BC-1) backward compat: entries WITHOUT snapshots + top-level model_snapshot
|
|
# ---------------------------------------------------------------------------
|
|
async def test_bc1_old_format_manifest_falls_back_to_top_level_snapshot(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
root = tmp_path / "loras"
|
|
root.mkdir()
|
|
model = root / "model.safetensors"
|
|
snapshot = {
|
|
"file_path": str(model),
|
|
"sha256": "c" * 64,
|
|
"tags": ["beta"],
|
|
"model_name": "model",
|
|
}
|
|
|
|
# Old-format manifest: entries carry NO snapshot; only the top-level
|
|
# model_snapshot exists (pre-F3 single-delete manifests).
|
|
batch_id = "a" * 32
|
|
batch_dir = root / PENDING_DELETE_DIR_NAME / batch_id
|
|
batch_dir.mkdir(parents=True)
|
|
(batch_dir / "model.safetensors").write_bytes(b"old-format-data")
|
|
_write_batch_manifest(
|
|
batch_dir,
|
|
batch_id=batch_id,
|
|
kind="model",
|
|
model_type="loras",
|
|
expires_at=int(time.time()) + 3600,
|
|
entries=[
|
|
{
|
|
"staged": str(batch_dir / "model.safetensors"),
|
|
"original": str(model),
|
|
"restored": False,
|
|
}
|
|
],
|
|
model_snapshot=dict(snapshot),
|
|
)
|
|
|
|
scanner = FakeScanner(root, model_type="lora")
|
|
await _register_scanners(monkeypatch, lora=scanner)
|
|
|
|
from py.routes.handlers.pending_delete_handler import PendingDeleteHandler
|
|
|
|
response = await PendingDeleteHandler().undo_delete(
|
|
_make_undo_request(batch_id=batch_id) # pyright: ignore[reportArgumentType]
|
|
)
|
|
assert response.status == 200
|
|
|
|
# The top-level snapshot entry is still restored exactly.
|
|
assert len(scanner._cache.raw_data) == 1
|
|
assert scanner._cache.raw_data[0] == snapshot
|
|
assert scanner._tags_count == {"beta": 1}
|
|
assert model.read_bytes() == b"old-format-data"
|