"""Tests for CLI logs command.""" from collections.abc import Coroutine from pathlib import Path from typing import Any from unittest.mock import patch import pytest import typer from compose_farm.cli.monitoring import logs from compose_farm.config import Config, Host from compose_farm.executor import CommandResult def _make_config(tmp_path: Path) -> Config: """Create a minimal config for testing.""" compose_dir = tmp_path / "compose" compose_dir.mkdir() for svc in ("svc1", "svc2", "svc3"): svc_dir = compose_dir / svc svc_dir.mkdir() (svc_dir / "docker-compose.yml").write_text("services: {}\n") return Config( compose_dir=compose_dir, hosts={"local": Host(address="localhost"), "remote": Host(address="192.168.1.10")}, services={"svc1": "local", "svc2": "local", "svc3": "remote"}, ) def _make_result(service: str) -> CommandResult: """Create a successful command result.""" return CommandResult(service=service, exit_code=0, success=True, stdout="", stderr="") def _mock_run_async_factory( services: list[str], ) -> tuple[Any, list[CommandResult]]: """Create a mock run_async that returns results for given services.""" results = [_make_result(s) for s in services] def mock_run_async(_coro: Coroutine[Any, Any, Any]) -> list[CommandResult]: return results return mock_run_async, results class TestLogsContextualDefault: """Tests for logs --tail contextual default behavior.""" def test_logs_all_services_defaults_to_20(self, tmp_path: Path) -> None: """When --all is specified, default tail should be 20.""" cfg = _make_config(tmp_path) mock_run_async, _ = _mock_run_async_factory(["svc1", "svc2", "svc3"]) with ( patch("compose_farm.cli.monitoring.load_config_or_exit", return_value=cfg), patch("compose_farm.cli.common.load_config_or_exit", return_value=cfg), patch("compose_farm.cli.monitoring.run_async", side_effect=mock_run_async), patch("compose_farm.cli.monitoring.run_on_services") as mock_run, ): mock_run.return_value = None logs(services=None, all_services=True, host=None, follow=False, tail=None, config=None) mock_run.assert_called_once() call_args = mock_run.call_args assert call_args[0][2] == "logs --tail 20" def test_logs_single_service_defaults_to_100(self, tmp_path: Path) -> None: """When specific services are specified, default tail should be 100.""" cfg = _make_config(tmp_path) mock_run_async, _ = _mock_run_async_factory(["svc1"]) with ( patch("compose_farm.cli.monitoring.load_config_or_exit", return_value=cfg), patch("compose_farm.cli.common.load_config_or_exit", return_value=cfg), patch("compose_farm.cli.monitoring.run_async", side_effect=mock_run_async), patch("compose_farm.cli.monitoring.run_on_services") as mock_run, ): logs( services=["svc1"], all_services=False, host=None, follow=False, tail=None, config=None, ) mock_run.assert_called_once() call_args = mock_run.call_args assert call_args[0][2] == "logs --tail 100" def test_logs_explicit_tail_overrides_default(self, tmp_path: Path) -> None: """When --tail is explicitly provided, it should override the default.""" cfg = _make_config(tmp_path) mock_run_async, _ = _mock_run_async_factory(["svc1", "svc2", "svc3"]) with ( patch("compose_farm.cli.monitoring.load_config_or_exit", return_value=cfg), patch("compose_farm.cli.common.load_config_or_exit", return_value=cfg), patch("compose_farm.cli.monitoring.run_async", side_effect=mock_run_async), patch("compose_farm.cli.monitoring.run_on_services") as mock_run, ): logs( services=None, all_services=True, host=None, follow=False, tail=50, config=None, ) mock_run.assert_called_once() call_args = mock_run.call_args assert call_args[0][2] == "logs --tail 50" def test_logs_follow_appends_flag(self, tmp_path: Path) -> None: """When --follow is specified, -f should be appended to command.""" cfg = _make_config(tmp_path) mock_run_async, _ = _mock_run_async_factory(["svc1"]) with ( patch("compose_farm.cli.monitoring.load_config_or_exit", return_value=cfg), patch("compose_farm.cli.common.load_config_or_exit", return_value=cfg), patch("compose_farm.cli.monitoring.run_async", side_effect=mock_run_async), patch("compose_farm.cli.monitoring.run_on_services") as mock_run, ): logs( services=["svc1"], all_services=False, host=None, follow=True, tail=None, config=None, ) mock_run.assert_called_once() call_args = mock_run.call_args assert call_args[0][2] == "logs --tail 100 -f" class TestLogsHostFilter: """Tests for logs --host filter behavior.""" def test_logs_host_filter_selects_services_on_host(self, tmp_path: Path) -> None: """When --host is specified, only services on that host are included.""" cfg = _make_config(tmp_path) mock_run_async, _ = _mock_run_async_factory(["svc1", "svc2"]) with ( patch("compose_farm.cli.monitoring.load_config_or_exit", return_value=cfg), patch("compose_farm.cli.monitoring.run_async", side_effect=mock_run_async), patch("compose_farm.cli.monitoring.run_on_services") as mock_run, ): logs( services=None, all_services=False, host="local", follow=False, tail=None, config=None, ) mock_run.assert_called_once() call_args = mock_run.call_args # svc1 and svc2 are on "local", svc3 is on "remote" assert set(call_args[0][1]) == {"svc1", "svc2"} def test_logs_host_filter_defaults_to_20_lines(self, tmp_path: Path) -> None: """When --host is specified, default tail should be 20 (multiple services).""" cfg = _make_config(tmp_path) mock_run_async, _ = _mock_run_async_factory(["svc1", "svc2"]) with ( patch("compose_farm.cli.monitoring.load_config_or_exit", return_value=cfg), patch("compose_farm.cli.monitoring.run_async", side_effect=mock_run_async), patch("compose_farm.cli.monitoring.run_on_services") as mock_run, ): logs( services=None, all_services=False, host="local", follow=False, tail=None, config=None, ) mock_run.assert_called_once() call_args = mock_run.call_args assert call_args[0][2] == "logs --tail 20" def test_logs_all_and_host_mutually_exclusive(self) -> None: """Using --all and --host together should error.""" # No config mock needed - error is raised before config is loaded with pytest.raises(typer.Exit) as exc_info: logs( services=None, all_services=True, host="local", follow=False, tail=None, config=None, ) assert exc_info.value.exit_code == 1