test_flask_interface_runtime_wiring.py 11.8 KB
from __future__ import annotations

import builtins
import importlib
import json
import shutil
import sys
from datetime import datetime, timedelta
from uuid import uuid4

import pytest

from services.shared.dto import ReportJobDTO, ResearchTaskDTO


class _FakeTask:
    def __init__(
        self,
        task_id: str,
        *,
        research_task_id: str,
        created_at: datetime,
        updated_at: datetime | None = None,
        status: str = "completed",
    ) -> None:
        self.task_id = task_id
        self.research_task_id = research_task_id
        self.created_at = created_at
        self.updated_at = updated_at or created_at
        self.status = status
        self.progress = 55 if status == "running" else 100
        self.error_message = ""
        self.published_events: list[dict[str, object]] = []

    def to_dto(self) -> ReportJobDTO:
        return ReportJobDTO(
            id=self.task_id,
            research_task_id=self.research_task_id,
            status=self.status,
            progress={"percent": 55 if self.status == "running" else 100},
            output_path=f"reports/{self.task_id}.html",
            output_format="html",
            artifacts={
                "report_file_ready": self.status == "completed",
                "report_file_path": f"reports/{self.task_id}.html",
                "has_result": self.status == "completed",
            },
            last_action=f"{self.task_id} {self.status}",
            created_at=self.created_at.isoformat(),
            updated_at=self.updated_at.isoformat(),
        )

    def to_dict(self) -> dict[str, object]:
        return self.to_dto().to_response_item()

    def publish_event(self, event_type: str, payload: dict[str, object]) -> None:
        self.published_events.append({"event_type": event_type, "payload": payload})

    def update_status(self, status: str, progress: int | None = None, error_message: str = "") -> None:
        self.status = status
        if progress is not None:
            self.progress = progress
        if error_message:
            self.error_message = error_message
        self.updated_at = datetime(2026, 4, 24, 12, 0, 0)


@pytest.fixture()
def isolated_report_runtime(monkeypatch):
    monkeypatch.setattr(builtins, "clear_report_log", lambda: None, raising=False)
    sys.modules.pop("services.engines.report.flask_interface", None)
    report_runtime = importlib.import_module("services.engines.report.flask_interface")

    original_tasks = report_runtime.REPORT_TASK_RUNTIME.list_tasks()
    original_current_task = report_runtime.REPORT_TASK_RUNTIME.get_current_task()
    report_runtime.REPORT_TASK_RUNTIME.replace_registry([], current_task=None)

    try:
        yield report_runtime
    finally:
        report_runtime.REPORT_TASK_RUNTIME.replace_registry(
            original_tasks,
            current_task=original_current_task,
        )


def test_services_bind_directly_to_report_task_runtime_store(isolated_report_runtime):
    report_runtime = isolated_report_runtime

    assert report_runtime.REPORT_APP_SERVICE._current_task_getter.__self__ is report_runtime.REPORT_TASK_RUNTIME
    assert report_runtime.REPORT_APP_SERVICE._current_task_setter.__self__ is report_runtime.REPORT_TASK_RUNTIME
    assert report_runtime.REPORT_APP_SERVICE._report_task_getter.__self__ is report_runtime.REPORT_TASK_RUNTIME
    assert report_runtime.REPORT_APP_SERVICE._register_task.__self__ is report_runtime.REPORT_TASK_RUNTIME
    assert report_runtime.REPORT_QUERY_SERVICE._report_job_getter.__self__ is report_runtime.REPORT_TASK_RUNTIME
    assert report_runtime.REPORT_STREAM_SERVICE._report_task_getter.__self__ is report_runtime.REPORT_TASK_RUNTIME


