Files
ComfyUI-Lora-Manager/tests/routes/test_base_model_routes_smoke.py
T
Will Miao 2dcaf6a30e feat(sidebar): manage folders that live under several model roots
The sidebar's folder tree merges every model root into one relative-path
namespace, but folder operations turned a node into a path by prefixing
default_*_root. A folder living under another root failed to delete with
"Folder no longer exists" (recipes under the primary lora root while
default_lora_root is the extra one), and where the same relative folder
exists in both roots the operation silently hit the other copy — 14 of the
16 top-level folders in the reporting library are shared, so guessing a root
was never safe.

Backend:
* ModelMoveService.resolve_folder() and GET /api/lm/{prefix}/resolve-folder
  answer which directories a library-relative folder maps to
  (folder_path/root/is_symlink), in scanner root order, skipping directories
  no root holds and refusing absolute or climbing paths.
* delete_folder/rename_folder tag a vanished directory with code "missing"
  so the sidebar can tell "this node is stale, refresh" from a failed
  operation.

Frontend:
* _resolveFolderCandidates() is the single place that turns a node into
  absolute paths: default root first, the old root-prefix fallback only
  while a single root is configured, and an explicit unresolved error for a
  multi-root library — nothing is guessed silently any more.
* One copy keeps the single-target modal, which now names the resolved
  absolute path. Several copies render one checkbox row per copy, each
  dry-run against the delete guard ("no models" / "contains N model
  file(s)..." / a deletion is still pending / no longer exists / symbolic
  link): a blocked copy is unticked, disabled and explained, the button
  reads "Delete N folders", every ticked copy is deleted and guarded on its
  own, and a partial failure is reported without discarding the successes.
* Rows are built once per open and only their status text is updated, so
  ticking a box no longer rebuilds the list, steals focus or resizes the
  modal mid-click; the action row keeps a fixed button width.
* Rename offers a root picker in its inline row, create inherits the
  parent's root when the parent resolves to exactly one directory, and the
  undo restores every copy a delete removed.

i18n: 26 new keys (sidebar.deleteFolderModal.*, .deleteFolderResult.*,
.renameFolderResult.*, .folderRoot.*, .folderResult.*) translated in all 9
locales, with the en wording normalized to the established "model root" noun
(it had said "library root") and the new terminology recorded in
docs/i18n-translation-guidelines.md.

Verified: pytest 3686 passed, vitest 1473 passed (86 in the folder-management
suite), pytest tests/i18n 20 passed, sync_translation_keys.py --dry-run
clean. A sandboxed standalone instance with two roots confirmed that deleting
one copy leaves the node in place, that the twin's model card survives the
purge, and that deleting both copies and undoing restores both directories.
2026-10-06 17:14:07 +08:00

955 lines
32 KiB
Python

