Newer
Older
navi-1 / tests / unit / api / test_health.py
"""Health endpoint contract tests (handbook 10-platform/health.md)."""

from unittest.mock import AsyncMock

import pytest
from fastapi.testclient import TestClient

import navi.api.routes.health as health_mod
from navi.config import Settings


@pytest.fixture
def health_client(monkeypatch):
    new_settings = Settings(_env_file=None, navi_persona_file="", hive_url="")
    monkeypatch.setattr(health_mod, "settings", new_settings)
    from navi.main import app

    return TestClient(app)


@pytest.fixture(autouse=True)
def _fresh_embed_cache():
    health_mod._embed_cache["checked_at"] = 0.0
    health_mod._embed_cache["result"] = None
    yield


def test_health_ok_when_all_checks_pass(health_client, monkeypatch):
    monkeypatch.setattr(
        health_mod, "_check_embed",
        AsyncMock(return_value={"ok": True, "backend": "ollama:11434", "error": None}),
    )
    resp = health_client.get("/health")
    assert resp.status_code == 200
    body = resp.json()
    assert body["status"] == "ok"
    assert body["service"] == "navi"
    assert body["checks"]["embed"]["status"] == "ok"
    assert body["checks"]["hive"]["status"] == "ok"


def test_health_degraded_when_embed_fails(health_client, monkeypatch):
    monkeypatch.setattr(
        health_mod, "_check_embed",
        AsyncMock(return_value={"ok": False, "backend": "none", "error": "connection refused"}),
    )
    body = health_client.get("/health").json()
    assert body["status"] == "degraded"
    assert body["checks"]["embed"]["status"] == "degraded"
    assert body["checks"]["embed"]["detail"] == "connection refused"


def test_health_keeps_legacy_embed_payload(health_client, monkeypatch):
    """The pre-contract top-level `embed` dict must survive for existing tooling."""
    monkeypatch.setattr(
        health_mod, "_check_embed",
        AsyncMock(return_value={"ok": True, "backend": "ollama:11434", "error": None}),
    )
    body = health_client.get("/health").json()
    assert body["embed"]["ok"] is True
    assert body["embed"]["backend"] == "ollama:11434"


def test_embed_check_is_cached(health_client, monkeypatch):
    check = AsyncMock(return_value={"ok": True, "backend": "x", "error": None})
    monkeypatch.setattr(health_mod, "_check_embed", check)

    first = health_client.get("/health").json()
    second = health_client.get("/health").json()

    assert check.await_count == 1  # second poll was served from cache
    assert first["checks"]["embed"] == second["checks"]["embed"]
    assert isinstance(first["checks"]["embed"]["ms"], int)


# ── llm route block ─────────────────────────────────────────────────────────
# "Which side of the pair is answering" is the point of the backup host, so
# /health says it — and degrades when the primary is not the one serving.


class _FakeLLMBackend:
    def __init__(self, active, snapshot=None):
        self._active = active
        self.health = type("H", (), {"snapshot": lambda _self: snapshot or {}})()

    def active_report(self):
        return self._active


def _serve_llm(monkeypatch, backend):
    import navi.api.deps as deps_mod

    monkeypatch.setattr(
        deps_mod, "_resolve_container", lambda: type("C", (), {"llm_backend": backend})()
    )


def _primary_report():
    return {
        "default": {
            "model": "glm-5.3-flash:cloud",
            "target": "cloud",
            "primary": True,
            "primary_model": "glm-5.3-flash:cloud",
        }
    }


def _fallback_report():
    return {
        "default": {
            "model": "qwen3.6:35b",
            "target": "home",
            "primary": False,
            "primary_model": "glm-5.3-flash:cloud",
        }
    }


def test_health_llm_ok_when_the_primary_is_serving(health_client, monkeypatch):
    monkeypatch.setattr(
        health_mod, "_check_embed",
        AsyncMock(return_value={"ok": True, "backend": "x", "error": None}),
    )
    _serve_llm(monkeypatch, _FakeLLMBackend(_primary_report()))

    body = health_client.get("/health").json()

    assert body["checks"]["llm"]["status"] == "ok"
    assert body["llm"]["degraded"] is False
    assert body["llm"]["active"]["default"]["target"] == "cloud"
    assert body["status"] == "ok"


def test_health_degrades_when_a_route_runs_on_a_backup_entry(health_client, monkeypatch):
    monkeypatch.setattr(
        health_mod, "_check_embed",
        AsyncMock(return_value={"ok": True, "backend": "x", "error": None}),
    )
    _serve_llm(monkeypatch, _FakeLLMBackend(_fallback_report()))

    body = health_client.get("/health").json()

    assert body["status"] == "degraded"
    assert body["checks"]["llm"]["status"] == "degraded"
    assert "qwen3.6:35b@home" in body["checks"]["llm"]["detail"]


def test_health_llm_exposes_target_states_and_missing_models(health_client, monkeypatch):
    monkeypatch.setattr(
        health_mod, "_check_embed",
        AsyncMock(return_value={"ok": True, "backend": "x", "error": None}),
    )
    snapshot = {
        "targets": {"cloud": {"state": "cooling", "reason": "quota", "retry_in_sec": 42}},
        "missing_models": [{"target": "home", "model": "qwen3.6:35b", "detail": "404"}],
    }
    _serve_llm(monkeypatch, _FakeLLMBackend(_fallback_report(), snapshot))

    llm = health_client.get("/health").json()["llm"]

    assert llm["targets"]["cloud"]["reason"] == "quota"
    assert llm["missing_models"][0]["model"] == "qwen3.6:35b"


def test_health_says_the_llm_is_not_configured_without_a_backend(health_client, monkeypatch):
    monkeypatch.setattr(
        health_mod, "_check_embed",
        AsyncMock(return_value={"ok": True, "backend": "x", "error": None}),
    )
    _serve_llm(monkeypatch, None)

    body = health_client.get("/health").json()

    assert body["llm"] == {"configured": False}
    assert body["checks"]["llm"] == {"status": "ok", "detail": "not configured"}