mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-08 23:10:15 -03:00
1724 lines
63 KiB
Python
1724 lines
63 KiB
Python
"""Integration smoke tests for the recipe route stack."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from contextlib import asynccontextmanager
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import Any, AsyncIterator, Dict, List, Optional
|
|
|
|
from aiohttp import FormData, web
|
|
from aiohttp.test_utils import TestClient, TestServer
|
|
from PIL import Image
|
|
|
|
from py.config import config
|
|
from py.routes import base_recipe_routes
|
|
from py.routes.handlers import recipe_handlers
|
|
from py.routes.recipe_routes import RecipeRoutes
|
|
from py.recipes.parsers.civitai_image import CivitaiApiMetadataParser
|
|
from py.services.recipes import RecipeValidationError, RecipeNotFoundError
|
|
from py.services.service_registry import ServiceRegistry
|
|
|
|
|
|
@dataclass
|
|
class RecipeRouteHarness:
|
|
"""Container exposing the aiohttp client and stubbed collaborators."""
|
|
|
|
client: TestClient[Any, Any]
|
|
scanner: "StubRecipeScanner"
|
|
analysis: "StubAnalysisService"
|
|
persistence: "StubPersistenceService"
|
|
sharing: "StubSharingService"
|
|
downloader: "StubDownloader"
|
|
civitai: "StubCivitaiClient"
|
|
tmp_dir: Path
|
|
|
|
|
|
class StubRecipeScanner:
|
|
"""Minimal scanner double with the surface used by the handlers."""
|
|
|
|
def __init__(self, base_dir: Path) -> None:
|
|
self.recipes_dir = str(base_dir / "recipes")
|
|
self.listing_items: List[Dict[str, Any]] = []
|
|
self.cached_raw: List[Dict[str, Any]] = []
|
|
self.recipes: Dict[str, Dict[str, Any]] = {}
|
|
self.removed: List[str] = []
|
|
self.last_paginated_params: Dict[str, Any] | None = None
|
|
self.lora_lookup: Dict[str, List[Dict[str, Any]]] = {}
|
|
self.checkpoint_lookup: Dict[str, List[Dict[str, Any]]] = {}
|
|
self.image_id_map_override: Dict[str, str] = {}
|
|
self.local_hash_cache: Dict[str, Dict[str, Any]] | None = None
|
|
|
|
async def _noop_get_cached_data(force_refresh: bool = False) -> None: # noqa: ARG001 - signature mirrors real scanner
|
|
return None
|
|
|
|
self._lora_scanner: Any = SimpleNamespace( # mimic BaseRecipeRoutes expectations
|
|
get_cached_data=_noop_get_cached_data,
|
|
_hash_index=SimpleNamespace(_hash_to_path={}),
|
|
)
|
|
|
|
async def get_cached_data(self, force_refresh: bool = False) -> SimpleNamespace: # noqa: ARG002 - flag unused by stub
|
|
return SimpleNamespace(
|
|
raw_data=list(self.cached_raw),
|
|
image_id_map=dict(getattr(self, "image_id_map_override", {})),
|
|
)
|
|
|
|
async def build_local_hash_cache(self) -> Dict[str, Dict[str, Any]]:
|
|
"""Return the stub's local hash map; empty unless a test overrides it."""
|
|
cache = getattr(self, "local_hash_cache", None)
|
|
return cache if cache is not None else {}
|
|
|
|
async def get_paginated_data(self, **params: Any) -> Dict[str, Any]:
|
|
self.last_paginated_params = params
|
|
items = [dict(item) for item in self.listing_items]
|
|
page = int(params.get("page", 1))
|
|
page_size = int(params.get("page_size", 20))
|
|
return {
|
|
"items": items,
|
|
"total": len(items),
|
|
"page": page,
|
|
"page_size": page_size,
|
|
"total_pages": max(1, (len(items) + page_size - 1) // max(page_size, 1)),
|
|
}
|
|
|
|
async def get_recipe_by_id(self, recipe_id: str) -> Optional[Dict[str, Any]]:
|
|
return self.recipes.get(recipe_id)
|
|
|
|
async def get_recipes_for_lora(self, lora_hash: str) -> List[Dict[str, Any]]:
|
|
return list(self.lora_lookup.get(lora_hash.lower(), []))
|
|
|
|
async def get_recipes_for_checkpoint(
|
|
self, checkpoint_hash: str
|
|
) -> List[Dict[str, Any]]:
|
|
return list(self.checkpoint_lookup.get(checkpoint_hash.lower(), []))
|
|
|
|
async def get_recipe_json_path(self, recipe_id: str) -> Optional[str]:
|
|
candidate = Path(self.recipes_dir) / f"{recipe_id}.recipe.json"
|
|
return str(candidate) if candidate.exists() else None
|
|
|
|
async def get_recipe_syntax_tokens(self, recipe_id: str) -> List[str]:
|
|
return self.recipes.get(recipe_id, {}).get("syntax", []) # pragma: no cover - overridden per test
|
|
|
|
async def remove_recipe(self, recipe_id: str) -> None:
|
|
self.removed.append(recipe_id)
|
|
self.recipes.pop(recipe_id, None)
|
|
|
|
|
|
class StubAnalysisService:
|
|
"""Captures calls made by analysis routes while returning canned responses."""
|
|
|
|
instances: List["StubAnalysisService"] = []
|
|
|
|
def __init__(self, **_: Any) -> None:
|
|
self.raise_for_uploaded: Optional[Exception] = None
|
|
self.raise_for_remote: Optional[Exception] = None
|
|
self.raise_for_local: Optional[Exception] = None
|
|
self.upload_calls: List[bytes] = []
|
|
self.remote_calls: List[Optional[str]] = []
|
|
self.local_calls: List[Optional[str]] = []
|
|
self.result = SimpleNamespace(payload={"loras": []}, status=200)
|
|
self._recipe_parser_factory: Any = None
|
|
StubAnalysisService.instances.append(self)
|
|
|
|
async def analyze_uploaded_image(
|
|
self, *, image_bytes: bytes | None, recipe_scanner
|
|
) -> SimpleNamespace: # noqa: D401 - mirrors real signature
|
|
if self.raise_for_uploaded:
|
|
raise self.raise_for_uploaded
|
|
self.upload_calls.append(image_bytes or b"")
|
|
return self.result
|
|
|
|
async def analyze_remote_image(
|
|
self, *, url: Optional[str], recipe_scanner, civitai_client
|
|
) -> SimpleNamespace: # noqa: D401
|
|
if self.raise_for_remote:
|
|
raise self.raise_for_remote
|
|
self.remote_calls.append(url)
|
|
return self.result
|
|
|
|
async def analyze_local_image(
|
|
self, *, file_path: Optional[str], recipe_scanner
|
|
) -> SimpleNamespace: # noqa: D401
|
|
if self.raise_for_local:
|
|
raise self.raise_for_local
|
|
self.local_calls.append(file_path)
|
|
return self.result
|
|
|
|
async def analyze_widget_metadata(self, *, recipe_scanner) -> SimpleNamespace:
|
|
return SimpleNamespace(payload={"metadata": {}, "image_bytes": b""}, status=200)
|
|
|
|
|
|
class StubPersistenceService:
|
|
"""Stub for persistence operations to avoid filesystem writes."""
|
|
|
|
instances: List["StubPersistenceService"] = []
|
|
|
|
def __init__(self, **_: Any) -> None:
|
|
self.save_calls: List[Dict[str, Any]] = []
|
|
self.delete_calls: List[str] = []
|
|
self.move_calls: List[Dict[str, str]] = []
|
|
self.update_calls: List[Dict[str, Any]] = []
|
|
self.save_result = SimpleNamespace(
|
|
payload={"success": True, "recipe_id": "stub-id"}, status=200
|
|
)
|
|
self.delete_result = SimpleNamespace(payload={"success": True}, status=200)
|
|
StubPersistenceService.instances.append(self)
|
|
|
|
async def save_recipe(
|
|
self,
|
|
*,
|
|
recipe_scanner,
|
|
image_bytes,
|
|
image_base64,
|
|
name,
|
|
tags,
|
|
metadata,
|
|
extension=None,
|
|
recipe_id=None,
|
|
target_dir=None,
|
|
) -> SimpleNamespace: # noqa: D401
|
|
self.save_calls.append(
|
|
{
|
|
"recipe_scanner": recipe_scanner,
|
|
"image_bytes": image_bytes,
|
|
"image_base64": image_base64,
|
|
"name": name,
|
|
"tags": list(tags),
|
|
"metadata": metadata,
|
|
"extension": extension,
|
|
"recipe_id": recipe_id,
|
|
"target_dir": target_dir,
|
|
}
|
|
)
|
|
return self.save_result
|
|
|
|
async def delete_recipe(self, *, recipe_scanner, recipe_id: str) -> SimpleNamespace:
|
|
self.delete_calls.append(recipe_id)
|
|
await recipe_scanner.remove_recipe(recipe_id)
|
|
return self.delete_result
|
|
|
|
async def move_recipe(
|
|
self, *, recipe_scanner, recipe_id: str, target_path: str
|
|
) -> SimpleNamespace: # noqa: D401
|
|
self.move_calls.append({"recipe_id": recipe_id, "target_path": target_path})
|
|
return SimpleNamespace(
|
|
payload={
|
|
"success": True,
|
|
"recipe_id": recipe_id,
|
|
"new_file_path": target_path,
|
|
},
|
|
status=200,
|
|
)
|
|
|
|
async def update_recipe(
|
|
self, *, recipe_scanner, recipe_id: str, updates: Dict[str, Any]
|
|
) -> SimpleNamespace:
|
|
self.update_calls.append(
|
|
{
|
|
"recipe_scanner": recipe_scanner,
|
|
"recipe_id": recipe_id,
|
|
"updates": updates,
|
|
}
|
|
)
|
|
return SimpleNamespace(
|
|
payload={"success": True, "recipe_id": recipe_id, "updates": updates},
|
|
status=200,
|
|
)
|
|
|
|
async def reconnect_lora(
|
|
self, *, recipe_scanner, recipe_id: str, lora_index: int, target_name: str
|
|
) -> SimpleNamespace: # pragma: no cover
|
|
return SimpleNamespace(payload={"success": True}, status=200)
|
|
|
|
async def bulk_delete(
|
|
self, *, recipe_scanner, recipe_ids: List[str]
|
|
) -> SimpleNamespace: # pragma: no cover
|
|
return SimpleNamespace(
|
|
payload={"success": True, "deleted": recipe_ids}, status=200
|
|
)
|
|
|
|
async def save_recipe_from_widget(
|
|
self, *, recipe_scanner, metadata: Dict[str, Any], image_bytes: bytes
|
|
) -> SimpleNamespace: # pragma: no cover
|
|
return SimpleNamespace(payload={"success": True}, status=200)
|
|
|
|
|
|
class StubSharingService:
|
|
"""Share service stub recording requests and returning canned responses."""
|
|
|
|
instances: List["StubSharingService"] = []
|
|
|
|
def __init__(self, *, ttl_seconds: int = 300, logger) -> None: # noqa: ARG002 - ttl unused in stub
|
|
self.share_calls: List[str] = []
|
|
self.download_calls: List[str] = []
|
|
self.share_result = SimpleNamespace(
|
|
payload={
|
|
"success": True,
|
|
"download_url": "/share/stub",
|
|
"filename": "recipe.png",
|
|
},
|
|
status=200,
|
|
)
|
|
self.download_info = SimpleNamespace(file_path="", download_filename="")
|
|
StubSharingService.instances.append(self)
|
|
|
|
async def share_recipe(self, *, recipe_scanner, recipe_id: str) -> SimpleNamespace:
|
|
self.share_calls.append(recipe_id)
|
|
return self.share_result
|
|
|
|
async def prepare_download(
|
|
self, *, recipe_scanner, recipe_id: str
|
|
) -> SimpleNamespace:
|
|
self.download_calls.append(recipe_id)
|
|
return self.download_info
|
|
|
|
|
|
class StubDownloader:
|
|
"""Downloader stub that writes deterministic bytes to requested locations."""
|
|
|
|
def __init__(self) -> None:
|
|
self.urls: List[str] = []
|
|
|
|
async def download_file(self, url: str, destination: str, use_auth: bool = False): # noqa: ARG002 - use_auth unused
|
|
self.urls.append(url)
|
|
Path(destination).write_bytes(b"imported-image")
|
|
return True, destination
|
|
|
|
|
|
class StubCivitaiClient:
|
|
"""Stub for Civitai API client."""
|
|
|
|
def __init__(self) -> None:
|
|
self.image_info: Dict[str, Any] = {}
|
|
|
|
async def get_image_info(
|
|
self, image_id: str, source_url: str | None = None
|
|
) -> Optional[Dict[str, Any]]:
|
|
return self.image_info.get(image_id)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def recipe_harness(
|
|
monkeypatch, tmp_path: Path
|
|
) -> AsyncIterator[RecipeRouteHarness]:
|
|
"""Context manager that yields a fully wired recipe route harness."""
|
|
|
|
StubAnalysisService.instances.clear()
|
|
StubPersistenceService.instances.clear()
|
|
StubSharingService.instances.clear()
|
|
|
|
scanner = StubRecipeScanner(tmp_path)
|
|
civitai_client = StubCivitaiClient()
|
|
|
|
async def fake_get_recipe_scanner():
|
|
return scanner
|
|
|
|
async def fake_get_civitai_client():
|
|
return civitai_client
|
|
|
|
downloader = StubDownloader()
|
|
|
|
async def fake_get_downloader():
|
|
return downloader
|
|
|
|
monkeypatch.setattr(ServiceRegistry, "get_recipe_scanner", fake_get_recipe_scanner)
|
|
monkeypatch.setattr(ServiceRegistry, "get_civitai_client", fake_get_civitai_client)
|
|
monkeypatch.setattr(
|
|
base_recipe_routes, "RecipeAnalysisService", StubAnalysisService
|
|
)
|
|
monkeypatch.setattr(
|
|
base_recipe_routes, "RecipePersistenceService", StubPersistenceService
|
|
)
|
|
monkeypatch.setattr(base_recipe_routes, "RecipeSharingService", StubSharingService)
|
|
monkeypatch.setattr(base_recipe_routes, "get_downloader", fake_get_downloader)
|
|
monkeypatch.setattr(config, "loras_roots", [str(tmp_path)], raising=False)
|
|
|
|
app = web.Application()
|
|
RecipeRoutes.setup_routes(app)
|
|
|
|
server = TestServer(app)
|
|
client = TestClient(server)
|
|
await client.start_server()
|
|
|
|
harness = RecipeRouteHarness(
|
|
client=client,
|
|
scanner=scanner,
|
|
analysis=StubAnalysisService.instances[-1],
|
|
persistence=StubPersistenceService.instances[-1],
|
|
sharing=StubSharingService.instances[-1],
|
|
downloader=downloader,
|
|
civitai=civitai_client,
|
|
tmp_dir=tmp_path,
|
|
)
|
|
|
|
try:
|
|
yield harness
|
|
finally:
|
|
await client.close()
|
|
StubAnalysisService.instances.clear()
|
|
StubPersistenceService.instances.clear()
|
|
StubSharingService.instances.clear()
|
|
|
|
|
|
async def test_list_recipes_provides_file_urls(monkeypatch, tmp_path: Path) -> None:
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
recipe_path = harness.tmp_dir / "recipes" / "demo.png"
|
|
harness.scanner.listing_items = [
|
|
{
|
|
"id": "recipe-1",
|
|
"file_path": str(recipe_path),
|
|
"title": "Demo",
|
|
"loras": [],
|
|
}
|
|
]
|
|
harness.scanner.cached_raw = list(harness.scanner.listing_items)
|
|
|
|
response = await harness.client.get("/api/lm/recipes")
|
|
payload = await response.json()
|
|
|
|
assert response.status == 200
|
|
assert payload["items"][0]["file_url"].endswith("demo.png")
|
|
assert payload["items"][0]["loras"] == []
|
|
|
|
|
|
async def test_list_recipes_exposes_preview_dimensions(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
"""(a) Image recipe items carry integer width/height from the on-disk file."""
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
recipe_path = harness.tmp_dir / "recipes" / "real.png"
|
|
recipe_path.parent.mkdir(parents=True, exist_ok=True)
|
|
Image.new("RGB", (64, 32), color="red").save(recipe_path)
|
|
|
|
harness.scanner.listing_items = [
|
|
{
|
|
"id": "recipe-1",
|
|
"file_path": str(recipe_path),
|
|
"title": "Image Recipe",
|
|
"loras": [],
|
|
}
|
|
]
|
|
harness.scanner.cached_raw = list(harness.scanner.listing_items)
|
|
|
|
response = await harness.client.get("/api/lm/recipes")
|
|
payload = await response.json()
|
|
|
|
assert response.status == 200
|
|
item = payload["items"][0]
|
|
assert item["width"] == 64
|
|
assert item["height"] == 32
|
|
assert isinstance(item["width"], int)
|
|
assert isinstance(item["height"], int)
|
|
|
|
|
|
async def test_list_recipes_omits_dimensions_for_video_and_missing(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
"""(b) Video/missing-image recipes omit width/height yet still return 200."""
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
harness.scanner.listing_items = [
|
|
{
|
|
"id": "recipe-video",
|
|
"file_path": str(harness.tmp_dir / "recipes" / "preview.mp4"),
|
|
"title": "Video Recipe",
|
|
"loras": [],
|
|
},
|
|
{
|
|
"id": "recipe-missing",
|
|
"file_path": str(harness.tmp_dir / "recipes" / "gone.png"),
|
|
"title": "Missing Recipe",
|
|
"loras": [],
|
|
},
|
|
]
|
|
harness.scanner.cached_raw = list(harness.scanner.listing_items)
|
|
|
|
response = await harness.client.get("/api/lm/recipes")
|
|
payload = await response.json()
|
|
|
|
assert response.status == 200
|
|
for item in payload["items"]:
|
|
assert "width" not in item
|
|
assert "height" not in item
|
|
|
|
|
|
async def test_list_recipes_offloads_dimensions_to_thread(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
"""(c) Dimension reads run through asyncio.to_thread for every item."""
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
recipe_path = harness.tmp_dir / "recipes" / "real.png"
|
|
recipe_path.parent.mkdir(parents=True, exist_ok=True)
|
|
Image.new("RGB", (16, 48), color="blue").save(recipe_path)
|
|
|
|
harness.scanner.listing_items = [
|
|
{
|
|
"id": "recipe-1",
|
|
"file_path": str(recipe_path),
|
|
"title": "Image Recipe",
|
|
"loras": [],
|
|
},
|
|
{
|
|
"id": "recipe-2",
|
|
"file_path": str(harness.tmp_dir / "recipes" / "gone.png"),
|
|
"title": "Missing Recipe",
|
|
"loras": [],
|
|
},
|
|
]
|
|
harness.scanner.cached_raw = list(harness.scanner.listing_items)
|
|
|
|
real_to_thread = asyncio.to_thread
|
|
to_thread_calls: list[tuple[Any, ...]] = []
|
|
|
|
async def counting_to_thread(fn, *args, **kwargs):
|
|
to_thread_calls.append((fn, args, kwargs))
|
|
return await real_to_thread(fn, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(asyncio, "to_thread", counting_to_thread)
|
|
|
|
response = await harness.client.get("/api/lm/recipes")
|
|
payload = await response.json()
|
|
|
|
assert response.status == 200
|
|
assert len(to_thread_calls) >= len(harness.scanner.listing_items)
|
|
assert payload["items"][0]["width"] == 16
|
|
assert payload["items"][0]["height"] == 48
|
|
assert "width" not in payload["items"][1]
|
|
assert "height" not in payload["items"][1]
|
|
|
|
|
|
async def test_list_recipes_batches_dimensions_for_mixed_items(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
"""(d) Mixed file_path presence: dims align per-item with their own files."""
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
wide_path = harness.tmp_dir / "recipes" / "wide.png"
|
|
tall_path = harness.tmp_dir / "recipes" / "tall.png"
|
|
wide_path.parent.mkdir(parents=True, exist_ok=True)
|
|
Image.new("RGB", (120, 40), color="green").save(wide_path)
|
|
Image.new("RGB", (30, 90), color="blue").save(tall_path)
|
|
|
|
harness.scanner.listing_items = [
|
|
{
|
|
"id": "recipe-wide",
|
|
"file_path": str(wide_path),
|
|
"title": "Wide",
|
|
"loras": [],
|
|
},
|
|
{"id": "recipe-none", "title": "No Preview", "loras": []},
|
|
{
|
|
"id": "recipe-tall",
|
|
"file_path": str(tall_path),
|
|
"title": "Tall",
|
|
"loras": [],
|
|
},
|
|
{"id": "recipe-none-2", "title": "No Preview 2", "loras": []},
|
|
]
|
|
harness.scanner.cached_raw = list(harness.scanner.listing_items)
|
|
|
|
response = await harness.client.get("/api/lm/recipes")
|
|
payload = await response.json()
|
|
|
|
assert response.status == 200
|
|
items = payload["items"]
|
|
|
|
# Items with a file_path carry integer dims read from their own file;
|
|
# the two images differ in both dimensions so a shifted pair would
|
|
# fail these assertions.
|
|
assert items[0]["width"] == 120
|
|
assert items[0]["height"] == 40
|
|
assert isinstance(items[0]["width"], int)
|
|
assert isinstance(items[0]["height"], int)
|
|
assert items[2]["width"] == 30
|
|
assert items[2]["height"] == 90
|
|
|
|
# Items without a file_path get the no-preview fallback and omit dims.
|
|
for item in (items[1], items[3]):
|
|
assert "width" not in item
|
|
assert "height" not in item
|
|
assert item["file_url"] == "/loras_static/images/no-preview.png"
|
|
|
|
|
|
async def test_list_recipes_passes_checkpoint_hash_filter(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
response = await harness.client.get("/api/lm/recipes?checkpoint_hash=ckpt123")
|
|
payload = await response.json()
|
|
|
|
assert response.status == 200
|
|
assert payload["items"] == []
|
|
assert harness.scanner.last_paginated_params is not None
|
|
assert harness.scanner.last_paginated_params["checkpoint_hash"] == "ckpt123"
|
|
|
|
|
|
async def test_get_recipes_for_checkpoint(monkeypatch, tmp_path: Path) -> None:
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
harness.scanner.checkpoint_lookup["abc123"] = [
|
|
{"id": "recipe-1", "title": "Linked recipe"}
|
|
]
|
|
|
|
response = await harness.client.get(
|
|
"/api/lm/recipes/for-checkpoint?hash=ABC123"
|
|
)
|
|
payload = await response.json()
|
|
|
|
assert response.status == 200
|
|
assert payload == {
|
|
"success": True,
|
|
"recipes": [{"id": "recipe-1", "title": "Linked recipe"}],
|
|
}
|
|
|
|
|
|
async def test_get_recipes_for_checkpoint_requires_hash(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
response = await harness.client.get("/api/lm/recipes/for-checkpoint")
|
|
payload = await response.json()
|
|
|
|
assert response.status == 400
|
|
assert payload["success"] is False
|
|
|
|
|
|
async def test_save_and_delete_recipe_round_trip(monkeypatch, tmp_path: Path) -> None:
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
form = FormData()
|
|
form.add_field(
|
|
"image", b"stub", filename="sample.png", content_type="image/png"
|
|
)
|
|
form.add_field("name", "Test Recipe")
|
|
form.add_field("tags", json.dumps(["tag-a"]))
|
|
form.add_field("metadata", json.dumps({"loras": []}))
|
|
form.add_field("image_base64", "aW1hZ2U=")
|
|
|
|
harness.persistence.save_result = SimpleNamespace(
|
|
payload={"success": True, "recipe_id": "saved-id"},
|
|
status=201,
|
|
)
|
|
|
|
save_response = await harness.client.post("/api/lm/recipes/save", data=form)
|
|
save_payload = await save_response.json()
|
|
|
|
assert save_response.status == 201
|
|
assert save_payload["recipe_id"] == "saved-id"
|
|
assert harness.persistence.save_calls[-1]["name"] == "Test Recipe"
|
|
|
|
harness.persistence.delete_result = SimpleNamespace(
|
|
payload={"success": True}, status=200
|
|
)
|
|
|
|
delete_response = await harness.client.delete("/api/lm/recipe/saved-id")
|
|
delete_payload = await delete_response.json()
|
|
|
|
assert delete_response.status == 200
|
|
assert delete_payload["success"] is True
|
|
assert harness.persistence.delete_calls == ["saved-id"]
|
|
|
|
|
|
async def test_move_recipe_invokes_persistence(monkeypatch, tmp_path: Path) -> None:
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
response = await harness.client.post(
|
|
"/api/lm/recipe/move",
|
|
json={
|
|
"recipe_id": "move-me",
|
|
"target_path": str(tmp_path / "recipes" / "subdir"),
|
|
},
|
|
)
|
|
|
|
payload = await response.json()
|
|
assert response.status == 200
|
|
assert payload["recipe_id"] == "move-me"
|
|
assert harness.persistence.move_calls == [
|
|
{
|
|
"recipe_id": "move-me",
|
|
"target_path": str(tmp_path / "recipes" / "subdir"),
|
|
}
|
|
]
|
|
|
|
|
|
async def test_import_remote_recipe(monkeypatch, tmp_path: Path) -> None:
|
|
provider_calls: list[str | int] = []
|
|
|
|
class Provider:
|
|
async def get_model_version_info(self, model_version_id):
|
|
provider_calls.append(model_version_id)
|
|
return {
|
|
"baseModel": "Flux Provider",
|
|
"model": {"type": "Checkpoint", "name": "Flux"},
|
|
}, None
|
|
|
|
async def fake_get_default_metadata_provider():
|
|
return Provider()
|
|
|
|
monkeypatch.setattr(
|
|
"py.recipes.enrichment.get_default_metadata_provider",
|
|
fake_get_default_metadata_provider,
|
|
)
|
|
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
resources = [
|
|
{
|
|
"type": "checkpoint",
|
|
"modelId": 10,
|
|
"modelVersionId": 33,
|
|
"modelName": "Flux",
|
|
"modelVersionName": "Dev",
|
|
},
|
|
{
|
|
"type": "lora",
|
|
"modelId": 20,
|
|
"modelVersionId": 44,
|
|
"modelName": "Painterly",
|
|
"modelVersionName": "v2",
|
|
"weight": 0.25,
|
|
},
|
|
]
|
|
response = await harness.client.get(
|
|
"/api/lm/recipes/import-remote",
|
|
params={
|
|
"image_url": "https://example.com/images/1",
|
|
"name": "Remote Recipe",
|
|
"resources": json.dumps(resources),
|
|
"tags": "foo,bar",
|
|
"base_model": "Flux",
|
|
"source_path": "https://example.com/images/1",
|
|
"gen_params": json.dumps({"prompt": "hello world", "cfg_scale": 7}),
|
|
},
|
|
)
|
|
|
|
payload = await response.json()
|
|
assert response.status == 200
|
|
assert payload["success"] is True
|
|
|
|
call = harness.persistence.save_calls[-1]
|
|
assert call["name"] == "Remote Recipe"
|
|
assert call["tags"] == ["foo", "bar"]
|
|
metadata = call["metadata"]
|
|
assert metadata["base_model"] == "Flux Provider"
|
|
assert provider_calls == ["33"]
|
|
assert metadata["checkpoint"]["modelVersionId"] == 33
|
|
assert metadata["loras"][0]["weight"] == 0.25
|
|
assert metadata["gen_params"]["prompt"] == "hello world"
|
|
assert harness.downloader.urls == ["https://example.com/images/1"]
|
|
|
|
|
|
async def test_import_remote_recipe_falls_back_to_request_base_model(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
provider_calls: list[str | int] = []
|
|
|
|
class Provider:
|
|
async def get_model_version_info(self, model_version_id):
|
|
provider_calls.append(model_version_id)
|
|
return {}, None
|
|
|
|
async def fake_get_default_metadata_provider():
|
|
return Provider()
|
|
|
|
monkeypatch.setattr(
|
|
"py.recipes.enrichment.get_default_metadata_provider",
|
|
fake_get_default_metadata_provider,
|
|
)
|
|
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
resources = [
|
|
{
|
|
"type": "checkpoint",
|
|
"modelId": 11,
|
|
"modelVersionId": 77,
|
|
"modelName": "Flux",
|
|
"modelVersionName": "Dev",
|
|
},
|
|
]
|
|
response = await harness.client.get(
|
|
"/api/lm/recipes/import-remote",
|
|
params={
|
|
"image_url": "https://example.com/images/1",
|
|
"name": "Remote Recipe",
|
|
"resources": json.dumps(resources),
|
|
"tags": "foo,bar",
|
|
"base_model": "Flux",
|
|
},
|
|
)
|
|
|
|
payload = await response.json()
|
|
assert response.status == 200
|
|
assert payload["success"] is True
|
|
|
|
metadata = harness.persistence.save_calls[-1]["metadata"]
|
|
assert metadata["base_model"] == "Flux"
|
|
assert provider_calls == ["77"]
|
|
|
|
|
|
async def test_update_recipe_accepts_gen_params(monkeypatch, tmp_path: Path) -> None:
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
payload = {
|
|
"gen_params": {
|
|
"prompt": "updated prompt",
|
|
"negative_prompt": "updated negative",
|
|
"steps": 30,
|
|
}
|
|
}
|
|
|
|
response = await harness.client.put(
|
|
"/api/lm/recipe/recipe-42/update",
|
|
json=payload,
|
|
)
|
|
data = await response.json()
|
|
|
|
assert response.status == 200
|
|
assert data["success"] is True
|
|
assert harness.persistence.update_calls == [
|
|
{
|
|
"recipe_scanner": harness.scanner,
|
|
"recipe_id": "recipe-42",
|
|
"updates": payload,
|
|
}
|
|
]
|
|
|
|
|
|
async def test_import_remote_video_recipe(monkeypatch, tmp_path: Path) -> None:
|
|
async def fake_get_default_metadata_provider():
|
|
return SimpleNamespace(get_model_version_info=lambda id: ({}, None))
|
|
|
|
monkeypatch.setattr(
|
|
"py.recipes.enrichment.get_default_metadata_provider",
|
|
fake_get_default_metadata_provider,
|
|
)
|
|
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
harness.civitai.image_info["12345"] = {
|
|
"id": 12345,
|
|
"url": "https://image.civitai.com/x/y/original=true/video.mp4",
|
|
"type": "video",
|
|
}
|
|
|
|
response = await harness.client.get(
|
|
"/api/lm/recipes/import-remote",
|
|
params={
|
|
"image_url": "https://civitai.com/images/12345",
|
|
"name": "Video Recipe",
|
|
"resources": json.dumps([]),
|
|
"base_model": "Flux",
|
|
},
|
|
)
|
|
|
|
payload = await response.json()
|
|
assert response.status == 200
|
|
assert payload["success"] is True
|
|
|
|
# Verify downloader was called with rewritten URL
|
|
assert "transcode=true" in harness.downloader.urls[0]
|
|
|
|
# Verify persistence was called with correct extension
|
|
call = harness.persistence.save_calls[-1]
|
|
assert call["extension"] == ".mp4"
|
|
|
|
|
|
async def test_import_remote_recipe_supports_civitai_red(monkeypatch, tmp_path: Path) -> None:
|
|
async def fake_get_default_metadata_provider():
|
|
return SimpleNamespace(get_model_version_info=lambda id: ({}, None))
|
|
|
|
monkeypatch.setattr(
|
|
"py.recipes.enrichment.get_default_metadata_provider",
|
|
fake_get_default_metadata_provider,
|
|
)
|
|
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
harness.civitai.image_info["126920345"] = {
|
|
"id": 126920345,
|
|
"url": "https://image.civitai.com/x/y/original=true/sample.jpeg",
|
|
"type": "image",
|
|
}
|
|
|
|
response = await harness.client.get(
|
|
"/api/lm/recipes/import-remote",
|
|
params={
|
|
"image_url": "https://civitai.red/images/126920345",
|
|
"name": "Red Recipe",
|
|
"resources": json.dumps([]),
|
|
"base_model": "Flux",
|
|
},
|
|
)
|
|
|
|
payload = await response.json()
|
|
assert response.status == 200
|
|
assert payload["success"] is True
|
|
assert harness.downloader.urls
|
|
assert "width=450,optimized=true" in harness.downloader.urls[0]
|
|
|
|
|
|
async def test_analyze_remote_image_supports_civitai_red(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
harness.analysis.result = SimpleNamespace(payload={"loras": []}, status=200)
|
|
|
|
response = await harness.client.post(
|
|
"/api/lm/recipes/analyze-image",
|
|
json={"url": "https://civitai.red/images/126920345"},
|
|
)
|
|
payload = await response.json()
|
|
|
|
assert response.status == 200
|
|
assert payload == {"loras": []}
|
|
assert harness.analysis.remote_calls == [
|
|
"https://civitai.red/images/126920345"
|
|
]
|
|
|
|
|
|
async def test_analyze_uploaded_image_error_path(monkeypatch, tmp_path: Path) -> None:
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
harness.analysis.raise_for_uploaded = RecipeValidationError(
|
|
"No image data provided"
|
|
)
|
|
|
|
form = FormData()
|
|
form.add_field("image", b"", filename="empty.png", content_type="image/png")
|
|
|
|
response = await harness.client.post("/api/lm/recipes/analyze-image", data=form)
|
|
payload = await response.json()
|
|
|
|
assert response.status == 400
|
|
assert payload["error"] == "No image data provided"
|
|
assert payload["loras"] == []
|
|
|
|
|
|
async def test_share_and_download_recipe(monkeypatch, tmp_path: Path) -> None:
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
recipe_id = "share-me"
|
|
download_path = harness.tmp_dir / "recipes" / "share.png"
|
|
download_path.parent.mkdir(parents=True, exist_ok=True)
|
|
download_path.write_bytes(b"stub")
|
|
|
|
harness.scanner.recipes[recipe_id] = {
|
|
"id": recipe_id,
|
|
"title": "Shared",
|
|
"file_path": str(download_path),
|
|
}
|
|
|
|
harness.sharing.share_result = SimpleNamespace(
|
|
payload={
|
|
"success": True,
|
|
"download_url": "/api/share",
|
|
"filename": "share.png",
|
|
},
|
|
status=200,
|
|
)
|
|
harness.sharing.download_info = SimpleNamespace(
|
|
file_path=str(download_path),
|
|
download_filename="share.png",
|
|
)
|
|
|
|
share_response = await harness.client.get(f"/api/lm/recipe/{recipe_id}/share")
|
|
share_payload = await share_response.json()
|
|
|
|
assert share_response.status == 200
|
|
assert share_payload["filename"] == "share.png"
|
|
assert harness.sharing.share_calls == [recipe_id]
|
|
|
|
download_response = await harness.client.get(
|
|
f"/api/lm/recipe/{recipe_id}/share/download"
|
|
)
|
|
body = await download_response.read()
|
|
|
|
assert download_response.status == 200
|
|
assert (
|
|
download_response.headers["Content-Disposition"]
|
|
== 'attachment; filename="share.png"'
|
|
)
|
|
assert body == b"stub"
|
|
|
|
download_path.unlink(missing_ok=True)
|
|
|
|
|
|
async def test_import_remote_recipe_merges_metadata(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
# 1. Mock Metadata Provider
|
|
class Provider:
|
|
async def get_model_version_info(self, model_version_id):
|
|
return {"baseModel": "Flux Provider"}, None
|
|
|
|
async def fake_get_default_metadata_provider():
|
|
return Provider()
|
|
|
|
monkeypatch.setattr(
|
|
"py.recipes.enrichment.get_default_metadata_provider",
|
|
fake_get_default_metadata_provider,
|
|
)
|
|
|
|
# 2. Mock ExifUtils to return some embedded metadata
|
|
class MockExifUtils:
|
|
@staticmethod
|
|
def extract_image_metadata(path):
|
|
return "Recipe metadata: " + json.dumps(
|
|
{"gen_params": {"prompt": "from embedded", "seed": 123}}
|
|
)
|
|
|
|
monkeypatch.setattr(recipe_handlers, "ExifUtils", MockExifUtils)
|
|
|
|
# 3. Mock Parser Factory for StubAnalysisService
|
|
class MockParser:
|
|
async def parse_metadata(self, raw, recipe_scanner=None):
|
|
return json.loads(raw[len("Recipe metadata: ") :])
|
|
|
|
class MockApiParser:
|
|
async def parse_metadata(self, raw, recipe_scanner=None):
|
|
return {"gen_params": raw, "loras": []}
|
|
|
|
class MockFactory:
|
|
def create_parser(self, raw):
|
|
if isinstance(raw, str) and raw.startswith("Recipe metadata: "):
|
|
return MockParser()
|
|
if isinstance(raw, dict):
|
|
return MockApiParser()
|
|
return None
|
|
|
|
# 4. Setup Harness and run test
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
harness.analysis._recipe_parser_factory = MockFactory()
|
|
|
|
# Civitai meta via image_info
|
|
harness.civitai.image_info["1"] = {
|
|
"id": 1,
|
|
"url": "https://example.com/images/1.jpg",
|
|
"meta": {"prompt": "from civitai", "cfg": 7.0},
|
|
}
|
|
|
|
resources = []
|
|
response = await harness.client.get(
|
|
"/api/lm/recipes/import-remote",
|
|
params={
|
|
"image_url": "https://civitai.com/images/1",
|
|
"name": "Merged Recipe",
|
|
"resources": json.dumps(resources),
|
|
"gen_params": json.dumps({"prompt": "from request", "steps": 25}),
|
|
},
|
|
)
|
|
|
|
payload = await response.json()
|
|
assert response.status == 200
|
|
|
|
call = harness.persistence.save_calls[-1]
|
|
metadata = call["metadata"]
|
|
gen_params = metadata["gen_params"]
|
|
|
|
assert gen_params["seed"] == 123
|
|
|
|
|
|
async def test_get_recipe_syntax(monkeypatch, tmp_path: Path) -> None:
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
recipe_id = "test-recipe-id"
|
|
harness.scanner.recipes[recipe_id] = {
|
|
"id": recipe_id,
|
|
"title": "Syntax Test",
|
|
"loras": [{"name": "lora1", "weight": 0.5}],
|
|
}
|
|
|
|
# Mock the method that handlers call
|
|
async def fake_get_recipe_syntax_tokens(rid):
|
|
if rid == recipe_id:
|
|
return ["<lora:lora1:0.5>"]
|
|
raise RecipeNotFoundError(f"Recipe {rid} not found")
|
|
|
|
harness.scanner.get_recipe_syntax_tokens = ( # pyright: ignore[reportAttributeAccessIssue]
|
|
fake_get_recipe_syntax_tokens
|
|
)
|
|
|
|
response = await harness.client.get(f"/api/lm/recipe/{recipe_id}/syntax")
|
|
payload = await response.json()
|
|
|
|
assert response.status == 200
|
|
assert payload["success"] is True
|
|
assert payload["syntax"] == "<lora:lora1:0.5>"
|
|
|
|
# Test error path
|
|
response_404 = await harness.client.get("/api/lm/recipe/non-existent/syntax")
|
|
assert response_404.status == 404
|
|
|
|
|
|
async def test_batch_import_start_success(monkeypatch, tmp_path: Path) -> None:
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
response = await harness.client.post(
|
|
"/api/lm/recipes/batch-import/start",
|
|
json={
|
|
"items": [
|
|
{"source": "https://example.com/image1.png"},
|
|
{"source": "https://example.com/image2.png"},
|
|
],
|
|
"tags": ["batch", "import"],
|
|
"skip_no_metadata": True,
|
|
},
|
|
)
|
|
payload = await response.json()
|
|
assert response.status == 200
|
|
assert payload["success"] is True
|
|
assert "operation_id" in payload
|
|
|
|
|
|
async def test_batch_import_start_empty_items(monkeypatch, tmp_path: Path) -> None:
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
response = await harness.client.post(
|
|
"/api/lm/recipes/batch-import/start",
|
|
json={"items": [], "tags": []},
|
|
)
|
|
payload = await response.json()
|
|
assert response.status == 400
|
|
assert payload["success"] is False
|
|
assert "No items provided" in payload["error"]
|
|
|
|
|
|
async def test_batch_import_start_missing_source(monkeypatch, tmp_path: Path) -> None:
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
response = await harness.client.post(
|
|
"/api/lm/recipes/batch-import/start",
|
|
json={"items": [{"source": ""}]},
|
|
)
|
|
payload = await response.json()
|
|
assert response.status == 400
|
|
assert payload["success"] is False
|
|
assert "source" in payload["error"].lower()
|
|
|
|
|
|
async def test_batch_import_start_already_running(monkeypatch, tmp_path: Path) -> None:
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
original_analyze = harness.analysis.analyze_remote_image
|
|
|
|
async def slow_analyze(*, url, recipe_scanner, civitai_client):
|
|
await asyncio.sleep(0.5)
|
|
return await original_analyze(
|
|
url=url, recipe_scanner=recipe_scanner, civitai_client=civitai_client
|
|
)
|
|
|
|
harness.analysis.analyze_remote_image = slow_analyze
|
|
|
|
items = [{"source": f"https://example.com/image{i}.png"} for i in range(10)]
|
|
|
|
response1 = await harness.client.post(
|
|
"/api/lm/recipes/batch-import/start",
|
|
json={"items": items},
|
|
)
|
|
assert response1.status == 200
|
|
|
|
payload1 = await response1.json()
|
|
assert payload1["success"] is True
|
|
|
|
await asyncio.sleep(0.1)
|
|
|
|
response2 = await harness.client.post(
|
|
"/api/lm/recipes/batch-import/start",
|
|
json={"items": [{"source": "https://example.com/other.png"}]},
|
|
)
|
|
payload2 = await response2.json()
|
|
assert response2.status == 409
|
|
assert "already in progress" in payload2["error"].lower()
|
|
|
|
|
|
async def test_batch_import_get_progress_not_found(monkeypatch, tmp_path: Path) -> None:
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
response = await harness.client.get(
|
|
"/api/lm/recipes/batch-import/progress",
|
|
params={"operation_id": "nonexistent-id"},
|
|
)
|
|
payload = await response.json()
|
|
assert response.status == 404
|
|
assert payload["success"] is False
|
|
|
|
|
|
async def test_batch_import_get_progress_missing_id(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
response = await harness.client.get("/api/lm/recipes/batch-import/progress")
|
|
payload = await response.json()
|
|
assert response.status == 400
|
|
assert payload["success"] is False
|
|
|
|
|
|
async def test_batch_import_cancel_success(monkeypatch, tmp_path: Path) -> None:
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
start_response = await harness.client.post(
|
|
"/api/lm/recipes/batch-import/start",
|
|
json={"items": [{"source": "https://example.com/image.png"}]},
|
|
)
|
|
start_payload = await start_response.json()
|
|
operation_id = start_payload["operation_id"]
|
|
|
|
cancel_response = await harness.client.post(
|
|
"/api/lm/recipes/batch-import/cancel",
|
|
json={"operation_id": operation_id},
|
|
)
|
|
cancel_payload = await cancel_response.json()
|
|
assert cancel_response.status == 200
|
|
assert cancel_payload["success"] is True
|
|
|
|
|
|
async def test_batch_import_cancel_not_found(monkeypatch, tmp_path: Path) -> None:
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
response = await harness.client.post(
|
|
"/api/lm/recipes/batch-import/cancel",
|
|
json={"operation_id": "nonexistent-id"},
|
|
)
|
|
payload = await response.json()
|
|
assert response.status == 404
|
|
assert payload["success"] is False
|
|
|
|
|
|
async def test_batch_import_cancel_missing_id(monkeypatch, tmp_path: Path) -> None:
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
response = await harness.client.post(
|
|
"/api/lm/recipes/batch-import/cancel",
|
|
json={},
|
|
)
|
|
payload = await response.json()
|
|
assert response.status == 400
|
|
assert payload["success"] is False
|
|
|
|
|
|
async def test_check_image_exists_uses_image_id_map(monkeypatch, tmp_path: Path) -> None:
|
|
"""check_image_exists must use precomputed image_id_map instead of scanning raw_data."""
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
harness.scanner.image_id_map_override = {
|
|
"123": "recipe-alpha",
|
|
"789": "recipe-gamma",
|
|
}
|
|
|
|
response = await harness.client.get(
|
|
"/api/lm/recipes/check-image-exists",
|
|
params={"image_ids": "123,456,789"},
|
|
)
|
|
payload = await response.json()
|
|
|
|
assert response.status == 200
|
|
assert payload["success"] is True
|
|
assert payload["results"]["123"] == {
|
|
"in_library": True,
|
|
"recipe_id": "recipe-alpha",
|
|
}
|
|
assert payload["results"]["456"] == {
|
|
"in_library": False,
|
|
"recipe_id": None,
|
|
}
|
|
assert payload["results"]["789"] == {
|
|
"in_library": True,
|
|
"recipe_id": "recipe-gamma",
|
|
}
|
|
|
|
|
|
async def test_check_image_exists_handles_empty_input(monkeypatch, tmp_path: Path) -> None:
|
|
"""Empty or non-numeric image_ids must return an empty results dict."""
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
response = await harness.client.get(
|
|
"/api/lm/recipes/check-image-exists",
|
|
params={"image_ids": ""},
|
|
)
|
|
payload = await response.json()
|
|
assert response.status == 200
|
|
assert payload["results"] == {}
|
|
|
|
|
|
async def test_import_from_url_detects_duplicate_via_image_id_map(
|
|
monkeypatch, tmp_path: Path,
|
|
) -> None:
|
|
"""import_from_url must return already_exists when image_id is in image_id_map."""
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
harness.scanner.cached_raw = [
|
|
{"id": "existing-recipe", "title": "My Recipe"},
|
|
]
|
|
harness.scanner.image_id_map_override = {
|
|
"99999": "existing-recipe",
|
|
}
|
|
|
|
response = await harness.client.get(
|
|
"/api/lm/recipes/import-from-url",
|
|
params={"image_url": "https://civitai.com/images/99999"},
|
|
)
|
|
payload = await response.json()
|
|
|
|
assert response.status == 200
|
|
assert payload["already_exists"] is True
|
|
assert payload["recipe_id"] == "existing-recipe"
|
|
assert payload["name"] == "My Recipe"
|
|
|
|
|
|
async def test_import_from_url_proceeds_when_image_id_not_in_map(
|
|
monkeypatch, tmp_path: Path,
|
|
) -> None:
|
|
"""When image_id is absent from image_id_map, import_from_url must proceed to import."""
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
harness.scanner.image_id_map_override = {
|
|
"111": "some-other-recipe",
|
|
}
|
|
harness.civitai.image_info["99999"] = {
|
|
"id": 99999,
|
|
"url": "https://image.civitai.com/x/y/original=true/sample.jpeg",
|
|
"type": "image",
|
|
"meta": {"prompt": "test"},
|
|
}
|
|
|
|
response = await harness.client.get(
|
|
"/api/lm/recipes/import-from-url",
|
|
params={"image_url": "https://civitai.com/images/99999"},
|
|
)
|
|
|
|
# The import may succeed or fail depending on downstream stubs,
|
|
# but it must NOT return already_exists
|
|
payload = await response.json()
|
|
assert payload.get("already_exists") is not True
|
|
|
|
|
|
async def test_import_remote_recipe_passes_local_cache_to_civitai_parser(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
"""import_remote must hand the same local hash cache to both Civitai parse passes."""
|
|
parse_calls: list[Dict[str, Any]] = []
|
|
|
|
class SpyCivitaiParser(CivitaiApiMetadataParser):
|
|
async def parse_metadata(
|
|
self, user_comment, recipe_scanner=None, civitai_client=None,
|
|
local_cache=None,
|
|
) -> Dict[str, Any]:
|
|
parse_calls.append({"local_cache": local_cache})
|
|
return {"gen_params": {"prompt": "spy"}}
|
|
|
|
class SpyFactory:
|
|
def create_parser(self, raw):
|
|
return SpyCivitaiParser()
|
|
|
|
class MockExifUtils:
|
|
@staticmethod
|
|
def extract_image_metadata(path):
|
|
return "Recipe metadata: " + json.dumps({"gen_params": {"seed": 7}})
|
|
|
|
async def fake_get_default_metadata_provider():
|
|
return SimpleNamespace(get_model_version_info=lambda _id: ({}, None))
|
|
|
|
monkeypatch.setattr(recipe_handlers, "ExifUtils", MockExifUtils)
|
|
monkeypatch.setattr(
|
|
"py.recipes.enrichment.get_default_metadata_provider",
|
|
fake_get_default_metadata_provider,
|
|
)
|
|
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
harness.analysis._recipe_parser_factory = SpyFactory()
|
|
cache_marker = {"abcdef123456": {"model_name": "local-lora"}}
|
|
harness.scanner.local_hash_cache = cache_marker
|
|
harness.civitai.image_info["1"] = {
|
|
"id": 1,
|
|
"url": "https://example.com/images/1.jpg",
|
|
"meta": {"prompt": "from civitai", "seed": 99},
|
|
}
|
|
|
|
response = await harness.client.get(
|
|
"/api/lm/recipes/import-remote",
|
|
params={
|
|
"image_url": "https://civitai.com/images/1",
|
|
"name": "Civitai Cache",
|
|
"resources": json.dumps([]),
|
|
"gen_params": json.dumps({"prompt": "from request", "steps": 25}),
|
|
},
|
|
)
|
|
payload = await response.json()
|
|
|
|
assert response.status == 200
|
|
assert payload["success"] is True
|
|
# Both parse passes (EXIF + CivitAI API) ran and received the SAME
|
|
# local_cache object, built once at handler level.
|
|
assert len(parse_calls) == 2
|
|
assert all(call["local_cache"] is cache_marker for call in parse_calls)
|
|
assert parse_calls[0]["local_cache"] is parse_calls[1]["local_cache"]
|
|
|
|
|
|
async def test_import_remote_recipe_does_not_pass_local_cache_to_other_parsers(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
"""Non-Civitai parsers must never receive local_cache (their signature lacks it)."""
|
|
parse_calls: list[Dict[str, Any]] = []
|
|
|
|
class PlainParser:
|
|
async def parse_metadata(self, raw, recipe_scanner=None, **kwargs):
|
|
assert "local_cache" not in kwargs, (
|
|
"local_cache leaked to a non-Civitai parser"
|
|
)
|
|
parse_calls.append({"raw": raw})
|
|
return {"gen_params": {"prompt": "plain"}}
|
|
|
|
class PlainFactory:
|
|
def create_parser(self, raw):
|
|
return PlainParser()
|
|
|
|
class MockExifUtils:
|
|
@staticmethod
|
|
def extract_image_metadata(path):
|
|
return "Recipe metadata: " + json.dumps({"gen_params": {"seed": 7}})
|
|
|
|
async def fake_get_default_metadata_provider():
|
|
return SimpleNamespace(get_model_version_info=lambda _id: ({}, None))
|
|
|
|
monkeypatch.setattr(recipe_handlers, "ExifUtils", MockExifUtils)
|
|
monkeypatch.setattr(
|
|
"py.recipes.enrichment.get_default_metadata_provider",
|
|
fake_get_default_metadata_provider,
|
|
)
|
|
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
harness.analysis._recipe_parser_factory = PlainFactory()
|
|
harness.scanner.local_hash_cache = {"deadbeef": {"model_name": "local"}}
|
|
harness.civitai.image_info["1"] = {
|
|
"id": 1,
|
|
"url": "https://example.com/images/1.jpg",
|
|
"meta": {"prompt": "from civitai", "seed": 99},
|
|
}
|
|
|
|
response = await harness.client.get(
|
|
"/api/lm/recipes/import-remote",
|
|
params={
|
|
"image_url": "https://civitai.com/images/1",
|
|
"name": "Plain Cache",
|
|
"resources": json.dumps([]),
|
|
"gen_params": json.dumps({"prompt": "from request", "steps": 25}),
|
|
},
|
|
)
|
|
payload = await response.json()
|
|
|
|
assert response.status == 200
|
|
assert payload["success"] is True
|
|
assert len(parse_calls) == 2
|
|
|
|
|
|
async def test_import_from_url_passes_local_cache_on_all_three_parse_calls(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
"""import_from_url must pass local_cache to all three Civitai parse passes."""
|
|
parse_calls: list[Dict[str, Any]] = []
|
|
str_call_count = 0
|
|
|
|
class SpyCivitaiParser(CivitaiApiMetadataParser):
|
|
async def parse_metadata(
|
|
self, user_comment, recipe_scanner=None, civitai_client=None,
|
|
local_cache=None,
|
|
) -> Dict[str, Any]:
|
|
nonlocal str_call_count
|
|
parse_calls.append({"local_cache": local_cache})
|
|
if isinstance(user_comment, str):
|
|
str_call_count += 1
|
|
if str_call_count == 1:
|
|
# First (optimized EXIF) pass yields an empty dict so the
|
|
# handler falls back to the original image, exercising
|
|
# pass #2 (an empty dict is falsy for `not parsed_embedded`).
|
|
return {}
|
|
return {"gen_params": {"prompt": "fallback"}}
|
|
return {"gen_params": {"prompt": "civitai"}}
|
|
|
|
class SpyFactory:
|
|
def create_parser(self, raw):
|
|
return SpyCivitaiParser()
|
|
|
|
class MockExifUtils:
|
|
@staticmethod
|
|
def extract_image_metadata(path):
|
|
if path.endswith(".png"):
|
|
return "original metadata: " + json.dumps({"gen_params": {"seed": 2}})
|
|
return "optimized metadata: " + json.dumps({"gen_params": {"seed": 1}})
|
|
|
|
async def fake_get_default_metadata_provider():
|
|
return SimpleNamespace(get_model_version_info=lambda _id: ({}, None))
|
|
|
|
monkeypatch.setattr(recipe_handlers, "ExifUtils", MockExifUtils)
|
|
monkeypatch.setattr(
|
|
"py.recipes.enrichment.get_default_metadata_provider",
|
|
fake_get_default_metadata_provider,
|
|
)
|
|
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
harness.analysis._recipe_parser_factory = SpyFactory()
|
|
cache_marker = {"deadbeef": {"model_name": "local-lora"}}
|
|
harness.scanner.local_hash_cache = cache_marker
|
|
harness.civitai.image_info["42"] = {
|
|
"id": 42,
|
|
"url": "https://image.civitai.com/x/y/original=true/sample.jpeg",
|
|
"type": "image",
|
|
"meta": {"prompt": "test", "seed": 3},
|
|
}
|
|
|
|
response = await harness.client.get(
|
|
"/api/lm/recipes/import-from-url",
|
|
params={"image_url": "https://civitai.com/images/42"},
|
|
)
|
|
payload = await response.json()
|
|
|
|
assert response.status == 200
|
|
assert payload["success"] is True
|
|
assert len(parse_calls) == 3
|
|
assert all(call["local_cache"] is cache_marker for call in parse_calls)
|
|
assert parse_calls[0]["local_cache"] is parse_calls[2]["local_cache"]
|
|
|
|
|
|
class _RawDataLoraScanner:
|
|
"""Lora-scanner double whose get_cached_data returns a fixed raw_data list."""
|
|
|
|
def __init__(self, raw_data: List[Dict[str, Any]]) -> None:
|
|
self._raw_data = raw_data
|
|
|
|
async def get_cached_data(self) -> SimpleNamespace:
|
|
return SimpleNamespace(raw_data=self._raw_data)
|
|
|
|
|
|
class _SpyCivitaiParser(CivitaiApiMetadataParser):
|
|
"""Records the local_cache passed to parse_metadata and returns a canned result."""
|
|
|
|
def __init__(self, parse_result: Dict[str, Any]) -> None:
|
|
self.received_cache: Optional[Dict[str, Dict[str, Any]]] = None
|
|
self._parse_result = parse_result
|
|
|
|
async def parse_metadata(
|
|
self, user_comment, recipe_scanner=None, civitai_client=None, local_cache=None,
|
|
) -> Dict[str, Any]:
|
|
self.received_cache = local_cache
|
|
return self._parse_result
|
|
|
|
|
|
class _SpyFactory:
|
|
def __init__(self, parser: _SpyCivitaiParser) -> None:
|
|
self._parser = parser
|
|
|
|
def create_parser(self, raw): # noqa: ARG001 - mirrors real factory signature
|
|
return self._parser
|
|
|
|
|
|
async def _post_create_from_example(
|
|
harness: RecipeRouteHarness, model_hash: str, *, model_name: str = "parent.safetensors"
|
|
) -> Any:
|
|
return await harness.client.post(
|
|
"/api/lm/recipes/create-from-example",
|
|
json={
|
|
"image_data": {
|
|
"url": "https://image.civitai.com/x/y/original=true/sample.jpeg",
|
|
"meta": {"prompt": "sample prompt"},
|
|
},
|
|
"model_hash": model_hash,
|
|
"model_name": model_name,
|
|
"model_type": "loras",
|
|
},
|
|
)
|
|
|
|
|
|
async def test_create_from_example_resolves_parent_item_by_sha256_key(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
"""A parent present in the shared cache under its sha256 key drives isDeleted reconciliation."""
|
|
model_hash = "a1b2c3d4e5f60718293a4b5c6d7e8f90123456789abcdef0123456789abcdef"
|
|
model_name = "parent.safetensors"
|
|
parent_item: Dict[str, Any] = {
|
|
"civitai": {"id": 77, "modelId": 88, "name": "v1.0"},
|
|
"model_name": "Parent Model",
|
|
}
|
|
parser = _SpyCivitaiParser(
|
|
{
|
|
"loras": [{"isDeleted": True, "file_name": model_name, "name": "Stale"}],
|
|
"gen_params": {"prompt": "test prompt"},
|
|
}
|
|
)
|
|
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
harness.analysis._recipe_parser_factory = _SpyFactory(parser)
|
|
harness.scanner.local_hash_cache = {model_hash.lower(): parent_item}
|
|
|
|
response = await _post_create_from_example(harness, model_hash, model_name=model_name)
|
|
payload = await response.json()
|
|
|
|
assert response.status == 200, payload
|
|
# The shared builder result is the exact object handed to the parser.
|
|
assert parser.received_cache is harness.scanner.local_hash_cache
|
|
lora = harness.persistence.save_calls[0]["metadata"]["loras"][0]
|
|
assert lora["isDeleted"] is False
|
|
assert lora["existsLocally"] is True
|
|
assert lora["hash"] == model_hash
|
|
assert lora["id"] == 77
|
|
assert lora["modelId"] == 88
|
|
assert lora["version"] == "v1.0"
|
|
assert lora["name"] == "Parent Model"
|
|
|
|
|
|
async def test_create_from_example_computes_autov3_for_unbackfilled_parent(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
"""A parent with no stored autov3 gets a single-file compute registered in the cache."""
|
|
model_hash = "b2c3d4e5f60718293a4b5c6d7e8f90123456789abcdef0123456789abcdef0"
|
|
model_file = tmp_path / "parent.safetensors"
|
|
model_file.write_bytes(b"\x00" * 16)
|
|
parent_item: Dict[str, Any] = {
|
|
"sha256": model_hash,
|
|
"autov3": "",
|
|
"file_path": str(model_file),
|
|
"model_name": "Parent Model",
|
|
}
|
|
autov3_calls: list[str] = []
|
|
|
|
def fake_calculate_autov3(file_path: str) -> str | None:
|
|
autov3_calls.append(file_path)
|
|
return "abc123def456"
|
|
|
|
monkeypatch.setattr("py.utils.file_utils.calculate_autov3", fake_calculate_autov3)
|
|
parser = _SpyCivitaiParser({"loras": [], "gen_params": {"prompt": "test prompt"}})
|
|
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
harness.analysis._recipe_parser_factory = _SpyFactory(parser)
|
|
# The sha256 key is already present (as the real builder registers it);
|
|
# the supplement must still run because the stored autov3 is empty.
|
|
harness.scanner.local_hash_cache = {model_hash.lower(): parent_item}
|
|
harness.scanner._lora_scanner = _RawDataLoraScanner([parent_item])
|
|
|
|
response = await _post_create_from_example(harness, model_hash)
|
|
payload = await response.json()
|
|
|
|
assert response.status == 200, payload
|
|
# Bounded: exactly one header read for the single parent file.
|
|
assert autov3_calls == [str(model_file)]
|
|
assert parser.received_cache is harness.scanner.local_hash_cache
|
|
assert parser.received_cache is not None
|
|
# The computed autov3 key was registered, pointing at the parent item.
|
|
assert parser.received_cache["abc123def456"] is parent_item
|
|
|
|
|
|
async def test_create_from_example_parent_not_in_library_has_no_arbitrary_item(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
"""A missing parent must NOT fall back to an arbitrary cache entry during auto-populate."""
|
|
model_hash = "c3d4e5f60718293a4b5c6d7e8f90123456789abcdef0123456789abcdef01"
|
|
model_name = "parent.safetensors"
|
|
decoy: Dict[str, Any] = {
|
|
"civitai": {"id": 999, "modelId": 998, "name": "v9"},
|
|
"model_name": "Wrong Model",
|
|
}
|
|
parser = _SpyCivitaiParser({"loras": [], "gen_params": {"prompt": "test prompt"}})
|
|
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
harness.analysis._recipe_parser_factory = _SpyFactory(parser)
|
|
harness.scanner.local_hash_cache = {"someotherhash": decoy}
|
|
|
|
response = await _post_create_from_example(harness, model_hash, model_name=model_name)
|
|
payload = await response.json()
|
|
|
|
assert response.status == 200, payload
|
|
lora = harness.persistence.save_calls[0]["metadata"]["loras"][0]
|
|
assert lora["hash"] == model_hash
|
|
assert lora["file_name"] == model_name
|
|
assert lora["existsLocally"] is True
|
|
# parent_item is None: the entry keeps its default name, and no
|
|
# enrichment fields may come from any cache entry.
|
|
assert lora["name"] == model_name
|
|
assert "id" not in lora
|
|
assert "modelId" not in lora
|
|
assert "version" not in lora
|
|
|
|
|
|
async def test_create_from_example_parent_not_in_library_isDeleted_reconciliation_has_no_arbitrary_item(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
"""isDeleted reconciliation must not enrich from an arbitrary entry when the parent is missing."""
|
|
model_hash = "d4e5f60718293a4b5c6d7e8f90123456789abcdef0123456789abcdef012"
|
|
model_name = "parent.safetensors"
|
|
decoy: Dict[str, Any] = {
|
|
"civitai": {"id": 777, "modelId": 776, "name": "v7"},
|
|
"model_name": "Decoy Model",
|
|
}
|
|
parser = _SpyCivitaiParser(
|
|
{
|
|
"loras": [{"isDeleted": True, "file_name": model_name}],
|
|
"gen_params": {"prompt": "test prompt"},
|
|
}
|
|
)
|
|
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
harness.analysis._recipe_parser_factory = _SpyFactory(parser)
|
|
harness.scanner.local_hash_cache = {"someotherhash": decoy}
|
|
|
|
response = await _post_create_from_example(harness, model_hash, model_name=model_name)
|
|
payload = await response.json()
|
|
|
|
assert response.status == 200, payload
|
|
lora = harness.persistence.save_calls[0]["metadata"]["loras"][0]
|
|
# Reconciliation still runs (parent matched by file_name), but enriches
|
|
# nothing because parent_item is None.
|
|
assert lora["isDeleted"] is False
|
|
assert lora["existsLocally"] is True
|
|
assert lora["hash"] == model_hash
|
|
assert "id" not in lora
|
|
assert "name" not in lora
|
|
|
|
|
|
async def test_create_from_example_does_not_pass_local_cache_to_other_parsers(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
"""Non-Civitai parsers must never receive local_cache (their signature lacks it)."""
|
|
parse_calls: list[Dict[str, Any]] = []
|
|
|
|
class PlainParser:
|
|
async def parse_metadata(self, raw, recipe_scanner=None, **kwargs):
|
|
assert "local_cache" not in kwargs, (
|
|
"local_cache leaked to a non-Civitai parser"
|
|
)
|
|
parse_calls.append({"raw": raw})
|
|
return {"loras": [], "gen_params": {"prompt": "plain"}}
|
|
|
|
class PlainFactory:
|
|
def create_parser(self, raw): # noqa: ARG001 - mirrors real factory signature
|
|
return PlainParser()
|
|
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
harness.analysis._recipe_parser_factory = PlainFactory()
|
|
harness.scanner.local_hash_cache = {"deadbeef": {"model_name": "local"}}
|
|
|
|
response = await _post_create_from_example(harness, "f1234", model_name="plain.safetensors")
|
|
payload = await response.json()
|
|
|
|
assert response.status == 200, payload
|
|
assert len(parse_calls) == 1
|
|
|
|
|
|
async def test_create_from_example_does_not_recompute_stored_autov3(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
"""A parent whose autov3 is already stored must not trigger the supplement."""
|
|
model_hash = "e5f60718293a4b5c6d7e8f90123456789abcdef0123456789abcdef0123"
|
|
model_file = tmp_path / "parent.safetensors"
|
|
model_file.write_bytes(b"\x00" * 16)
|
|
parent_item: Dict[str, Any] = {
|
|
"sha256": model_hash,
|
|
"autov3": "existing123456",
|
|
"file_path": str(model_file),
|
|
"model_name": "Parent Model",
|
|
}
|
|
autov3_calls: list[str] = []
|
|
|
|
def fake_calculate_autov3(file_path: str) -> str | None: # noqa: ARG001 - must not run
|
|
autov3_calls.append(file_path)
|
|
return "should-not-run"
|
|
|
|
monkeypatch.setattr("py.utils.file_utils.calculate_autov3", fake_calculate_autov3)
|
|
parser = _SpyCivitaiParser({"loras": [], "gen_params": {"prompt": "test prompt"}})
|
|
|
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
harness.analysis._recipe_parser_factory = _SpyFactory(parser)
|
|
# The shared builder registers both the sha256 and the stored autov3 keys.
|
|
harness.scanner.local_hash_cache = {
|
|
model_hash.lower(): parent_item,
|
|
"existing123456": parent_item,
|
|
}
|
|
harness.scanner._lora_scanner = _RawDataLoraScanner([parent_item])
|
|
|
|
response = await _post_create_from_example(harness, model_hash)
|
|
payload = await response.json()
|
|
|
|
assert response.status == 200, payload
|
|
# Stale-state guard: no recompute when autov3 is already stored.
|
|
assert autov3_calls == []
|
|
assert parser.received_cache is harness.scanner.local_hash_cache
|
|
assert parser.received_cache is not None
|
|
assert parser.received_cache["existing123456"] is parent_item
|