def test_persisted_task_loader_and_restore_share_the_same_state_decoder(
    isolated_report_runtime,
    monkeypatch,
):
    report_runtime = isolated_report_runtime
    temp_root = report_runtime.PROJECT_ROOT / "var" / f"test-report-runtime-{uuid4().hex}"
    state_dir = temp_root / "task_registry"
    final_reports_dir = temp_root / "final_reports"
    state_dir.mkdir(parents=True)
    final_reports_dir.mkdir(parents=True)
    monkeypatch.setattr(report_runtime, "REPORT_TASK_STATE_DIR", state_dir)
    monkeypatch.setattr(report_runtime, "FINAL_REPORTS_DIR", final_reports_dir)

    payload = {
        "task_id": "report-persisted",
        "query": "museum query",
        "custom_template": "",
        "research_task_id": "research-1",
        "status": "completed",
        "progress": 100,
        "error_message": "",
        "created_at": "2026-04-24T09:00:00",
        "updated_at": "2026-04-24T09:05:00",
        "html_content": "<h1>report</h1>",
        "report_file_path": "",
        "report_file_relative_path": "reports/report-persisted.html",
        "report_file_name": "report-persisted.html",
        "state_file_path": "",
        "state_file_relative_path": "",
        "ir_file_path": "",
        "ir_file_relative_path": "",
        "markdown_file_path": "",
        "markdown_file_relative_path": "",
        "markdown_file_name": "",
    }
    (state_dir / "report-persisted.json").write_text(json.dumps(payload), encoding="utf-8")
    (state_dir / "broken.json").write_text("{not-json", encoding="utf-8")

    try:
        loaded = report_runtime._load_persisted_report_task("report-persisted")
        restored = report_runtime._restore_report_tasks_from_disk()

        assert loaded is not None
        assert loaded.task_id == "report-persisted"
        assert loaded.report_file_path.endswith("reports\\report-persisted.html")
        assert [task.task_id for task in restored] == ["report-persisted"]
        assert restored[0].report_file_path == loaded.report_file_path
    finally:
        shutil.rmtree(temp_root, ignore_errors=True)


def test_report_query_service_uses_runtime_store_for_task_resolution_and_snapshot(
    isolated_report_runtime,
    monkeypatch,
):
    report_runtime = isolated_report_runtime
    base_time = datetime(2026, 4, 23, 10, 0, 0)
    history_task = _FakeTask(
        "report-history",
        research_task_id="research-1",
        created_at=base_time,
        updated_at=base_time + timedelta(minutes=2),
        status="completed",
    )
    current_task = _FakeTask(
        "report-current",
        research_task_id="research-1",
        created_at=base_time + timedelta(minutes=1),
        updated_at=base_time + timedelta(minutes=5),
        status="running",
    )

    report_runtime.REPORT_TASK_RUNTIME.replace_registry(
        [history_task, current_task],
        current_task=current_task,
    )

    monkeypatch.setattr(
        report_runtime,
        "current_task",
        _FakeTask(
            "legacy-current",
            research_task_id="research-legacy",
            created_at=base_time - timedelta(minutes=5),
        ),
        raising=False,
    )
    monkeypatch.setattr(
        report_runtime,
        "tasks_registry",
        {
            "legacy-only": _FakeTask(
                "legacy-only",
                research_task_id="research-legacy",
                created_at=base_time - timedelta(minutes=10),
            )
        },
        raising=False,
    )

    payload = report_runtime.REPORT_QUERY_SERVICE.build_report_tasks_payload()
    resolved_job = report_runtime.REPORT_QUERY_SERVICE.get_report_job_dto("report-current")

    assert payload["current_task"]["task_id"] == "report-current"
    assert [item["task_id"] for item in payload["tasks"]] == ["report-current", "report-history"]
    assert payload["tasks"][0]["status"] == "running"
    assert payload["tasks"][1]["status"] == "completed"
    assert resolved_job is not None
    assert resolved_job.id == "report-current"
    assert report_runtime.REPORT_QUERY_SERVICE.get_report_job_dto("legacy-only") is None


