mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-12 12:32:11 -03:00
The new informational Windows job surfaced 91 failures: four real bugs (fixed in
the previous two commits) and a long tail of POSIX assumptions in the tests
themselves. None of them assert behaviour that differs on Windows, so they are
fixed rather than skipped wherever the behaviour is the same:
* Path spelling: expectations built with `str(tmp_path)` are compared through an
identity helper (`normalize_root_identity`) or the platform's separator, since
the code hands out forward-slashed business paths while `str()` is native.
* Root labels: `_root_display_labels` prefixes the drive on Windows ("C: loras"),
so label assertions compare the final path component or derive the expected
label from the same helper.
* `~` expansion reads USERPROFILE (ntpath), not HOME; timer granularity rounds
0.1s down; a case-only rename counts as "already named" via `os.path.normcase`.
* Genuinely POSIX-only strategies are skipped with a reason instead of failing:
the headless clipboard fallback, dbus file-manager selection, xdg-open, and
the case-fold index test that simulates a case-sensitive filesystem.
955 lines
32 KiB
Python
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").replace(os.sep, "/"),
|
|
"root": str(primary).replace(os.sep, "/"),
|
|
"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())
|