test_web_api_system_controls.py 8.43 KB
"""Contract tests for the web API system control routes."""

from __future__ import annotations

from types import SimpleNamespace

import pytest

from apps.web_api.bootstrap import runtime as runtime_bootstrap
from apps.web_api.runtime.engine_registry import EngineRuntimeRegistry, build_default_engine_catalog
from apps.web_api.runtime.forum_runtime import build_forum_runtime
from apps.web_api.runtime.process_manager import ProcessManager
from apps.web_api.runtime.process_registry import ProcessRuntimeRegistry
from apps.web_api.runtime.search_dispatch import build_search_dispatch_runtime
from apps.web_api.runtime.status_service import RuntimeStatusService
from apps.web_api.runtime.system_state import SystemStateRegistry
from apps.web_api.runtime.task_runtime_store import TaskRuntimeStore
from services.shared.timeline import InMemoryTaskTimelineStore, create_task_timeline_tracker


def _build_process_registry() -> ProcessRuntimeRegistry:
    return ProcessRuntimeRegistry(
        {
            "insight": {"process": None, "port": 8501, "status": "stopped", "output": []},
            "media": {"process": None, "port": 8502, "status": "stopped", "output": []},
            "query": {"process": None, "port": 8503, "status": "stopped", "output": []},
            "forum": {"process": None, "port": None, "status": "stopped", "output": []},
        }
    )


class StubSystemLifecycle:
    def __init__(
        self,
        *,
        start_response: tuple[dict[str, object], int] | None = None,
        shutdown_response: tuple[dict[str, object], int] | None = None,
    ) -> None:
        self.start_calls = 0
        self.shutdown_calls: list[float] = []
        self.start_response = start_response or (
            {
                "success": True,
                "message": "stub start accepted",
                "logs": ["insight ready", "forum ready"],
            },
            200,
        )
        self.shutdown_response = shutdown_response or (
            {
                "success": True,
                "message": "stub shutdown accepted",
                "ports": ["insight:8501", "media:8502", "query:8503"],
            },
            200,
        )

    def start_system(self) -> tuple[dict[str, object], int]:
        self.start_calls += 1
        return self.start_response

    def shutdown_system(self, *, cleanup_timeout: float = 6.0) -> tuple[dict[str, object], int]:
        self.shutdown_calls.append(cleanup_timeout)
        return self.shutdown_response


@pytest.fixture()
def web_api_context(monkeypatch: pytest.MonkeyPatch, isolated_research_task_store, reload_web_api_app):
    """Load the app with isolated runtime services and research task storage."""
    lifecycle = StubSystemLifecycle()
    runtime_services = _build_runtime_services(isolated_research_task_store, lifecycle)
    monkeypatch.setattr(
        runtime_bootstrap,
        "build_runtime_services",
        lambda socketio, **kwargs: runtime_services,
    )

    module = reload_web_api_app()
    return SimpleNamespace(module=module, lifecycle=lifecycle, temp_dir=isolated_research_task_store.temp_dir)


def _build_runtime_services(isolated_research_task_store, lifecycle: StubSystemLifecycle):
    engine_registry = EngineRuntimeRegistry(build_default_engine_catalog())
    process_registry = _build_process_registry()
    forum_runtime = build_forum_runtime(process_registry=process_registry)
    process_manager = ProcessManager(process_registry=process_registry)
    system_state_registry = SystemStateRegistry()
    analysis_service = SimpleNamespace(
        resolve_search_query=lambda **_: ("", ""),
        dispatch_search_request=lambda **_: {"success": True, "message": "ok"},
    )
    runtime_services = runtime_bootstrap.RuntimeServices(
        log_dir=isolated_research_task_store.temp_dir,
        engine_registry=engine_registry,
        forum_runtime=forum_runtime,
        process_registry=process_registry,
        process_manager=process_manager,
        system_state_registry=system_state_registry,
        task_runtime_store=TaskRuntimeStore(),
        runtime_status_service=RuntimeStatusService(
            process_registry=process_registry,
            system_state_registry=system_state_registry,
            refresh_process_status=process_manager.check_app_status,
        ),
        task_timeline_tracker=create_task_timeline_tracker(
            timeline_store=InMemoryTaskTimelineStore(),
            source="tests.web_api_system_controls",
        ),
        research_task_service=object(),
        analysis_service=analysis_service,
        search_dispatch_runtime=build_search_dispatch_runtime(
            analysis_service=analysis_service,
        ),
        system_lifecycle=lifecycle,
        cleanup_handler=lambda: None,
    )
    return runtime_services


