Files
MajoorWaldi-ComfyUI-Majoor-…/tests/features/test_misc_routes.py
T

397 lines
14 KiB
Python

import datetime
import json
from typing import Any, cast
import pytest
from aiohttp import web
from aiohttp.test_utils import make_mocked_request
from mjr_am_backend.routes.handlers import calendar as cal_mod
from mjr_am_backend.routes.handlers import metadata as meta_mod
from mjr_am_backend.routes.handlers import plugins as plugins_mod
from mjr_am_backend.routes.handlers import releases as rel_mod
from mjr_am_backend.routes.handlers import stacks as stacks_mod
from mjr_am_backend.shared import Result
def _app_with(register_fn):
app = web.Application()
routes = web.RouteTableDef()
register_fn(routes)
app.add_routes(routes)
return app
async def _call(app, method: str, path: str):
req = make_mocked_request(method, path, app=app)
match = await app.router.resolve(req)
req._match_info = match
resp = await match.handler(req)
return json.loads(resp.text), resp.status
@pytest.mark.asyncio
async def test_releases_route_success_with_fake_github(monkeypatch) -> None:
class _Resp:
def __init__(self, payload):
self.status = 200
self._payload = payload
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def json(self):
return self._payload
async def text(self):
return "ok"
class _Session:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
def get(self, url, headers=None, timeout=None):
_ = (headers, timeout)
if url.endswith("/tags?per_page=100"):
return _Resp([{"name": "v1"}])
return _Resp([{"name": "main"}])
monkeypatch.setattr(rel_mod, "ClientSession", lambda: _Session())
app = _app_with(rel_mod.register_releases_routes)
req = make_mocked_request("GET", "/mjr/am/releases", app=app)
match = await app.router.resolve(req)
resp = await match.handler(req)
body = json.loads(resp.text)
assert body.get("ok") is True
data = body.get("data") or {}
assert data.get("tags") == ["v1"]
assert data.get("branches") == ["main"]
@pytest.mark.asyncio
async def test_calendar_invalid_month_and_scope(monkeypatch) -> None:
async def _require_services():
return {"index": object()}, None
monkeypatch.setattr(cal_mod, "_require_services", _require_services)
app = _app_with(cal_mod.register_calendar_routes)
req = make_mocked_request("GET", "/mjr/am/date-histogram?month=bad", app=app)
match = await app.router.resolve(req)
resp = await match.handler(req)
body = json.loads(resp.text)
assert body.get("code") == "INVALID_INPUT"
req2 = make_mocked_request("GET", "/mjr/am/date-histogram?month=2026-01&scope=zzz", app=app)
match2 = await app.router.resolve(req2)
resp2 = await match2.handler(req2)
body2 = json.loads(resp2.text)
assert body2.get("code") == "DB_ERROR"
def test_calendar_month_bounds_use_utc() -> None:
res = cal_mod._month_bounds("2026-01")
assert res.ok
assert res.data is not None
start, end = res.data
assert start == int(datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc).timestamp())
assert end == int(datetime.datetime(2026, 2, 1, tzinfo=datetime.timezone.utc).timestamp())
@pytest.mark.asyncio
async def test_calendar_success_output_scope(monkeypatch) -> None:
captured: dict[str, dict[str, Any] | None] = {"filters": None}
class _Index:
async def date_histogram_scoped(self, roots, month_start, month_end, filters=None):
_ = (roots, month_start, month_end)
captured["filters"] = filters
return Result.Ok({"2026-01-01": 2})
async def _require_services():
return {"index": _Index()}, None
monkeypatch.setattr(cal_mod, "_require_services", _require_services)
app = _app_with(cal_mod.register_calendar_routes)
req = make_mocked_request(
"GET",
"/mjr/am/date-histogram?month=2026-01&scope=output&subfolder=animals&min_size_mb=2&max_size_mb=1&workflow_type=t2i&date_exact=2026-01-15",
app=app,
)
match = await app.router.resolve(req)
resp = await match.handler(req)
body = json.loads(resp.text)
assert body.get("ok") is True
filters = captured["filters"] or {}
assert filters.get("subfolder") == "animals"
assert filters.get("workflow_type") == "T2I"
assert filters.get("max_size_bytes") == filters.get("min_size_bytes")
mtime_start = cast(int, filters.get("mtime_start"))
mtime_end = cast(int, filters.get("mtime_end"))
assert mtime_start < mtime_end
@pytest.mark.asyncio
async def test_metadata_route_invalid_and_rate_limited(monkeypatch) -> None:
async def _require_services():
return {"metadata": object()}, None
monkeypatch.setattr(meta_mod, "_require_services", _require_services)
monkeypatch.setattr(meta_mod, "_check_rate_limit", lambda *args, **kwargs: (False, 4))
app = _app_with(meta_mod.register_metadata_routes)
req = make_mocked_request("GET", "/mjr/am/metadata?type=output&filename=x.png", app=app)
match = await app.router.resolve(req)
resp = await match.handler(req)
body = json.loads(resp.text)
assert body.get("code") == "RATE_LIMITED"
monkeypatch.setattr(meta_mod, "_check_rate_limit", lambda *args, **kwargs: (True, None))
req2 = make_mocked_request("GET", "/mjr/am/metadata?type=bad&filename=x.png", app=app)
match2 = await app.router.resolve(req2)
resp2 = await match2.handler(req2)
body2 = json.loads(resp2.text)
assert body2.get("code") == "INVALID_INPUT"
@pytest.mark.asyncio
async def test_plugins_routes_success_and_errors(monkeypatch) -> None:
class _PluginManager:
def __init__(self) -> None:
self.enabled: list[str] = []
def list_plugins(self):
return [{"name": "demo", "enabled": False}]
def enable_plugin(self, name):
self.enabled.append(name)
return name == "demo"
async def reload(self):
return 3
class _Metadata:
plugin_manager = _PluginManager()
async def _require_services():
return {"metadata": _Metadata()}, None
monkeypatch.setattr(plugins_mod, "_require_services", _require_services)
monkeypatch.setattr(plugins_mod, "_csrf_error", lambda _request: None)
monkeypatch.setattr(plugins_mod, "_require_write_access", lambda _request: Result.Ok(True))
monkeypatch.setattr(plugins_mod, "_check_rate_limit", lambda *args, **kwargs: (True, None))
app = _app_with(plugins_mod.register_plugin_routes)
body, status = await _call(app, "GET", "/mjr/am/plugins/list")
assert status == 200
assert body["ok"] is True
assert body["data"][0]["name"] == "demo"
body, _status = await _call(app, "POST", "/mjr/am/plugins/demo/enable")
assert body["ok"] is True
assert body["data"] == {"enabled": True}
body, _status = await _call(app, "POST", "/mjr/am/plugins/missing/enable")
assert body["code"] == "NOT_FOUND"
body, _status = await _call(app, "POST", "/mjr/am/plugins/reload")
assert body["ok"] is True
assert body["data"] == {"reloaded": 3}
@pytest.mark.asyncio
async def test_plugins_mutations_require_csrf(monkeypatch) -> None:
async def _require_services():
raise AssertionError("services should not be loaded when CSRF fails")
monkeypatch.setattr(plugins_mod, "_require_services", _require_services)
monkeypatch.setattr(plugins_mod, "_csrf_error", lambda _request: "Missing anti-CSRF header")
monkeypatch.setattr(plugins_mod, "_require_write_access", lambda _request: Result.Ok(True))
monkeypatch.setattr(plugins_mod, "_check_rate_limit", lambda *args, **kwargs: (True, None))
app = _app_with(plugins_mod.register_plugin_routes)
body, _status = await _call(app, "POST", "/mjr/am/plugins/demo/enable")
assert body["code"] == "CSRF"
body, _status = await _call(app, "POST", "/mjr/am/plugins/reload")
assert body["code"] == "CSRF"
@pytest.mark.asyncio
async def test_plugins_routes_report_unavailable_manager(monkeypatch) -> None:
async def _require_services():
return {"metadata": object()}, None
monkeypatch.setattr(plugins_mod, "_require_services", _require_services)
app = _app_with(plugins_mod.register_plugin_routes)
body, _status = await _call(app, "GET", "/mjr/am/plugins/list")
assert body["code"] == "NOT_AVAILABLE"
@pytest.mark.asyncio
async def test_stacks_read_routes_and_invalid_ids(monkeypatch) -> None:
class _Stacks:
async def list_stacks(self, *, limit, offset, include_total):
return Result.Ok({"limit": limit, "offset": offset, "include_total": include_total})
async def get_stack_by_job_id(self, job_id):
return Result.Ok({"job_id": job_id})
async def get_stack(self, stack_id):
return Result.Ok({"id": stack_id})
async def get_members(self, stack_id, limit=None):
return Result.Ok([{"stack_id": stack_id, "limit": limit}])
services = {"_stacks_service": _Stacks()}
async def _require_services():
return services, None
monkeypatch.setattr(stacks_mod, "_require_services", _require_services)
app = _app_with(stacks_mod.register_stacks_routes)
body, _status = await _call(app, "GET", "/mjr/am/stacks?limit=bad&offset=bad&include_total=0")
assert body["data"] == {"limit": 50, "offset": 0, "include_total": False}
body, _status = await _call(app, "GET", "/mjr/am/stacks/by-job/job-1")
assert body["data"] == {"job_id": "job-1"}
body, _status = await _call(app, "GET", "/mjr/am/stacks/12")
assert body["data"] == {"id": 12}
body, _status = await _call(app, "GET", "/mjr/am/stacks/not-an-int")
assert body["code"] == "INVALID_INPUT"
body, _status = await _call(app, "GET", "/mjr/am/stacks/12/members")
assert body["data"] == [{"stack_id": 12, "limit": None}]
body, _status = await _call(app, "GET", "/mjr/am/stacks/12/members?limit=501")
assert body["data"] == [{"stack_id": 12, "limit": 501}]
body, _status = await _call(app, "GET", "/mjr/am/stacks/12/members?limit=bad")
assert body["code"] == "INVALID_INPUT"
@pytest.mark.asyncio
async def test_stacks_write_routes_validate_and_call_service(monkeypatch) -> None:
calls: list[tuple[str, object]] = []
class _Stacks:
async def set_cover(self, stack_id, cover_asset_id):
calls.append(("cover", (stack_id, cover_asset_id)))
return Result.Ok(True)
async def update_name(self, stack_id, name):
calls.append(("rename", (stack_id, name)))
return Result.Ok(True)
async def dissolve(self, stack_id):
calls.append(("dissolve", stack_id))
return Result.Ok(True)
async def merge_into(self, target_id, source_ids):
calls.append(("merge", (target_id, source_ids)))
return Result.Ok(True)
async def auto_stack_by_job_id(self, job_id):
calls.append(("auto_job", job_id))
return Result.Ok({"stacks": [{"id": 1}]})
async def auto_stack_by_workflow_hash(self, *, mtime_window_s):
calls.append(("auto_workflow", mtime_window_s))
return Result.Ok({"stacks": []})
body_queue = [
{},
{"cover_asset_id": 7},
{"name": "New name"},
{},
{"stack_id": 9},
{"target_stack_id": 1, "source_stack_ids": [2, 3]},
{"mode": "workflow_hash", "mtime_window_s": 44},
{"mode": "job_id", "job_id": "abc"},
]
async def _require_services():
return {"_stacks_service": _Stacks()}, None
async def _read_json(_request):
return body_queue.pop(0)
monkeypatch.setattr(stacks_mod, "_require_services", _require_services)
monkeypatch.setattr(stacks_mod, "_csrf_error", lambda _request: None)
monkeypatch.setattr(stacks_mod, "_require_write_access", lambda _request: Result.Ok(True))
monkeypatch.setattr(stacks_mod, "_read_json", _read_json)
monkeypatch.setattr(stacks_mod, "is_execution_grouping_enabled", lambda: True)
monkeypatch.setattr(stacks_mod, "_send_stack_update_event", lambda payload: calls.append(("event", payload)))
app = _app_with(stacks_mod.register_stacks_routes)
body, _status = await _call(app, "POST", "/mjr/am/stacks/1/cover")
assert body["code"] == "INVALID_INPUT"
body, _status = await _call(app, "POST", "/mjr/am/stacks/1/cover")
assert body["ok"] is True
body, _status = await _call(app, "POST", "/mjr/am/stacks/1/rename")
assert body["ok"] is True
body, _status = await _call(app, "POST", "/mjr/am/stacks/dissolve")
assert body["code"] == "INVALID_INPUT"
body, _status = await _call(app, "POST", "/mjr/am/stacks/dissolve")
assert body["ok"] is True
body, _status = await _call(app, "POST", "/mjr/am/stacks/merge")
assert body["ok"] is True
body, _status = await _call(app, "POST", "/mjr/am/stacks/auto-stack")
assert body["ok"] is True
body, _status = await _call(app, "POST", "/mjr/am/stacks/auto-stack")
assert body["ok"] is True
assert ("cover", (1, 7)) in calls
assert ("rename", (1, "New name")) in calls
assert ("dissolve", 9) in calls
assert ("merge", (1, [2, 3])) in calls
assert ("auto_workflow", 44) in calls
assert ("auto_job", "abc") in calls
assert any(name == "event" for name, _payload in calls)
@pytest.mark.asyncio
async def test_stacks_mutations_require_csrf(monkeypatch) -> None:
async def _require_services():
raise AssertionError("services should not be loaded when CSRF fails")
monkeypatch.setattr(stacks_mod, "_require_services", _require_services)
monkeypatch.setattr(stacks_mod, "_csrf_error", lambda _request: "Missing anti-CSRF header")
monkeypatch.setattr(stacks_mod, "_require_write_access", lambda _request: Result.Ok(True))
app = _app_with(stacks_mod.register_stacks_routes)
for path in (
"/mjr/am/stacks/1/cover",
"/mjr/am/stacks/1/rename",
"/mjr/am/stacks/dissolve",
"/mjr/am/stacks/merge",
"/mjr/am/stacks/auto-stack",
):
body, _status = await _call(app, "POST", path)
assert body["code"] == "CSRF"