test_http_routes_runtime_dependencies.py 14.1 KB
from __future__ import annotations

from pathlib import Path

from flask import Flask

from apps.web_api.interfaces import http_routes
from apps.web_api.interfaces.http_routes import HttpRouteDependencies, register_http_routes


class _FakeProcessRegistry:
    def contains(self, app_name: str) -> bool:
        return app_name in {"forum", "query"}

    def get_port(self, app_name: str) -> int | None:
        return {"query": 18503, "forum": None}.get(app_name)

    def status_snapshot(self, include_output_lines: bool = False) -> dict[str, object]:
        return {
            "forum": {"status": "stopped", "port": None, "output_lines": 0},
            "query": {"status": "running", "port": 18503, "output_lines": 3},
        }


class _FakeSystemStateRegistry:
    def snapshot(self) -> dict[str, bool]:
        return {"started": False, "starting": False}


class _FakeSystemLifecycle:
    def start_system(self) -> tuple[dict[str, object], int]:
        return {"success": True}, 200

    def shutdown_system(self, *, cleanup_timeout: float = 6.0) -> tuple[dict[str, object], int]:
        return {"success": True}, 200


def _build_dependencies(captured: dict[str, object]) -> HttpRouteDependencies:
    log_dir = Path("runtime-logs")
    process_registry = _FakeProcessRegistry()
    system_state_registry = _FakeSystemStateRegistry()

    def socket_emit(event: str, payload: dict[str, object]) -> None:
        captured.setdefault("socket_emits", []).append((event, payload))

    captured["socket_emit"] = socket_emit

    def start_forum_engine(*, emit_output):
        captured.setdefault("start_forum_engine", []).append(emit_output)
        return True

    def stop_forum_engine(*, emit_output):
        captured.setdefault("stop_forum_engine", []).append(emit_output)

    def get_forum_output(*, log_dir: Path):
        captured["forum_output_log_dir"] = log_dir
        return {"success": True, "output": ["forum-output"]}

    def get_forum_log_payload(*, log_dir: Path):
        captured["forum_log_payload_log_dir"] = log_dir
        return {"success": True, "log_lines": ["forum-log"]}

    def get_forum_log_history(*, log_dir: Path, start_position: int, max_lines: int):
        captured["forum_log_history_call"] = {
            "log_dir": log_dir,
            "start_position": start_position,
            "max_lines": max_lines,
        }
        return {"success": True, "log_lines": ["forum-history"], "position": 12, "has_more": False}

    def read_log(log_dir: Path, app_name: str, tail_lines: int | None = None):
        captured["read_log_call"] = {
            "log_dir": log_dir,
            "app_name": app_name,
            "tail_lines": tail_lines,
        }
        return ["query-log"]

    def write_log(log_dir: Path, app_name: str, line: str):
        captured["write_log_call"] = {
            "log_dir": log_dir,
            "app_name": app_name,
            "line": line,
        }

    def submit_search_request(*, payload):
        captured.setdefault("submit_search_request", []).append({"payload": payload})
        task_id = str(payload.get("research_task_id", ""))
        query = str(payload.get("query", "")).strip()
        if not query:
            return {"success": False, "message": "鎼滅储鏌ヨ涓嶈兘涓虹┖"}, 200
        return {"success": True, "accepted": True, "message": "ok", "task_id": task_id, "query": query}, 200

    def get_process_status(*, include_output_lines: bool = False):
        captured.setdefault("get_process_status", []).append(
            {"include_output_lines": include_output_lines}
        )
        return process_registry.status_snapshot(include_output_lines=include_output_lines)

    def get_system_status():
        captured.setdefault("get_system_status", []).append("called")
        state = system_state_registry.snapshot()
        return {
            "success": True,
            "started": state["started"],
            "starting": state["starting"],
        }

    def start_streamlit_app(app_name, script_path, port, *, log_dir, emit_output):
        captured.setdefault("start_streamlit_app", []).append(
            {
                "app_name": app_name,
                "script_path": script_path,
                "port": port,
                "log_dir": log_dir,
                "emit_output": emit_output,
            }
        )
        return True, "query started"

    def wait_for_app_startup(app_name: str, timeout: int):
        captured.setdefault("wait_for_app_startup", []).append((app_name, timeout))
        return True, "ready"

    def stop_streamlit_app(app_name: str):
        captured.setdefault("stop_streamlit_app", []).append(app_name)
        return True, "query stopped"

    return HttpRouteDependencies(
        frontend_dev_server_url=lambda: None,
        log_dir=log_dir,
        read_log=read_log,
        write_log=write_log,
        socket_emit=socket_emit,
        get_process_status=get_process_status,
        get_system_status=get_system_status,
        system_lifecycle=_FakeSystemLifecycle(),
        streamlit_scripts={"query": "apps/query/app.py"},
        start_streamlit_app=start_streamlit_app,
        wait_for_app_startup=wait_for_app_startup,
        stop_streamlit_app=stop_streamlit_app,
        start_forum_engine=start_forum_engine,
        stop_forum_engine=stop_forum_engine,
        get_forum_output=get_forum_output,
        get_forum_log_payload=get_forum_log_payload,
        get_forum_log_history=get_forum_log_history,
        submit_search_request=submit_search_request,
    )


