Newer
Older
navi-1 / tests / unit / overlay / test_apply.py
"""Applying a validated overlay: settings, profiles, llm, mcp."""

import pytest

import navi.config as config_mod
from navi.overlay import apply as apply_mod
from navi.overlay.apply import (
    apply_profiles_list,
    apply_profiles_registry,
    apply_settings_live,
    build_settings,
    overlay_llm_raw,
    overlay_mcp_configs,
    refresh,
)
from navi.overlay.fetch import Fetched
from navi.overlay.state import get_state, reset_state, status_summary
from navi.settings_model import Settings


@pytest.fixture(autouse=True)
def _fresh_state():
    reset_state()
    yield
    reset_state()


@pytest.fixture()
def base(tmp_path) -> Settings:
    return Settings(
        _env_file=None,
        hive_config_cache_file=str(tmp_path / "cache.json"),
    )


@pytest.fixture()
def state(base):
    state = get_state()
    state.base_settings = base
    state.enabled = True
    return state


# ── settings ────────────────────────────────────────────────────────────────


def test_build_settings_overlays_only_named_fields(base):
    overlaid = build_settings(base, {"llm_complete_timeout": 99})
    assert overlaid.llm_complete_timeout == 99
    assert overlaid.navi_port == base.navi_port
    assert overlaid is not base


def test_build_settings_without_patch_returns_the_same_object(base):
    assert build_settings(base, {}) is base


def test_hot_setting_is_written_into_the_live_object(state):
    hot, restart = apply_settings_live({"gnauth_client_secret": "rotated"})
    assert hot == ["gnauth_client_secret"]
    assert restart == []
    assert config_mod.settings.gnauth_client_secret == "rotated"


def test_non_hot_setting_requires_a_restart(state):
    before = config_mod.settings.openai_api_key
    hot, restart = apply_settings_live({"openai_api_key": "sk-new"})
    assert hot == []
    assert restart == ["openai_api_key"]
    # Not applied behind the operator's back.
    assert config_mod.settings.openai_api_key == before


def test_removing_a_field_hands_it_back_to_the_local_value(state, base):
    state.merged = {"settings": {"llm_complete_timeout": 99}}
    apply_settings_live({"llm_complete_timeout": 99})
    assert config_mod.settings.llm_complete_timeout == 99

    state.merged = {"settings": {"llm_complete_timeout": 99}}
    hot, _ = apply_settings_live({})
    assert hot == ["llm_complete_timeout"]
    assert config_mod.settings.llm_complete_timeout == base.llm_complete_timeout


def test_unchanged_hot_field_is_not_reported(state):
    apply_settings_live({"gnauth_client_secret": "same"})
    hot, _ = apply_settings_live({"gnauth_client_secret": "same"})
    assert hot == []


# ── profiles ────────────────────────────────────────────────────────────────


def test_apply_profiles_list_overlays_the_named_profile(state):
    from navi.profiles import ALL_PROFILES

    state.merged = {"profiles": {"assistant": {"temperature": 0.11}}}
    profiles = apply_profiles_list(ALL_PROFILES)
    by_id = {p.id: p for p in profiles}
    assert by_id["assistant"].temperature == 0.11
    others = [p for p in profiles if p.id != "assistant"]
    assert others  # the untouched profiles are still all there


def test_apply_profiles_list_without_patch_returns_input(state):
    from navi.profiles import ALL_PROFILES

    state.merged = {}
    assert apply_profiles_list(ALL_PROFILES) is ALL_PROFILES


def test_registry_rebuild_is_idempotent_and_reversible(state):
    from navi.core.registry import ProfileRegistry
    from navi.profiles import ALL_PROFILES

    registry = ProfileRegistry()
    for profile in ALL_PROFILES:
        registry.register(profile)
    original = registry.get("assistant").temperature

    state.merged = {"profiles": {"assistant": {"temperature": 0.05}}}
    apply_profiles_registry(registry)
    assert registry.get("assistant").temperature == 0.05

    state.merged = {}
    apply_profiles_registry(registry)
    assert registry.get("assistant").temperature == original


def test_registry_leaves_unknown_runtime_profiles_alone(state):
    from navi.core.registry import ProfileRegistry
    from navi.profiles.base import AgentProfile

    registry = ProfileRegistry()
    custom = AgentProfile(id="made-at-runtime", name="X", description="", system_prompt="")
    registry.register(custom)
    apply_profiles_registry(registry)
    assert registry.get("made-at-runtime") is custom