def test_research_report_proxy_uses_injected_runtime_query_service(
    isolated_report_runtime,
    monkeypatch,
):
    try:
        from apps.web_api.bootstrap import runtime as runtime_bootstrap
        import backend.research_routes as research_routes
    except TypeError as exc:
        pytest.skip(f"research route wiring has not landed in this workspace yet: {exc}")

    report_runtime = isolated_report_runtime
    base_time = datetime(2026, 4, 23, 11, 0, 0)
    linked_task = _FakeTask(
        "report-linked",
        research_task_id="research-linked",
        created_at=base_time,
        updated_at=base_time + timedelta(minutes=3),
        status="completed",
    )

    report_runtime.REPORT_TASK_RUNTIME.replace_registry(
        [linked_task],
        current_task=linked_task,
    )
    monkeypatch.setattr(
        report_runtime,
        "current_task",
        _FakeTask(
            "legacy-current",
            research_task_id="research-legacy",
            created_at=base_time - timedelta(minutes=3),
        ),
        raising=False,
    )
    monkeypatch.setattr(report_runtime, "tasks_registry", {}, raising=False)
    runtime_bootstrap.configure_research_route_services(
        report_query_service=report_runtime.REPORT_QUERY_SERVICE,
    )

    payload = research_routes.RESEARCH_REPORT_APP_SERVICE.build_task_report_resource_payload(
        research_task_id="research-linked",
        report_job_id="report-linked",
        task=ResearchTaskDTO(
            id="research-linked",
            status="reporting",
            report_job_id="report-linked",
            last_action="Report linked",
            updated_at="2026-04-23T11:03:00",
        ),
    )

    assert research_routes._get_report_app_service() is report_runtime.REPORT_QUERY_SERVICE
    assert payload["task_id"] == "research-linked"
    assert payload["report"]["report_job_id"] == "report-linked"
    assert payload["report"]["current_job"]["id"] == "report-linked"
    assert payload["report"]["engine_result"]["engine_name"] == "report"
    assert [item["id"] for item in payload["report"]["jobs"]] == ["report-linked"]
    assert payload["report"]["history"]["linked_job_available"] is True
    assert payload["report"]["history"]["linked_job_is_current"] is True


def test_run_report_generation_error_does_not_clear_unrelated_current_task(isolated_report_runtime, monkeypatch):
    report_runtime = isolated_report_runtime
    base_time = datetime(2026, 4, 24, 11, 0, 0)
    failed_task = _FakeTask(
        "report-failed",
        research_task_id="research-1",
        created_at=base_time,
        status="running",
    )
    current_task = _FakeTask(
        "report-current",
        research_task_id="research-2",
        created_at=base_time + timedelta(minutes=1),
        status="running",
    )
    report_runtime.REPORT_TASK_RUNTIME.replace_registry(
        [failed_task, current_task],
        current_task=current_task,
    )
    monkeypatch.setattr(
        report_runtime,
        "_prepare_report_generation_inputs",
        lambda _task: (_ for _ in ()).throw(RuntimeError("boom")),
    )

    report_runtime.run_report_generation(failed_task, "museum query")

    assert report_runtime.REPORT_TASK_RUNTIME.get_current_task() is current_task
    assert failed_task.status == "error"
    assert failed_task.error_message == "boom"
    assert failed_task.published_events[-1]["event_type"] == "error"


def test_run_report_generation_error_clears_matching_current_task(isolated_report_runtime, monkeypatch):
    report_runtime = isolated_report_runtime
    base_time = datetime(2026, 4, 24, 12, 0, 0)
    failed_task = _FakeTask(
        "report-failed",
        research_task_id="research-1",
        created_at=base_time,
        status="running",
    )
    report_runtime.REPORT_TASK_RUNTIME.replace_registry(
        [failed_task],
        current_task=failed_task,
    )
    monkeypatch.setattr(
        report_runtime,
        "_prepare_report_generation_inputs",
        lambda _task: (_ for _ in ()).throw(RuntimeError("boom")),
    )

    report_runtime.run_report_generation(failed_task, "museum query")

    assert report_runtime.REPORT_TASK_RUNTIME.get_current_task() is None
    assert failed_task.status == "error"