def test_forum_runtime_routes_delegate_to_injected_dependencies():
    captured: dict[str, object] = {}
    app = Flask(__name__)
    register_http_routes(app, _build_dependencies(captured))
    client = app.test_client()

    start_response = client.get("/api/start/forum")
    stop_response = client.get("/api/stop/forum")
    output_response = client.get("/api/output/forum")
    log_response = client.get("/api/forum/log")
    history_response = client.post("/api/forum/log/history", json={"position": 7, "max_lines": 25})

    assert start_response.status_code == 200
    assert stop_response.status_code == 200
    assert output_response.get_json() == {"success": True, "output": ["forum-output"]}
    assert log_response.get_json() == {"success": True, "log_lines": ["forum-log"]}
    assert history_response.get_json() == {
        "success": True,
        "log_lines": ["forum-history"],
        "position": 12,
        "has_more": False,
    }
    assert captured["start_forum_engine"] == [captured["socket_emit"]]
    assert captured["stop_forum_engine"] == [captured["socket_emit"]]
    assert captured["forum_output_log_dir"] == Path("runtime-logs")
    assert captured["forum_log_payload_log_dir"] == Path("runtime-logs")
    assert captured["forum_log_history_call"] == {
        "log_dir": Path("runtime-logs"),
        "start_position": 7,
        "max_lines": 25,
    }


def test_forum_alias_routes_delegate_to_same_injected_runtime_handlers():
    captured: dict[str, object] = {}
    app = Flask(__name__)
    register_http_routes(app, _build_dependencies(captured))
    client = app.test_client()

    start_response = client.get("/api/forum/start")
    stop_response = client.get("/api/forum/stop")

    assert start_response.status_code == 200
    assert start_response.get_json()["success"] is True
    assert "ForumEngine" in start_response.get_json()["message"]
    assert stop_response.status_code == 200
    assert stop_response.get_json()["success"] is True
    assert "ForumEngine" in stop_response.get_json()["message"]
    assert captured["start_forum_engine"] == [captured["socket_emit"]]
    assert captured["stop_forum_engine"] == [captured["socket_emit"]]


def test_log_routes_delegate_to_injected_log_reader_writer_and_socket_emit():
    captured: dict[str, object] = {}
    app = Flask(__name__)
    register_http_routes(app, _build_dependencies(captured))
    client = app.test_client()

    output_response = client.get("/api/output/query")
    test_log_response = client.get("/api/test_log/query")

    assert output_response.status_code == 200
    assert output_response.get_json() == {"success": True, "output": ["query-log"]}
    assert captured["read_log_call"] == {
        "log_dir": Path("runtime-logs"),
        "app_name": "query",
        "tail_lines": None,
    }

    assert test_log_response.status_code == 200
    assert test_log_response.get_json()["success"] is True
    assert captured["write_log_call"]["log_dir"] == Path("runtime-logs")
    assert captured["write_log_call"]["app_name"] == "query"
    assert captured["write_log_call"]["line"].startswith("[")

    socket_event, socket_payload = captured["socket_emits"][0]
    assert socket_event == "console_output"
    assert socket_payload["app"] == "query"
    assert socket_payload["line"] == captured["write_log_call"]["line"]


def test_status_route_uses_injected_runtime_status_getter():
    captured: dict[str, object] = {}
    app = Flask(__name__)
    register_http_routes(app, _build_dependencies(captured))
    client = app.test_client()

    response = client.get("/api/status")

    assert response.status_code == 200
    assert response.get_json() == {
        "forum": {"status": "stopped", "port": None, "output_lines": 0},
        "query": {"status": "running", "port": 18503, "output_lines": 3},
    }
    assert captured["get_process_status"] == [{"include_output_lines": True}]