@pytest.fixture()
def client(web_api_context):
    return web_api_context.module.app.test_client()


def test_system_start_route_delegates_to_runtime_lifecycle(client, web_api_context):
    response = client.post("/api/system/start")

    assert response.status_code == 200
    assert response.get_json() == {
        "success": True,
        "message": "stub start accepted",
        "logs": ["insight ready", "forum ready"],
    }
    assert web_api_context.lifecycle.start_calls == 1


def test_system_shutdown_route_delegates_with_default_timeout(client, web_api_context):
    response = client.post("/api/system/shutdown")

    assert response.status_code == 200
    assert response.get_json() == {
        "success": True,
        "message": "stub shutdown accepted",
        "ports": ["insight:8501", "media:8502", "query:8503"],
    }
    assert web_api_context.lifecycle.shutdown_calls == [6.0]


@pytest.mark.parametrize(
    ("start_response", "expected_status", "expected_payload"),
    [
        (
            ({"success": False, "message": "system already starting"}, 400),
            400,
            {"success": False, "message": "system already starting"},
        ),
        (
            ({"success": False, "message": "startup failed", "detail": "forum bootstrap crashed"}, 500),
            500,
            {"success": False, "message": "startup failed", "detail": "forum bootstrap crashed"},
        ),
    ],
)
def test_system_start_route_preserves_lifecycle_failure_response(
    monkeypatch: pytest.MonkeyPatch,
    isolated_research_task_store,
    reload_web_api_app,
    start_response,
    expected_status,
    expected_payload,
):
    lifecycle = StubSystemLifecycle(start_response=start_response)
    runtime_services = _build_runtime_services(isolated_research_task_store, lifecycle)
    monkeypatch.setattr(
        runtime_bootstrap,
        "build_runtime_services",
        lambda socketio, **kwargs: runtime_services,
    )

    web_api_module = reload_web_api_app()
    client = web_api_module.app.test_client()

    response = client.post("/api/system/start")

    assert response.status_code == expected_status
    assert response.get_json() == expected_payload
    assert lifecycle.start_calls == 1


@pytest.mark.parametrize(
    ("shutdown_response", "expected_status", "expected_payload"),
    [
        (
            ({"success": False, "message": "shutdown already in progress"}, 400),
            400,
            {"success": False, "message": "shutdown already in progress"},
        ),
        (
            (
                {
                    "success": False,
                    "message": "shutdown failed",
                    "detail": "forum process did not exit",
                },
                500,
            ),
            500,
            {
                "success": False,
                "message": "shutdown failed",
                "detail": "forum process did not exit",
            },
        ),
    ],
)
def test_system_shutdown_route_preserves_lifecycle_failure_response(
    monkeypatch: pytest.MonkeyPatch,
    isolated_research_task_store,
    reload_web_api_app,
    shutdown_response,
    expected_status,
    expected_payload,
):
    lifecycle = StubSystemLifecycle(shutdown_response=shutdown_response)
    runtime_services = _build_runtime_services(isolated_research_task_store, lifecycle)
    monkeypatch.setattr(
        runtime_bootstrap,
        "build_runtime_services",
        lambda socketio, **kwargs: runtime_services,
    )

    web_api_module = reload_web_api_app()
    client = web_api_module.app.test_client()

    response = client.post("/api/system/shutdown")

    assert response.status_code == expected_status
    assert response.get_json() == expected_payload
    assert lifecycle.shutdown_calls == [6.0]