diff --git a/docs/architecture/recipe_routes.md b/docs/architecture/recipe_routes.md new file mode 100644 index 00000000..28684fad --- /dev/null +++ b/docs/architecture/recipe_routes.md @@ -0,0 +1,50 @@ +# Recipe route scaffolding + +The recipe HTTP stack is being migrated to mirror the shared model routing +architecture. The first phase extracts the registrar/controller scaffolding so +future handler sets can plug into a stable surface area. The stack now mirrors +the same separation of concerns described in +`docs/architecture/model_routes.md`: + +```mermaid +graph TD + subgraph HTTP + A[RecipeRouteRegistrar] -->|binds| B[BaseRecipeRoutes handler owner] + end + subgraph Application + B --> C[Recipe handler set] + C --> D[Async handlers] + D --> E[Services / scanners] + end +``` + +## Responsibilities + +| Layer | Module(s) | Responsibility | +| --- | --- | --- | +| Registrar | `py/routes/recipe_route_registrar.py` | Declarative list of every recipe endpoint and helper that binds them to an `aiohttp` application. | +| Base controller | `py/routes/base_recipe_routes.py` | Lazily resolves shared services, registers the server-side i18n filter exactly once, pre-warms caches on startup, and exposes a `{handler_name: coroutine}` mapping used by the registrar. | +| Handler set (upcoming) | `py/routes/handlers/recipe_handlers.py` (planned) | Will group HTTP handlers by concern (page rendering, listings, mutations, queries, sharing) and surface them to `BaseRecipeRoutes.get_handler_owner()`. | + +`RecipeRoutes` subclasses the base controller to keep compatibility with the +existing monolithic handlers. Once the handler set is extracted the subclass +will simply provide the concrete owner returned by `get_handler_owner()`. + +## High-level test baseline + +The new smoke suite in `tests/routes/test_recipe_route_scaffolding.py` +guarantees the registrar/controller contract remains intact: + +* `BaseRecipeRoutes.attach_dependencies` resolves registry services only once + and protects the i18n filter from duplicate registration. +* Startup hooks are appended exactly once so cache pre-warming and dependency + resolution run during application boot. +* `BaseRecipeRoutes.to_route_mapping()` uses the handler owner as the source of + callables, enabling the upcoming handler set without touching the registrar. +* `RecipeRouteRegistrar` binds every declarative route to the aiohttp router. +* `RecipeRoutes.setup_routes` wires the registrar and startup hooks together so + future refactors can swap in the handler set without editing callers. + +These guardrails mirror the expectations in the model route architecture and +provide confidence that future refactors can focus on handlers and use cases +without breaking HTTP wiring. diff --git a/py/routes/base_recipe_routes.py b/py/routes/base_recipe_routes.py new file mode 100644 index 00000000..e2b726da --- /dev/null +++ b/py/routes/base_recipe_routes.py @@ -0,0 +1,109 @@ +"""Base infrastructure shared across recipe routes.""" +from __future__ import annotations + +import logging +from typing import Callable, Mapping + +import jinja2 +from aiohttp import web + +from ..config import config +from ..services.server_i18n import server_i18n +from ..services.service_registry import ServiceRegistry +from ..services.settings_manager import settings +from .recipe_route_registrar import ROUTE_DEFINITIONS + +logger = logging.getLogger(__name__) + + +class BaseRecipeRoutes: + """Common dependency and startup wiring for recipe routes.""" + + _HANDLER_NAMES: tuple[str, ...] = tuple( + definition.handler_name for definition in ROUTE_DEFINITIONS + ) + + def __init__(self) -> None: + self.recipe_scanner = None + self.lora_scanner = None + self.civitai_client = None + self.settings = settings + self.server_i18n = server_i18n + self.template_env = jinja2.Environment( + loader=jinja2.FileSystemLoader(config.templates_path), + autoescape=True, + ) + + self._i18n_registered = False + self._startup_hooks_registered = False + self._handler_mapping: dict[str, Callable] | None = None + + async def attach_dependencies(self, app: web.Application | None = None) -> None: + """Resolve shared services from the registry.""" + + await self._ensure_services() + self._ensure_i18n_filter() + + async def ensure_dependencies_ready(self) -> None: + """Ensure dependencies are available for request handlers.""" + + if self.recipe_scanner is None or self.civitai_client is None: + await self.attach_dependencies() + + def register_startup_hooks(self, app: web.Application) -> None: + """Register startup hooks once for dependency wiring.""" + + if self._startup_hooks_registered: + return + + app.on_startup.append(self.attach_dependencies) + app.on_startup.append(self.prewarm_cache) + self._startup_hooks_registered = True + + async def prewarm_cache(self, app: web.Application | None = None) -> None: + """Pre-load recipe and LoRA caches on startup.""" + + try: + await self.attach_dependencies(app) + + if self.lora_scanner is not None: + await self.lora_scanner.get_cached_data() + hash_index = getattr(self.lora_scanner, "_hash_index", None) + if hash_index is not None and hasattr(hash_index, "_hash_to_path"): + _ = len(hash_index._hash_to_path) + + if self.recipe_scanner is not None: + await self.recipe_scanner.get_cached_data(force_refresh=True) + except Exception as exc: + logger.error("Error pre-warming recipe cache: %s", exc, exc_info=True) + + def to_route_mapping(self) -> Mapping[str, Callable]: + """Return a mapping of handler name to coroutine for registrar binding.""" + + if self._handler_mapping is None: + owner = self.get_handler_owner() + self._handler_mapping = { + name: getattr(owner, name) for name in self._HANDLER_NAMES + } + return self._handler_mapping + + # Internal helpers ------------------------------------------------- + + async def _ensure_services(self) -> None: + if self.recipe_scanner is None: + self.recipe_scanner = await ServiceRegistry.get_recipe_scanner() + self.lora_scanner = getattr(self.recipe_scanner, "_lora_scanner", None) + + if self.civitai_client is None: + self.civitai_client = await ServiceRegistry.get_civitai_client() + + def _ensure_i18n_filter(self) -> None: + if not self._i18n_registered: + self.template_env.filters["t"] = self.server_i18n.create_template_filter() + self._i18n_registered = True + + def get_handler_owner(self): + """Return the object supplying bound handler coroutines.""" + + return self + diff --git a/py/routes/recipe_route_registrar.py b/py/routes/recipe_route_registrar.py new file mode 100644 index 00000000..471edf19 --- /dev/null +++ b/py/routes/recipe_route_registrar.py @@ -0,0 +1,64 @@ +"""Route registrar for recipe endpoints.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, Mapping + +from aiohttp import web + + +@dataclass(frozen=True) +class RouteDefinition: + """Declarative definition for a recipe HTTP route.""" + + method: str + path: str + handler_name: str + + +ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( + RouteDefinition("GET", "/loras/recipes", "render_page"), + RouteDefinition("GET", "/api/lm/recipes", "list_recipes"), + RouteDefinition("GET", "/api/lm/recipe/{recipe_id}", "get_recipe"), + RouteDefinition("POST", "/api/lm/recipes/analyze-image", "analyze_uploaded_image"), + RouteDefinition("POST", "/api/lm/recipes/analyze-local-image", "analyze_local_image"), + RouteDefinition("POST", "/api/lm/recipes/save", "save_recipe"), + RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"), + RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"), + RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"), + RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share", "share_recipe"), + RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share/download", "download_shared_recipe"), + RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"), + RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"), + RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"), + RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"), + RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"), + RouteDefinition("POST", "/api/lm/recipes/save-from-widget", "save_recipe_from_widget"), + RouteDefinition("GET", "/api/lm/recipes/for-lora", "get_recipes_for_lora"), + RouteDefinition("GET", "/api/lm/recipes/scan", "scan_recipes"), +) + + +class RecipeRouteRegistrar: + """Bind declarative recipe definitions to an aiohttp router.""" + + _METHOD_MAP = { + "GET": "add_get", + "POST": "add_post", + "PUT": "add_put", + "DELETE": "add_delete", + } + + def __init__(self, app: web.Application) -> None: + self._app = app + + def register_routes(self, handler_lookup: Mapping[str, Callable[[web.Request], object]]) -> None: + for definition in ROUTE_DEFINITIONS: + handler = handler_lookup[definition.handler_name] + self._bind_route(definition.method, definition.path, handler) + + def _bind_route(self, method: str, path: str, handler: Callable) -> None: + add_method_name = self._METHOD_MAP[method.upper()] + add_method = getattr(self._app.router, add_method_name) + add_method(path, handler) + diff --git a/py/routes/recipe_routes.py b/py/routes/recipe_routes.py index 21214d99..55208552 100644 --- a/py/routes/recipe_routes.py +++ b/py/routes/recipe_routes.py @@ -1,7 +1,6 @@ import os import time import base64 -import jinja2 import numpy as np from PIL import Image import io @@ -12,20 +11,18 @@ import tempfile import json import asyncio import sys + +from .base_recipe_routes import BaseRecipeRoutes +from .recipe_route_registrar import RecipeRouteRegistrar from ..utils.exif_utils import ExifUtils from ..recipes import RecipeParserFactory from ..utils.constants import CARD_PREVIEW_WIDTH - -from ..services.settings_manager import settings -from ..services.server_i18n import server_i18n from ..config import config +from ..services.downloader import get_downloader # Check if running in standalone mode standalone_mode = os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0" -from ..services.service_registry import ServiceRegistry # Add ServiceRegistry import -from ..services.downloader import get_downloader - # Only import MetadataRegistry in non-standalone mode if not standalone_mode: # Import metadata_collector functions and classes conditionally @@ -35,111 +32,30 @@ if not standalone_mode: logger = logging.getLogger(__name__) -class RecipeRoutes: - """API route handlers for Recipe management""" - def __init__(self): - # Initialize service references as None, will be set during async init - self.recipe_scanner = None - self.civitai_client = None - self.template_env = jinja2.Environment( - loader=jinja2.FileSystemLoader(config.templates_path), - autoescape=True - ) - - # Pre-warm the cache - self._init_cache_task = None - - async def init_services(self): - """Initialize services from ServiceRegistry""" - self.recipe_scanner = await ServiceRegistry.get_recipe_scanner() - self.civitai_client = await ServiceRegistry.get_civitai_client() +class RecipeRoutes(BaseRecipeRoutes): + """API route handlers for Recipe management.""" @classmethod def setup_routes(cls, app: web.Application): - """Register API routes""" + """Register API routes using the declarative registrar.""" + routes = cls() - app.router.add_get('/loras/recipes', routes.handle_recipes_page) + registrar = RecipeRouteRegistrar(app) + registrar.register_routes(routes.to_route_mapping()) + routes.register_startup_hooks(app) - app.router.add_get('/api/lm/recipes', routes.get_recipes) - app.router.add_get('/api/lm/recipe/{recipe_id}', routes.get_recipe_detail) - app.router.add_post('/api/lm/recipes/analyze-image', routes.analyze_recipe_image) - app.router.add_post('/api/lm/recipes/analyze-local-image', routes.analyze_local_image) - app.router.add_post('/api/lm/recipes/save', routes.save_recipe) - app.router.add_delete('/api/lm/recipe/{recipe_id}', routes.delete_recipe) - - # Add new filter-related endpoints - app.router.add_get('/api/lm/recipes/top-tags', routes.get_top_tags) - app.router.add_get('/api/lm/recipes/base-models', routes.get_base_models) - - # Add new sharing endpoints - app.router.add_get('/api/lm/recipe/{recipe_id}/share', routes.share_recipe) - app.router.add_get('/api/lm/recipe/{recipe_id}/share/download', routes.download_shared_recipe) - - # Add new endpoint for getting recipe syntax - app.router.add_get('/api/lm/recipe/{recipe_id}/syntax', routes.get_recipe_syntax) - - # Add new endpoint for updating recipe metadata (name, tags and source_path) - app.router.add_put('/api/lm/recipe/{recipe_id}/update', routes.update_recipe) - - # Add new endpoint for reconnecting deleted LoRAs - app.router.add_post('/api/lm/recipe/lora/reconnect', routes.reconnect_lora) - - # Add new endpoint for finding duplicate recipes - app.router.add_get('/api/lm/recipes/find-duplicates', routes.find_duplicates) - - # Add new endpoint for bulk deletion of recipes - app.router.add_post('/api/lm/recipes/bulk-delete', routes.bulk_delete) - - # Start cache initialization - app.on_startup.append(routes._init_cache) - - app.router.add_post('/api/lm/recipes/save-from-widget', routes.save_recipe_from_widget) - - # Add route to get recipes for a specific Lora - app.router.add_get('/api/lm/recipes/for-lora', routes.get_recipes_for_lora) - - # Add new endpoint for scanning and rebuilding the recipe cache - app.router.add_get('/api/lm/recipes/scan', routes.scan_recipes) - - async def _init_cache(self, app): - """Initialize cache on startup""" + async def render_page(self, request: web.Request) -> web.Response: + """Handle GET /loras/recipes request.""" try: - # Initialize services first - await self.init_services() - - # Now that services are initialized, get the lora scanner - lora_scanner = self.recipe_scanner._lora_scanner - - # Get lora cache to ensure it's initialized - lora_cache = await lora_scanner.get_cached_data() - - # Verify hash index is built - if hasattr(lora_scanner, '_hash_index'): - hash_index_size = len(lora_scanner._hash_index._hash_to_path) if hasattr(lora_scanner._hash_index, '_hash_to_path') else 0 - - # Now that lora scanner is initialized, initialize recipe cache - await self.recipe_scanner.get_cached_data(force_refresh=True) - except Exception as e: - logger.error(f"Error pre-warming recipe cache: {e}", exc_info=True) + await self.ensure_dependencies_ready() - async def handle_recipes_page(self, request: web.Request) -> web.Response: - """Handle GET /loras/recipes request""" - try: - # Ensure services are initialized - await self.init_services() - # 获取用户语言设置 - user_language = settings.get('language', 'en') - + user_language = self.settings.get('language', 'en') + # 设置服务端i18n语言 - server_i18n.set_locale(user_language) - - # 为模板环境添加i18n过滤器 - if not hasattr(self.template_env, '_i18n_filter_added'): - self.template_env.filters['t'] = server_i18n.create_template_filter() - self.template_env._i18n_filter_added = True - + self.server_i18n.set_locale(user_language) + # Skip initialization check and directly try to get cached data try: # Recipe scanner will initialize cache if needed @@ -148,10 +64,10 @@ class RecipeRoutes: rendered = template.render( recipes=[], # Frontend will load recipes via API is_initializing=False, - settings=settings, + settings=self.settings, request=request, # 添加服务端翻译函数 - t=server_i18n.get_translation, + t=self.server_i18n.get_translation, ) except Exception as cache_error: logger.error(f"Error loading recipe cache data: {cache_error}") @@ -159,10 +75,10 @@ class RecipeRoutes: template = self.template_env.get_template('recipes.html') rendered = template.render( is_initializing=True, - settings=settings, + settings=self.settings, request=request, # 添加服务端翻译函数 - t=server_i18n.get_translation, + t=self.server_i18n.get_translation, ) logger.info("Recipe cache error, returning initialization page") @@ -178,11 +94,11 @@ class RecipeRoutes: status=500 ) - async def get_recipes(self, request: web.Request) -> web.Response: - """API endpoint for getting paginated recipes""" + async def list_recipes(self, request: web.Request) -> web.Response: + """API endpoint for getting paginated recipes.""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() # Get query parameters with defaults page = int(request.query.get('page', '1')) @@ -250,11 +166,11 @@ class RecipeRoutes: logger.error(f"Error retrieving recipes: {e}", exc_info=True) return web.json_response({"error": str(e)}, status=500) - async def get_recipe_detail(self, request: web.Request) -> web.Response: - """Get detailed information about a specific recipe""" + async def get_recipe(self, request: web.Request) -> web.Response: + """Get detailed information about a specific recipe.""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() recipe_id = request.match_info['recipe_id'] @@ -305,12 +221,12 @@ class RecipeRoutes: from datetime import datetime return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S') - async def analyze_recipe_image(self, request: web.Request) -> web.Response: - """Analyze an uploaded image or URL for recipe metadata""" + async def analyze_uploaded_image(self, request: web.Request) -> web.Response: + """Analyze an uploaded image or URL for recipe metadata.""" temp_path = None try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() # Check if request contains multipart data (image) or JSON data (url) content_type = request.headers.get('Content-Type', '') @@ -480,7 +396,7 @@ class RecipeRoutes: """Analyze a local image file for recipe metadata""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() # Get JSON data from request data = await request.json() @@ -573,7 +489,7 @@ class RecipeRoutes: """Save a recipe to the recipes folder""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() reader = await request.multipart() @@ -779,7 +695,7 @@ class RecipeRoutes: """Delete a recipe by ID""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() recipe_id = request.match_info['recipe_id'] @@ -829,7 +745,7 @@ class RecipeRoutes: """Get top tags used in recipes""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() # Get limit parameter with default limit = int(request.query.get('limit', '20')) @@ -864,7 +780,7 @@ class RecipeRoutes: """Get base models used in recipes""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() # Get all recipes from cache cache = await self.recipe_scanner.get_cached_data() @@ -895,7 +811,7 @@ class RecipeRoutes: """Process a recipe image for sharing by adding metadata to EXIF""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() recipe_id = request.match_info['recipe_id'] @@ -957,7 +873,7 @@ class RecipeRoutes: """Serve a processed recipe image for download""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() recipe_id = request.match_info['recipe_id'] @@ -1016,7 +932,7 @@ class RecipeRoutes: """Save a recipe from the LoRAs widget""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() # Get metadata using the metadata collector instead of workflow parsing raw_metadata = get_metadata() @@ -1216,7 +1132,7 @@ class RecipeRoutes: """Generate recipe syntax for LoRAs in the recipe, looking up proper file names using hash_index""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() recipe_id = request.match_info['recipe_id'] @@ -1299,7 +1215,7 @@ class RecipeRoutes: """Update recipe metadata (name and tags)""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() recipe_id = request.match_info['recipe_id'] data = await request.json() @@ -1329,7 +1245,7 @@ class RecipeRoutes: """Reconnect a deleted LoRA in a recipe to a local LoRA file""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() # Parse request data data = await request.json() @@ -1438,7 +1354,7 @@ class RecipeRoutes: """Get recipes that use a specific Lora""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() lora_hash = request.query.get('hash') @@ -1487,7 +1403,7 @@ class RecipeRoutes: """API endpoint for scanning and rebuilding the recipe cache""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() # Force refresh the recipe cache logger.info("Manually triggering recipe cache rebuild") @@ -1508,7 +1424,7 @@ class RecipeRoutes: """Find all duplicate recipes based on fingerprints""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() # Get all duplicate recipes duplicate_groups = await self.recipe_scanner.find_all_duplicate_recipes() @@ -1566,7 +1482,7 @@ class RecipeRoutes: """Delete multiple recipes by ID""" try: # Ensure services are initialized - await self.init_services() + await self.ensure_dependencies_ready() # Parse request data data = await request.json() @@ -1650,3 +1566,9 @@ class RecipeRoutes: 'success': False, 'error': str(e) }, status=500) + + # Legacy method aliases retained for compatibility with existing imports. + handle_recipes_page = render_page + get_recipes = list_recipes + get_recipe_detail = get_recipe + analyze_recipe_image = analyze_uploaded_image diff --git a/tests/routes/test_recipe_route_scaffolding.py b/tests/routes/test_recipe_route_scaffolding.py new file mode 100644 index 00000000..1f0e723d --- /dev/null +++ b/tests/routes/test_recipe_route_scaffolding.py @@ -0,0 +1,229 @@ +"""Smoke tests for the recipe routing scaffolding. + +The cases keep the registrar/controller contract aligned with +``docs/architecture/recipe_routes.md`` so future refactors can focus on handler +logic. +""" + +from __future__ import annotations + +import asyncio +import importlib.util +import sys +import types +from collections import Counter +from pathlib import Path +from typing import Any, Awaitable, Callable, Dict + +import pytest +from aiohttp import web + + +REPO_ROOT = Path(__file__).resolve().parents[2] +PY_PACKAGE_PATH = REPO_ROOT / "py" + +spec = importlib.util.spec_from_file_location( + "py_local", + PY_PACKAGE_PATH / "__init__.py", + submodule_search_locations=[str(PY_PACKAGE_PATH)], +) +py_local = importlib.util.module_from_spec(spec) +assert spec.loader is not None +spec.loader.exec_module(py_local) +sys.modules.setdefault("py_local", py_local) + +base_routes_module = importlib.import_module("py_local.routes.base_recipe_routes") +recipe_routes_module = importlib.import_module("py_local.routes.recipe_routes") +registrar_module = importlib.import_module("py_local.routes.recipe_route_registrar") + + +@pytest.fixture(autouse=True) +def reset_service_registry(monkeypatch: pytest.MonkeyPatch): + """Ensure each test starts from a clean registry state.""" + + services_module = importlib.import_module("py_local.services.service_registry") + registry = services_module.ServiceRegistry + previous_services = dict(registry._services) + previous_locks = dict(registry._locks) + registry._services.clear() + registry._locks.clear() + try: + yield + finally: + registry._services = previous_services + registry._locks = previous_locks + + +def _make_stub_scanner(): + class _StubScanner: + def __init__(self): + self._cache = types.SimpleNamespace() + + async def _lora_get_cached_data(): # pragma: no cover - smoke hook + return None + + self._lora_scanner = types.SimpleNamespace( + get_cached_data=_lora_get_cached_data, + _hash_index=types.SimpleNamespace(_hash_to_path={}), + ) + + async def get_cached_data(self, force_refresh: bool = False): + return self._cache + + return _StubScanner() + + +def test_attach_dependencies_resolves_services_once(monkeypatch: pytest.MonkeyPatch): + base_module = base_routes_module + services_module = importlib.import_module("py_local.services.service_registry") + registry = services_module.ServiceRegistry + server_i18n = importlib.import_module("py_local.services.server_i18n").server_i18n + + scanner = _make_stub_scanner() + civitai_client = object() + filter_calls = Counter() + + async def fake_get_recipe_scanner(): + return scanner + + async def fake_get_civitai_client(): + return civitai_client + + def fake_create_filter(): + filter_calls["create_filter"] += 1 + return object() + + monkeypatch.setattr(registry, "get_recipe_scanner", fake_get_recipe_scanner) + monkeypatch.setattr(registry, "get_civitai_client", fake_get_civitai_client) + monkeypatch.setattr(server_i18n, "create_template_filter", fake_create_filter) + + async def scenario(): + routes = base_module.BaseRecipeRoutes() + + await routes.attach_dependencies() + await routes.attach_dependencies() # idempotent + + assert routes.recipe_scanner is scanner + assert routes.lora_scanner is scanner._lora_scanner + assert routes.civitai_client is civitai_client + assert routes.template_env.filters["t"] is not None + assert filter_calls["create_filter"] == 1 + + asyncio.run(scenario()) + + +def test_register_startup_hooks_appends_once(): + routes = base_routes_module.BaseRecipeRoutes() + + app = web.Application() + routes.register_startup_hooks(app) + routes.register_startup_hooks(app) + + startup_bound_to_routes = [ + callback for callback in app.on_startup if getattr(callback, "__self__", None) is routes + ] + + assert routes.attach_dependencies in startup_bound_to_routes + assert routes.prewarm_cache in startup_bound_to_routes + assert len(startup_bound_to_routes) == 2 + + +def test_to_route_mapping_uses_handler_owner(monkeypatch: pytest.MonkeyPatch): + class DummyOwner: + async def render_page(self, request): + return web.Response(text="ok") + + async def list_recipes(self, request): # pragma: no cover - invoked via mapping + return web.json_response({}) + + class DummyRoutes(base_routes_module.BaseRecipeRoutes): + def get_handler_owner(self): # noqa: D401 - simple override for test + return DummyOwner() + + monkeypatch.setattr( + base_routes_module.BaseRecipeRoutes, + "_HANDLER_NAMES", + ("render_page", "list_recipes"), + ) + + routes = DummyRoutes() + mapping = routes.to_route_mapping() + + assert set(mapping.keys()) == {"render_page", "list_recipes"} + assert asyncio.iscoroutinefunction(mapping["render_page"]) + # Cached mapping reused on subsequent calls + assert routes.to_route_mapping() is mapping + + +def test_recipe_route_registrar_binds_every_route(): + class FakeRouter: + def __init__(self): + self.calls: list[tuple[str, str, Callable[..., Awaitable[Any]]]] = [] + + def add_get(self, path, handler): + self.calls.append(("GET", path, handler)) + + def add_post(self, path, handler): + self.calls.append(("POST", path, handler)) + + def add_put(self, path, handler): + self.calls.append(("PUT", path, handler)) + + def add_delete(self, path, handler): + self.calls.append(("DELETE", path, handler)) + + class FakeApp: + def __init__(self): + self.router = FakeRouter() + + app = FakeApp() + registrar = registrar_module.RecipeRouteRegistrar(app) + + handler_mapping = { + definition.handler_name: object() + for definition in registrar_module.ROUTE_DEFINITIONS + } + + registrar.register_routes(handler_mapping) + + assert { + (method, path) + for method, path, _ in app.router.calls + } == {(d.method, d.path) for d in registrar_module.ROUTE_DEFINITIONS} + + +def test_recipe_routes_setup_routes_uses_registrar(monkeypatch: pytest.MonkeyPatch): + registered_mappings: list[Dict[str, Callable[..., Awaitable[Any]]]] = [] + + class DummyRegistrar: + def __init__(self, app): + self.app = app + + def register_routes(self, mapping): + registered_mappings.append(mapping) + + monkeypatch.setattr(recipe_routes_module, "RecipeRouteRegistrar", DummyRegistrar) + + expected_mapping = {name: object() for name in ("render_page", "list_recipes")} + + def fake_to_route_mapping(self): + return expected_mapping + + monkeypatch.setattr(base_routes_module.BaseRecipeRoutes, "to_route_mapping", fake_to_route_mapping) + monkeypatch.setattr( + base_routes_module.BaseRecipeRoutes, + "_HANDLER_NAMES", + tuple(expected_mapping.keys()), + ) + + app = web.Application() + recipe_routes_module.RecipeRoutes.setup_routes(app) + + assert registered_mappings == [expected_mapping] + recipe_callbacks = { + cb + for cb in app.on_startup + if isinstance(getattr(cb, "__self__", None), recipe_routes_module.RecipeRoutes) + } + assert {type(cb.__self__) for cb in recipe_callbacks} == {recipe_routes_module.RecipeRoutes} + assert {cb.__name__ for cb in recipe_callbacks} == {"attach_dependencies", "prewarm_cache"}