"""Tests for web API helper functions.""" from __future__ import annotations from pathlib import Path from typing import TYPE_CHECKING import pytest from fastapi import HTTPException from pydantic import ValidationError if TYPE_CHECKING: from compose_farm.config import Config class TestExtractConfigError: """Tests for extract_config_error helper.""" def test_validation_error_with_location(self) -> None: from compose_farm.config import Config, Host from compose_farm.web.deps import extract_config_error # Trigger a validation error with an extra field with pytest.raises(ValidationError) as exc_info: Config( hosts={"server": Host(address="192.168.1.1")}, stacks={"app": "server"}, unknown_field="bad", # type: ignore[call-arg] # ty: ignore[unknown-argument] ) msg = extract_config_error(exc_info.value) assert "unknown_field" in msg assert "Extra inputs are not permitted" in msg def test_validation_error_nested_location(self) -> None: from compose_farm.config import Host from compose_farm.web.deps import extract_config_error # Trigger a validation error with a nested extra field with pytest.raises(ValidationError) as exc_info: Host(address="192.168.1.1", bad_key="value") # type: ignore[call-arg] # ty: ignore[unknown-argument] msg = extract_config_error(exc_info.value) assert "bad_key" in msg assert "Extra inputs are not permitted" in msg def test_regular_exception(self) -> None: from compose_farm.web.deps import extract_config_error exc = ValueError("Something went wrong") msg = extract_config_error(exc) assert msg == "Something went wrong" def test_file_not_found_exception(self) -> None: from compose_farm.web.deps import extract_config_error exc = FileNotFoundError("Config file not found") msg = extract_config_error(exc) assert msg == "Config file not found" class TestValidateYaml: """Tests for _validate_yaml helper.""" def test_valid_yaml(self) -> None: from compose_farm.web.routes.api import _validate_yaml # Should not raise _validate_yaml("key: value") _validate_yaml("list:\n - item1\n - item2") _validate_yaml("") def test_invalid_yaml(self) -> None: from compose_farm.web.routes.api import _validate_yaml with pytest.raises(HTTPException) as exc_info: _validate_yaml("key: [unclosed") assert exc_info.value.status_code == 400 assert "Invalid YAML" in exc_info.value.detail class TestGetStackComposePath: """Tests for _get_stack_compose_path helper.""" def test_stack_found(self, mock_config: Config) -> None: from compose_farm.web.routes.api import _get_stack_compose_path path = _get_stack_compose_path("plex") assert isinstance(path, Path) assert path.name == "compose.yaml" assert path.parent.name == "plex" def test_stack_not_found(self, mock_config: Config) -> None: from compose_farm.web.routes.api import _get_stack_compose_path with pytest.raises(HTTPException) as exc_info: _get_stack_compose_path("nonexistent") assert exc_info.value.status_code == 404 assert "not found" in exc_info.value.detail class TestIsLocalHost: """Tests for is_local_host helper.""" def test_returns_true_when_web_stack_host_matches( self, monkeypatch: pytest.MonkeyPatch ) -> None: """is_local_host returns True when host matches web stack host.""" from compose_farm.config import Config, Host from compose_farm.web.deps import is_local_host monkeypatch.setenv("CF_WEB_STACK", "compose-farm") config = Config( hosts={"nas": Host(address="10.99.99.1"), "nuc": Host(address="10.99.99.2")}, stacks={"compose-farm": "nas"}, ) host = config.hosts["nas"] assert is_local_host("nas", host, config) is True def test_returns_false_when_web_stack_host_differs( self, monkeypatch: pytest.MonkeyPatch ) -> None: """is_local_host returns False when host does not match web stack host.""" from compose_farm.config import Config, Host from compose_farm.web.deps import is_local_host monkeypatch.setenv("CF_WEB_STACK", "compose-farm") config = Config( hosts={"nas": Host(address="10.99.99.1"), "nuc": Host(address="10.99.99.2")}, stacks={"compose-farm": "nas"}, ) host = config.hosts["nuc"] # nuc is not local, and not matching the web stack host assert is_local_host("nuc", host, config) is False class TestGetLocalHost: """Tests for get_local_host helper.""" def test_returns_web_stack_host(self, monkeypatch: pytest.MonkeyPatch) -> None: """get_local_host returns the web stack host when in container.""" from compose_farm.config import Config, Host from compose_farm.web.deps import get_local_host monkeypatch.setenv("CF_WEB_STACK", "compose-farm") config = Config( hosts={"nas": Host(address="10.99.99.1"), "nuc": Host(address="10.99.99.2")}, stacks={"compose-farm": "nas"}, ) assert get_local_host(config) == "nas" def test_ignores_unknown_web_stack(self, monkeypatch: pytest.MonkeyPatch) -> None: """get_local_host ignores web stack if it's not in stacks.""" from compose_farm.config import Config, Host from compose_farm.web.deps import get_local_host monkeypatch.setenv("CF_WEB_STACK", "unknown-stack") # Use address that won't match local machine to avoid is_local() fallback config = Config( hosts={"nas": Host(address="10.99.99.1")}, stacks={"test": "nas"}, ) # Should fall back to auto-detection (which won't match anything here) assert get_local_host(config) is None def test_returns_none_outside_container(self, monkeypatch: pytest.MonkeyPatch) -> None: """get_local_host returns None when CF_WEB_STACK not set.""" from compose_farm.config import Config, Host from compose_farm.web.deps import get_local_host monkeypatch.delenv("CF_WEB_STACK", raising=False) config = Config( hosts={"nas": Host(address="10.99.99.1")}, stacks={"compose-farm": "nas"}, ) assert get_local_host(config) is None class TestRenderContainers: """Tests for container template rendering.""" def test_render_running_container(self, mock_config: Config) -> None: from compose_farm.web.routes.api import _render_containers containers = [{"Name": "plex", "State": "running"}] html = _render_containers("plex", "server-1", containers) assert "badge-success" in html assert "plex" in html assert "initExecTerminal" in html def test_render_unknown_state(self, mock_config: Config) -> None: from compose_farm.web.routes.api import _render_containers containers = [{"Name": "plex", "State": "unknown"}] html = _render_containers("plex", "server-1", containers) assert "loading-spinner" in html def test_render_exited_success(self, mock_config: Config) -> None: from compose_farm.web.routes.api import _render_containers containers = [{"Name": "plex", "State": "exited", "ExitCode": 0}] html = _render_containers("plex", "server-1", containers) assert "badge-neutral" in html assert "exited (0)" in html def test_render_exited_error(self, mock_config: Config) -> None: from compose_farm.web.routes.api import _render_containers containers = [{"Name": "plex", "State": "exited", "ExitCode": 1}] html = _render_containers("plex", "server-1", containers) assert "badge-error" in html assert "exited (1)" in html def test_render_other_state(self, mock_config: Config) -> None: from compose_farm.web.routes.api import _render_containers containers = [{"Name": "plex", "State": "restarting"}] html = _render_containers("plex", "server-1", containers) assert "badge-warning" in html assert "restarting" in html def test_render_with_header(self, mock_config: Config) -> None: from compose_farm.web.routes.api import _render_containers containers = [{"Name": "plex", "State": "running"}] html = _render_containers("plex", "server-1", containers, show_header=True) assert "server-1" in html assert "font-semibold" in html def test_render_multiple_containers(self, mock_config: Config) -> None: from compose_farm.web.routes.api import _render_containers containers = [ {"Name": "app-web-1", "State": "running"}, {"Name": "app-db-1", "State": "running"}, ] html = _render_containers("app", "server-1", containers) assert "app-web-1" in html assert "app-db-1" in html