import asyncio
import json
import os
import sys
from pathlib import Path
import types
from dataclasses import dataclass, field
from typing import Any, Optional
folder_paths_stub = types.SimpleNamespace(get_folder_paths=lambda *_: [])
sys.modules.setdefault("folder_paths", folder_paths_stub) # pyright: ignore[reportArgumentType]
import pytest
from aiohttp import FormData, web
from aiohttp.test_utils import TestClient, TestServer
from py.config import config
from py.routes.base_model_routes import BaseModelRoutes
from py.services import model_file_service
from py.services.downloader import DownloadProgress
from py.services.metadata_sync_service import MetadataSyncService
from py.services.model_file_service import AutoOrganizeResult
from py.services.model_update_service import ModelVersionRecord
from py.services.service_registry import ServiceRegistry
from py.services.use_cases import FilenameTemplateUseCase
from py.services.websocket_manager import ws_manager
from py.utils.exif_utils import ExifUtils
from py.utils.metadata_manager import MetadataManager
class DummyRoutes(BaseModelRoutes):
template_name = "dummy.html"
def setup_specific_routes(
self, registrar, prefix: str
) -> None: # pragma: no cover - no extra routes in smoke tests
return None
def __init__(self, service=None):
super().__init__(service)
self.set_model_update_service(
NullModelUpdateService() # pyright: ignore[reportArgumentType]
)
@dataclass
class NullUpdateRecord:
model_type: str
model_id: int
versions: list[ModelVersionRecord] = field(default_factory=list)
last_checked_at: float | None = None
should_ignore_model: bool = False
@property
def largest_version_id(self) -> int | None:
if not self.versions:
return None
return max(version.version_id for version in self.versions)
@property
def version_ids(self) -> list[int]:
return [version.version_id for version in self.versions]
@property
def in_library_version_ids(self) -> list[int]:
return [
version.version_id for version in self.versions if version.is_in_library
]
def has_update(self) -> bool:
return False
class NullModelUpdateService:
async def refresh_for_model_type(self, *args, **kwargs):
return {}
async def refresh_single_model(self, *args, **kwargs):
return None
async def update_in_library_versions(self, model_type, model_id, version_ids):
versions = [
ModelVersionRecord(
version_id=version_id,
name=None,
base_model=None,
released_at=None,
size_bytes=None,
preview_url=None,
is_in_library=True,
should_ignore=False,
)
for version_id in version_ids
]
return NullUpdateRecord(
model_type=model_type, model_id=model_id, versions=versions
)
async def set_should_ignore(self, model_type, model_id, should_ignore):
return NullUpdateRecord(
model_type=model_type,
model_id=model_id,
should_ignore_model=should_ignore,
)
async def set_version_should_ignore(
self, model_type, model_id, version_id, should_ignore
):
return await self.set_should_ignore(model_type, model_id, should_ignore)
async def get_record(self, *args, **kwargs):
return None
async def create_test_client(service) -> TestClient[Any, Any]:
routes = DummyRoutes(service)
app = web.Application()
routes.setup_routes(app, "test-models")
server = TestServer(app)
client = TestClient(server)
await client.start_server()
return client
@pytest.fixture(autouse=True)
def reset_ws_manager_state():
ws_manager.cleanup_auto_organize_progress()
ws_manager.cleanup_filename_template_progress()
ws_manager._download_progress.clear()
yield
ws_manager.cleanup_auto_organize_progress()
ws_manager.cleanup_filename_template_progress()
ws_manager._download_progress.clear()
@pytest.fixture
def download_manager_stub():
class FakeDownloadManager:
def __init__(self):
self.calls = []
self.error = None
self.cancelled = []
self.active_downloads = {}
self.last_progress_snapshot: Optional[DownloadProgress] = None
async def download_from_civitai(self, **kwargs):
self.calls.append(kwargs)
if self.error is not None:
raise self.error
snapshot = DownloadProgress(
percent_complete=50.0,
bytes_downloaded=5120,
total_bytes=10240,
bytes_per_second=2048.0,
timestamp=0.0,
)
self.last_progress_snapshot = snapshot
await kwargs["progress_callback"](snapshot)
return {"success": True, "path": "/tmp/model.safetensors"}
async def cancel_download(self, download_id):
self.cancelled.append(download_id)
return {"success": True, "download_id": download_id}
async def get_active_downloads(self):
return self.active_downloads
stub = FakeDownloadManager()
previous = ServiceRegistry._services.get("download_manager")
asyncio.run(ServiceRegistry.register_service("download_manager", stub))
try:
yield stub
finally:
if previous is not None:
ServiceRegistry._services["download_manager"] = previous
else:
ServiceRegistry._services.pop("download_manager", None)
def test_list_models_returns_formatted_items(mock_service, mock_scanner):
mock_service.paginated_items = [
{"file_path": "/tmp/demo.safetensors", "name": "Demo"}
]
async def scenario():
client = await create_test_client(mock_service)
try:
response = await client.get("/api/lm/test-models/list")
payload = await response.json()
assert response.status == 200
assert payload["items"] == [
{
"file_path": "/tmp/demo.safetensors",
"name": "Demo",
"formatted": True,
}
]
assert payload["total"] == 1
assert mock_service.formatted == payload["items"]
finally:
await client.close()
asyncio.run(scenario())
def test_list_models_filters_out_corrupted_entries(mock_service, mock_scanner):
"""Corrupted cache entries (format_response returns None) must not appear
in the response items nor cause a 500. See issue #730.
"""
mock_service.paginated_items = [
{"file_path": "/tmp/good.safetensors", "name": "Good"},
{"file_path": None, "name": "Corrupted"}, # triggers None from format_response
{"file_path": "/tmp/also_good.safetensors", "name": "AlsoGood"},
]
# Override format_response to return None for corrupted entries
original_format = mock_service.format_response
async def conditional_format(item):
if item.get("file_path") is None:
return None
return await original_format(item)
mock_service.format_response = conditional_format
async def scenario():
client = await create_test_client(mock_service)
try:
response = await client.get("/api/lm/test-models/list")
payload = await response.json()
assert response.status == 200
# Only the 2 non-corrupted entries should appear
assert len(payload["items"]) == 2
assert payload["items"][0]["name"] == "Good"
assert payload["items"][1]["name"] == "AlsoGood"
# None should never appear in the items list
assert None not in payload["items"]
finally:
await client.close()
asyncio.run(scenario())
def test_model_types_endpoint_returns_counts(mock_service, mock_scanner):
mock_service.model_types = [
{"type": "LoRa", "count": 3},
{"type": "Checkpoint", "count": 1},
]
async def scenario():
client = await create_test_client(mock_service)
try:
response = await client.get("/api/lm/test-models/model-types?limit=1")
payload = await response.json()
assert response.status == 200
assert payload["model_types"] == mock_service.model_types[:1]
finally:
await client.close()
asyncio.run(scenario())
def test_routes_return_service_not_ready_when_unattached():
async def scenario():
client = await create_test_client(None)
try:
response = await client.get("/api/lm/test-models/list")
payload = await response.json()
assert response.status == 503
assert payload == {"success": False, "error": "Service not ready"}
finally:
await client.close()
asyncio.run(scenario())
def test_delete_model_updates_cache_and_hash_index(
mock_service, mock_scanner, tmp_path: Path
):
model_path = tmp_path / "sample.safetensors"
model_path.write_bytes(b"model")
mock_scanner._cache.raw_data = [{"file_path": str(model_path)}]
async def scenario():
client = await create_test_client(mock_service)
try:
response = await client.post(
"/api/lm/test-models/delete",
json={"file_path": str(model_path)},
)
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
assert mock_scanner._cache.raw_data == []
assert mock_scanner._cache.resort_calls == 1
assert mock_scanner._hash_index.removed_paths == [str(model_path)]
finally:
await client.close()
asyncio.run(scenario())
assert not model_path.exists()
def test_replace_preview_writes_file_and_updates_cache(
mock_service,
mock_scanner,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
):
model_path = tmp_path / "preview-model.safetensors"
model_path.write_bytes(b"model")
metadata_path = tmp_path / "preview-model.metadata.json"
metadata_path.write_text(json.dumps({"file_path": str(model_path)}))
mock_scanner._cache.raw_data = [{"file_path": str(model_path)}]
monkeypatch.setattr(
ExifUtils,
"optimize_image",
staticmethod(lambda image_data, **_: (image_data, ".webp")),
)
monkeypatch.setattr(
config,
"get_preview_static_url",
lambda preview_path: f"/static/{Path(preview_path).name}",
)
form = FormData()
form.add_field(
"preview_file", b"binary-data", filename="preview.png", content_type="image/png"
)
form.add_field("model_path", str(model_path))
form.add_field("nsfw_level", "2")
async def scenario():
client = await create_test_client(mock_service)
try:
response = await client.post(
"/api/lm/test-models/replace-preview", data=form
)
payload = await response.json()
expected_preview = str((tmp_path / "preview-model.webp")).replace(
os.sep, "/"
)
assert response.status == 200
assert payload["success"] is True
assert payload["preview_url"] == "/static/preview-model.webp"
assert Path(expected_preview).exists()
assert mock_scanner.preview_updates[-1]["preview_path"] == expected_preview
assert mock_scanner._cache.raw_data[0]["preview_url"] == expected_preview
assert mock_scanner._cache.raw_data[0]["preview_nsfw_level"] == 2
updated_metadata = json.loads(metadata_path.read_text())
assert updated_metadata["preview_url"] == expected_preview
assert updated_metadata["preview_nsfw_level"] == 2
finally:
await client.close()
asyncio.run(scenario())
def test_set_preview_from_url_downloads_and_updates_cache(
mock_service,
mock_scanner,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
):
"""Test that set_preview_from_url endpoint downloads remote images and sets them as preview."""
model_path = tmp_path / "url-preview-model.safetensors"
model_path.write_bytes(b"model")
metadata_path = tmp_path / "url-preview-model.metadata.json"
metadata_path.write_text(json.dumps({"file_path": str(model_path)}))
mock_scanner._cache.raw_data = [{"file_path": str(model_path)}]
monkeypatch.setattr(
config,
"get_preview_static_url",
lambda preview_path: f"/static/{Path(preview_path).name}",
)
async def scenario():
client = await create_test_client(mock_service)
try:
# Mock the Downloader to return a test image
from py.services import downloader
class FakeDownloader:
async def download_to_memory(
self, url, use_auth=False, return_headers=True
):
return True, b"fake-image-data", {"Content-Type": "image/jpeg"}
async def fake_get_downloader():
return FakeDownloader()
monkeypatch.setattr(downloader, "get_downloader", fake_get_downloader)
response = await client.post(
"/api/lm/test-models/set-preview-from-url",
json={
"model_path": str(model_path),
"image_url": "https://example.com/image.jpg",
"nsfw_level": 3,
},
)
payload = await response.json()
expected_preview = str((tmp_path / "url-preview-model.webp")).replace(
os.sep, "/"
)
assert response.status == 200
assert payload["success"] is True
assert payload["preview_url"] == "/static/url-preview-model.webp"
assert Path(expected_preview).exists()
finally:
await client.close()
asyncio.run(scenario())
def test_fetch_civitai_hydrates_metadata_before_sync(
mock_service,
mock_scanner,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
):
model_path = tmp_path / "hydrate.safetensors"
model_path.write_bytes(b"model")
metadata_path = tmp_path / "hydrate.metadata.json"
existing_metadata = {
"file_path": str(model_path),
"sha256": "abc123",
"model_name": "Hydrated",
"preview_url": "keep/me.png",
"civitai": {
"id": 99,
"modelId": 42,
"images": [{"url": "https://example.com/existing.png", "type": "image"}],
"customImages": [{"id": "old-id", "url": "", "type": "image"}],
"trainedWords": ["keep"],
},
"custom_field": "preserve",
}
metadata_path.write_text(json.dumps(existing_metadata), encoding="utf-8")
minimal_cache_entry = {
"file_path": str(model_path),
"sha256": "abc123",
"folder": "some/folder",
"civitai": {"id": 99, "modelId": 42},
}
mock_scanner._cache.raw_data = [minimal_cache_entry]
class FakeMetadata:
def __init__(self, payload: dict[str, Any]) -> None:
self._payload = payload
self._unknown_fields = {"legacy_field": "legacy"}
def to_dict(self) -> dict[str, Any]:
return self._payload.copy()
async def fake_load_metadata(path: str, *_args, **_kwargs):
assert path == str(model_path)
return FakeMetadata(existing_metadata), False
async def fake_save_metadata(path: str, metadata: dict[str, Any]) -> bool:
save_calls.append((path, json.loads(json.dumps(metadata))))
return True
async def fake_fetch_and_update_model(
self,
*,
sha256: str,
file_path: str,
model_data: dict[str, Any],
update_cache_func,
):
captured["model_data"] = json.loads(json.dumps(model_data))
to_save = model_data.copy()
to_save.pop("folder", None)
await MetadataManager.save_metadata(
os.path.splitext(file_path)[0] + ".metadata.json",
to_save,
)
await update_cache_func(file_path, file_path, model_data)
return True, None
save_calls: list[tuple[str, dict[str, Any]]] = []
captured: dict[str, dict[str, Any]] = {}
monkeypatch.setattr(
MetadataManager, "load_metadata", staticmethod(fake_load_metadata)
)
monkeypatch.setattr(
MetadataManager, "save_metadata", staticmethod(fake_save_metadata)
)
monkeypatch.setattr(
MetadataSyncService, "fetch_and_update_model", fake_fetch_and_update_model
)
async def scenario():
client = await create_test_client(mock_service)
try:
response = await client.post(
"/api/lm/test-models/fetch-civitai",
json={"file_path": str(model_path)},
)
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
assert captured["model_data"]["custom_field"] == "preserve"
assert (
captured["model_data"]["civitai"]["images"][0]["url"]
== "https://example.com/existing.png"
)
assert captured["model_data"]["civitai"]["trainedWords"] == ["keep"]
assert captured["model_data"]["civitai"]["id"] == 99
finally:
await client.close()
asyncio.run(scenario())
assert save_calls, "Metadata save should be invoked"
saved_path, saved_payload = save_calls[0]
assert saved_path == str(metadata_path)
assert saved_payload["custom_field"] == "preserve"
assert (
saved_payload["civitai"]["images"][0]["url"]
== "https://example.com/existing.png"
)
assert saved_payload["civitai"]["trainedWords"] == ["keep"]
assert saved_payload["civitai"]["id"] == 99
assert saved_payload["legacy_field"] == "legacy"
assert mock_scanner.updated_models
updated_metadata = mock_scanner.updated_models[-1]["metadata"]
assert updated_metadata["custom_field"] == "preserve"
assert updated_metadata["civitai"]["customImages"][0]["id"] == "old-id"
def test_download_model_invokes_download_manager(
mock_service,
download_manager_stub,
tmp_path: Path,
):
async def scenario():
client = await create_test_client(mock_service)
try:
response = await client.post(
"/api/lm/download-model",
json={"model_id": 1, "model_root": str(tmp_path)},
)
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
assert download_manager_stub.calls
call_args = download_manager_stub.calls[0]
assert call_args["model_id"] == 1
assert call_args["download_id"] == payload["download_id"]
progress = ws_manager.get_download_progress(payload["download_id"])
assert progress is not None
expected_progress = round(
download_manager_stub.last_progress_snapshot.percent_complete
)
assert progress["progress"] == expected_progress
assert (
progress["bytes_downloaded"]
== download_manager_stub.last_progress_snapshot.bytes_downloaded
)
assert (
progress["total_bytes"]
== download_manager_stub.last_progress_snapshot.total_bytes
)
assert (
progress["bytes_per_second"]
== download_manager_stub.last_progress_snapshot.bytes_per_second
)
assert "timestamp" in progress
progress_response = await client.get(
f"/api/lm/download-progress/{payload['download_id']}"
)
progress_payload = await progress_response.json()
assert progress_response.status == 200
assert progress_payload == {
"success": True,
"progress": expected_progress,
"bytes_downloaded": download_manager_stub.last_progress_snapshot.bytes_downloaded,
"total_bytes": download_manager_stub.last_progress_snapshot.total_bytes,
"bytes_per_second": download_manager_stub.last_progress_snapshot.bytes_per_second,
}
ws_manager.cleanup_download_progress(payload["download_id"])
finally:
await client.close()
asyncio.run(scenario())
def test_download_model_requires_identifier(mock_service, download_manager_stub):
async def scenario():
client = await create_test_client(mock_service)
try:
response = await client.post(
"/api/lm/download-model",
json={"model_root": "/tmp"},
)
payload = await response.json()
assert response.status == 400
assert payload["success"] is False
assert "Missing required" in payload["error"]
finally:
await client.close()
asyncio.run(scenario())
def test_download_model_maps_validation_errors(mock_service, download_manager_stub):
download_manager_stub.error = ValueError("Invalid relative path")
async def scenario():
client = await create_test_client(mock_service)
try:
response = await client.post(
"/api/lm/download-model",
json={"model_version_id": 123},
)
payload = await response.json()
assert response.status == 400
assert payload == {"success": False, "error": "Invalid relative path"}
assert ws_manager._download_progress == {}
finally:
await client.close()
asyncio.run(scenario())
def test_download_model_maps_early_access_errors(mock_service, download_manager_stub):
download_manager_stub.error = RuntimeError("401 early access")
async def scenario():
client = await create_test_client(mock_service)
try:
response = await client.post(
"/api/lm/download-model",
json={"model_id": 4},
)
payload = await response.json()
assert response.status == 401
assert payload == {
"success": False,
"error": "Early Access Restriction: This model requires purchase. Please buy early access on Civitai.com.",
}
finally:
await client.close()
asyncio.run(scenario())
def test_auto_organize_progress_returns_latest_snapshot(mock_service):
async def scenario():
client = await create_test_client(mock_service)
try:
await ws_manager.broadcast_auto_organize_progress(
{"status": "processing", "percent": 50}
)
response = await client.get("/api/lm/test-models/auto-organize-progress")
payload = await response.json()
assert response.status == 200
assert payload == {
"success": True,
"progress": {"status": "processing", "percent": 50},
}
finally:
await client.close()
asyncio.run(scenario())
def test_auto_organize_route_emits_progress(
mock_service, monkeypatch: pytest.MonkeyPatch
):
async def fake_auto_organize(
self, file_paths=None, progress_callback=None, exclusion_patterns=None
):
result = AutoOrganizeResult()
result.total = 1
result.processed = 1
result.success_count = 1
result.skipped_count = 0
result.failure_count = 0
result.operation_type = "bulk"
if progress_callback is not None:
await progress_callback.on_progress(
{"type": "auto_organize_progress", "status": "started"}
)
await progress_callback.on_progress(
{"type": "auto_organize_progress", "status": "completed"}
)
return result
monkeypatch.setattr(
model_file_service.ModelFileService,
"auto_organize_models",
fake_auto_organize,
)
async def scenario():
client = await create_test_client(mock_service)
try:
response = await client.post(
"/api/lm/test-models/auto-organize", json={"file_paths": []}
)
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
progress = ws_manager.get_auto_organize_progress()
assert progress is not None
assert progress["status"] == "completed"
finally:
await client.close()
asyncio.run(scenario())
def test_auto_organize_conflict_when_running(mock_service):
async def scenario():
client = await create_test_client(mock_service)
try:
await ws_manager.broadcast_auto_organize_progress(
{"type": "auto_organize_progress", "status": "started"}
)
response = await client.post("/api/lm/test-models/auto-organize")
payload = await response.json()
assert response.status == 409
assert payload == {
"success": False,
"error": "Auto-organize is already running. Please wait for it to complete.",
}
finally:
await client.close()
asyncio.run(scenario())
def test_apply_filename_template_route_emits_progress(
mock_service, monkeypatch: pytest.MonkeyPatch
):
async def fake_execute(self, file_paths=None, progress_callback=None):
result = AutoOrganizeResult()
result.total = 1
result.processed = 1
result.success_count = 1
result.operation_type = "filename_template"
if progress_callback is not None:
await progress_callback.on_progress(
{"type": "filename_template_progress", "status": "started"}
)
await progress_callback.on_progress(
{"type": "filename_template_progress", "status": "completed"}
)
return result
monkeypatch.setattr(FilenameTemplateUseCase, "execute", fake_execute)
async def scenario():
client = await create_test_client(mock_service)
try:
response = await client.post(
"/api/lm/test-models/apply-filename-template",
json={"file_paths": ["/tmp/a.safetensors"]},
)
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
assert payload["summary"]["operation_type"] == "filename_template"
progress = ws_manager.get_filename_template_progress()
assert progress is not None
assert progress["status"] == "completed"
# Auto-organize progress state must stay untouched.
assert ws_manager.get_auto_organize_progress() is None
finally:
await client.close()
asyncio.run(scenario())
def test_apply_filename_template_get_parses_query_file_paths(
mock_service, monkeypatch: pytest.MonkeyPatch
):
captured = {}
async def fake_execute(self, file_paths=None, progress_callback=None):
captured["file_paths"] = file_paths
result = AutoOrganizeResult()
result.operation_type = "filename_template"
return result
monkeypatch.setattr(FilenameTemplateUseCase, "execute", fake_execute)
async def scenario():
client = await create_test_client(mock_service)
try:
response = await client.get(
"/api/lm/test-models/apply-filename-template",
params={"file_paths": "/tmp/a.safetensors, /tmp/b.safetensors"},
)
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
assert captured["file_paths"] == [
"/tmp/a.safetensors",
"/tmp/b.safetensors",
]
finally:
await client.close()
asyncio.run(scenario())
def test_apply_filename_template_conflict_when_running(mock_service):
async def scenario():
client = await create_test_client(mock_service)
try:
await ws_manager.broadcast_filename_template_progress(
{"type": "filename_template_progress", "status": "started"}
)
response = await client.post("/api/lm/test-models/apply-filename-template")
payload = await response.json()
assert response.status == 409
assert payload == {
"success": False,
"error": "Another library operation is already running. Please wait for it to complete.",
}
finally:
await client.close()
asyncio.run(scenario())
def test_download_model_returns_skipped_success(mock_service, download_manager_stub):
async def scenario():
download_manager_stub.last_progress_snapshot = None
async def fake_download(**kwargs):
download_manager_stub.calls.append(kwargs)
return {
"success": True,
"skipped": True,
"status": "skipped",
"reason": "base_model_excluded",
"message": "Skipped by settings",
"base_model": "SDXL 1.0",
"file_name": "demo.safetensors",
}
download_manager_stub.download_from_civitai = fake_download
client = await create_test_client(mock_service)
try:
response = await client.post(
"/api/lm/download-model",
json={"model_version_id": 123},
)
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
assert payload["skipped"] is True
assert payload["reason"] == "base_model_excluded"
assert payload["base_model"] == "SDXL 1.0"
assert payload["file_name"] == "demo.safetensors"
finally:
await client.close()
asyncio.run(scenario())
def test_resolve_folder_answers_with_the_root_that_holds_the_directory(
mock_service, mock_scanner, tmp_path: Path
):
"""The route the sidebar needs to stop guessing a root for a tree node.
The unified folder tree merges every model root into one relative-path
namespace, so "recipes" below must resolve to the root that actually has it
even though another root is configured as the library default.
"""
primary = tmp_path / "primary"
extra = tmp_path / "extra"
(primary / "recipes").mkdir(parents=True)
extra.mkdir()
mock_scanner.get_model_roots = lambda: [str(primary), str(extra)]
async def scenario():
client = await create_test_client(mock_service)
try:
response = await client.get(
"/api/lm/test-models/resolve-folder", params={"folder": "recipes"}
)
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
assert payload["folder"] == "recipes"
assert payload["candidates"] == [
{
"folder_path": str(primary / "recipes"),
"root": str(primary),
"is_symlink": False,
}
]
stale = await client.get(
"/api/lm/test-models/resolve-folder", params={"folder": "gone"}
)
assert (await stale.json())["candidates"] == []
refused = await client.get(
"/api/lm/test-models/resolve-folder",
params={"folder": str(primary / "recipes")},
)
assert refused.status == 400
finally:
await client.close()
asyncio.run(scenario())