# ── llm / mcp ───────────────────────────────────────────────────────────────


def test_overlay_llm_raw_merges_and_keeps_local_values(state):
    state.merged = {"llm": {"targets": {"home": {"host": "http://new"}}}}
    raw = {"targets": {"home": {"host": "http://old"}, "cloud": {"host": "https://c"}}}
    merged = overlay_llm_raw(raw)
    assert merged["targets"]["home"]["host"] == "http://new"
    assert merged["targets"]["cloud"]["host"] == "https://c"


def test_overlay_llm_raw_without_patch_is_a_passthrough(state):
    state.merged = {}
    raw = {"targets": {}}
    assert overlay_llm_raw(raw) is raw
    assert overlay_llm_raw(None) is None


def test_overlay_mcp_configs_merges_and_deletes(state):
    from navi.mcp.config import McpServerConfig

    state.merged = {
        "mcp_servers": {"srv": {"command": "new"}, "gone": None}
    }
    configs = {
        "srv": McpServerConfig(transport="stdio", command="old"),
        "gone": McpServerConfig(transport="stdio", command="x"),
        "kept": McpServerConfig(transport="stdio", command="y"),
    }
    merged = overlay_mcp_configs(configs)
    assert merged["srv"].command == "new"
    assert "gone" not in merged
    assert merged["kept"].command == "y"


# ── refresh ─────────────────────────────────────────────────────────────────


def test_refresh_applies_and_records(state, base, monkeypatch):
    fetched = Fetched(
        version=7,
        layers=[{"name": "global", "config": {"settings": {"llm_complete_timeout": 33}}}],
        hash="sha256:x",
    )
    monkeypatch.setattr(apply_mod, "fetch_layers", lambda b: fetched)

    result = refresh("test")
    assert result["status"] == "applied"
    assert config_mod.settings.llm_complete_timeout == 33
    assert state.version == 7
    assert state.hash == "sha256:x"
    assert state.layers == ["global"]
    assert state.merged["settings"] == {"llm_complete_timeout": 33}


def test_refresh_rejects_and_keeps_the_previous_config(state, monkeypatch):
    monkeypatch.setattr(
        apply_mod,
        "fetch_layers",
        lambda b: Fetched(
            version=1,
            layers=[{"name": "g", "config": {"settings": {"llm_complete_timeout": 33}}}],
            hash="sha256:good",
        ),
    )
    refresh("first")
    good = config_mod.settings.llm_complete_timeout

    monkeypatch.setattr(
        apply_mod,
        "fetch_layers",
        lambda b: Fetched(
            version=2,
            layers=[{"name": "g", "config": {"settings": {"llm_complete_timeout": "soon"}}}],
            hash="sha256:bad",
        ),
    )
    result = refresh("second")
    assert result["status"] == "rejected"
    assert config_mod.settings.llm_complete_timeout == good
    assert state.version == 1
    assert state.errors


def test_refresh_is_a_noop_when_the_channel_is_disabled(state):
    state.enabled = False
    assert refresh("test")["status"] == "disabled"


def test_refresh_survives_an_unreachable_hive(state, monkeypatch):
    def boom(base_settings):
        raise RuntimeError("no route to host")

    monkeypatch.setattr(apply_mod, "fetch_layers", boom)
    result = refresh("test")
    assert result["status"] == "unreachable"
    assert state.last_error and "no route" in state.last_error
    # The reason reaches /health and the hive, not only the state object.
    assert "no route" in (status_summary()["error"] or "")

    # A later success clears it, so /health stops reporting an old outage.
    monkeypatch.setattr(
        apply_mod,
        "fetch_layers",
        lambda b: Fetched(
            version=1,
            layers=[{"name": "g", "config": {"settings": {"llm_complete_timeout": 21}}}],
            hash="sha256:ok",
        ),
    )
    refresh("retry")
    assert state.last_error is None


def test_refresh_staged_when_a_restart_is_needed(state, monkeypatch):
    monkeypatch.setattr(
        apply_mod,
        "fetch_layers",
        lambda b: Fetched(
            version=1,
            layers=[{"name": "g", "config": {"settings": {"openai_api_key": "sk-new"}}}],
            hash="sha256:x",
        ),
    )
    result = refresh("test")
    assert result["status"] == "staged"
    assert "openai_api_key" in state.restart_required