def test_non_forum_start_route_delegates_to_process_manager(monkeypatch):
    captured: dict[str, object] = {}
    app = Flask(__name__)
    register_http_routes(app, _build_dependencies(captured))
    client = app.test_client()

    if "start_streamlit_app" not in HttpRouteDependencies.__dataclass_fields__:
        start_calls: list[dict[str, object]] = []
        wait_calls: list[tuple[str, int]] = []

        monkeypatch.setattr(
            http_routes,
            "STREAMLIT_SCRIPTS",
            {"query": "apps/query/app.py"},
        )

        def fake_start_streamlit_app(app_name, script_path, port, *, log_dir, emit_output):
            start_calls.append(
                {
                    "app_name": app_name,
                    "script_path": script_path,
                    "port": port,
                    "log_dir": log_dir,
                    "emit_output": emit_output,
                }
            )
            return True, "query started"

        def fake_wait_for_app_startup(app_name: str, timeout: int):
            wait_calls.append((app_name, timeout))
            return True, "ready"

        monkeypatch.setattr(http_routes, "start_streamlit_app", fake_start_streamlit_app)
        monkeypatch.setattr(http_routes, "wait_for_app_startup", fake_wait_for_app_startup)

    response = client.get("/api/start/query")

    assert response.status_code == 200
    assert response.get_json() == {"success": True, "message": "query started"}
    expected_start_call = {
        "app_name": "query",
        "script_path": "apps/query/app.py",
        "port": 18503,
        "log_dir": Path("runtime-logs"),
        "emit_output": captured["socket_emit"],
    }
    if "start_streamlit_app" in HttpRouteDependencies.__dataclass_fields__:
        assert captured["start_streamlit_app"] == [expected_start_call]
        assert captured["wait_for_app_startup"] == [("query", 15)]
    else:
        assert start_calls == [expected_start_call]
        assert wait_calls == [("query", 15)]


def test_non_forum_stop_route_delegates_to_process_manager(monkeypatch):
    captured: dict[str, object] = {}
    app = Flask(__name__)
    register_http_routes(app, _build_dependencies(captured))
    client = app.test_client()

    if "stop_streamlit_app" not in HttpRouteDependencies.__dataclass_fields__:
        stop_calls: list[str] = []

        def fake_stop_streamlit_app(app_name: str):
            stop_calls.append(app_name)
            return True, "query stopped"

        monkeypatch.setattr(http_routes, "stop_streamlit_app", fake_stop_streamlit_app)

    response = client.get("/api/stop/query")

    assert response.status_code == 200
    assert response.get_json() == {"success": True, "message": "query stopped"}
    if "stop_streamlit_app" in HttpRouteDependencies.__dataclass_fields__:
        assert captured["stop_streamlit_app"] == ["query"]
    else:
        assert stop_calls == ["query"]


def test_search_route_returns_200_when_submitter_reports_empty_query():
    captured: dict[str, object] = {}
    dependencies = _build_dependencies(captured)

    def submit_search_request(*, payload):
        captured.setdefault("submit_search_request", []).append({"payload": payload})
        return {"success": False, "message": "鎼滅储鏌ヨ涓嶈兘涓虹┖"}, 200

    dependencies = HttpRouteDependencies(
        **{
            **dependencies.__dict__,
            "submit_search_request": submit_search_request,
        }
    )
    app = Flask(__name__)
    register_http_routes(app, dependencies)
    client = app.test_client()

    response = client.post("/api/search", json={"research_task_id": "task-1"})

    assert response.status_code == 200
    assert response.get_json()["success"] is False
    assert response.get_json()["message"] == "鎼滅储鏌ヨ涓嶈兘涓虹┖"
    assert captured["submit_search_request"] == [
        {
            "payload": {"research_task_id": "task-1"},
        }
    ]


def test_search_route_delegates_only_to_submitter_payload_contract():
    captured: dict[str, object] = {}
    dependencies = _build_dependencies(captured)

    def submit_search_request(*, payload):
        captured.setdefault("submit_search_request", []).append({"payload": payload})
        return {"success": True, "accepted": True, "message": "ok"}, 200

    dependencies = HttpRouteDependencies(
        **{
            **dependencies.__dict__,
            "submit_search_request": submit_search_request,
        }
    )

    app = Flask(__name__)
    register_http_routes(app, dependencies)
    client = app.test_client()

    response = client.post("/api/search", json={"research_task_id": "task-2"})

    assert response.status_code == 200
    assert response.get_json() == {"success": True, "accepted": True, "message": "ok"}
    assert captured["submit_search_request"] == [
        {
            "payload": {"research_task_id": "task-2"},
        }
    ]