"""Tests for state module.""" from pathlib import Path import pytest from compose_farm.config import Config, Host from compose_farm.state import ( add_stack_host, get_orphaned_stacks, get_stack_host, get_stacks_not_in_state, load_state, remove_stack, save_state, set_stack_host, ) @pytest.fixture def config(tmp_path: Path) -> Config: """Create a config with a temporary config path for state storage.""" config_path = tmp_path / "compose-farm.yaml" config_path.write_text("") # Create empty file return Config( compose_dir=tmp_path / "compose", hosts={"nas01": Host(address="192.168.1.10")}, stacks={"plex": "nas01"}, config_path=config_path, ) class TestLoadState: """Tests for load_state function.""" def test_load_state_empty(self, config: Config) -> None: """Returns empty dict when state file doesn't exist.""" result = load_state(config) assert result == {} def test_load_state_with_data(self, config: Config) -> None: """Loads existing state from file.""" state_file = config.get_state_path() state_file.write_text("deployed:\n plex: nas01\n jellyfin: nas02\n") result = load_state(config) assert result == {"plex": "nas01", "jellyfin": "nas02"} def test_load_state_empty_file(self, config: Config) -> None: """Returns empty dict for empty file.""" state_file = config.get_state_path() state_file.write_text("") result = load_state(config) assert result == {} class TestSaveState: """Tests for save_state function.""" def test_save_state(self, config: Config) -> None: """Saves state to file.""" save_state(config, {"plex": "nas01", "jellyfin": "nas02"}) state_file = config.get_state_path() assert state_file.exists() content = state_file.read_text() assert "plex: nas01" in content assert "jellyfin: nas02" in content def test_save_state_sorts_host_lists(self, config: Config) -> None: """Saves state with sorted host lists for consistent output.""" # Pass hosts in unsorted order save_state(config, {"glances": ["pc", "nas", "hp", "anton"]}) state_file = config.get_state_path() content = state_file.read_text() # Hosts should be sorted alphabetically assert "- anton\n - hp\n - nas\n - pc" in content class TestGetStackHost: """Tests for get_stack_host function.""" def test_get_existing_stack(self, config: Config) -> None: """Returns host for existing stack.""" state_file = config.get_state_path() state_file.write_text("deployed:\n plex: nas01\n") host = get_stack_host(config, "plex") assert host == "nas01" def test_get_nonexistent_stack(self, config: Config) -> None: """Returns None for stack not in state.""" state_file = config.get_state_path() state_file.write_text("deployed:\n plex: nas01\n") host = get_stack_host(config, "unknown") assert host is None class TestSetStackHost: """Tests for set_stack_host function.""" def test_set_new_stack(self, config: Config) -> None: """Adds new stack to state.""" set_stack_host(config, "plex", "nas01") result = load_state(config) assert result["plex"] == "nas01" def test_update_existing_stack(self, config: Config) -> None: """Updates host for existing stack.""" state_file = config.get_state_path() state_file.write_text("deployed:\n plex: nas01\n") set_stack_host(config, "plex", "nas02") result = load_state(config) assert result["plex"] == "nas02" class TestRemoveStack: """Tests for remove_stack function.""" def test_remove_existing_stack(self, config: Config) -> None: """Removes stack from state.""" state_file = config.get_state_path() state_file.write_text("deployed:\n plex: nas01\n jellyfin: nas02\n") remove_stack(config, "plex") result = load_state(config) assert "plex" not in result assert result["jellyfin"] == "nas02" def test_remove_nonexistent_stack(self, config: Config) -> None: """Removing nonexistent stack doesn't error.""" state_file = config.get_state_path() state_file.write_text("deployed:\n plex: nas01\n") remove_stack(config, "unknown") # Should not raise result = load_state(config) assert result["plex"] == "nas01" def test_remove_host_from_list(self, config: Config) -> None: """Removes one host from a multi-host stack's list.""" state_file = config.get_state_path() state_file.write_text("deployed:\n glances:\n - nas\n - nuc\n - hp\n") remove_stack(config, "glances", "nas") result = load_state(config) assert set(result["glances"]) == {"nuc", "hp"} def test_remove_last_host_removes_stack(self, config: Config) -> None: """Removing the last host removes the stack entirely.""" state_file = config.get_state_path() state_file.write_text("deployed:\n glances:\n - nas\n") remove_stack(config, "glances", "nas") result = load_state(config) assert "glances" not in result def test_remove_host_from_single_host_stack(self, config: Config) -> None: """Removing host from single-host stack removes it if host matches.""" state_file = config.get_state_path() state_file.write_text("deployed:\n plex: nas\n") remove_stack(config, "plex", "nas") result = load_state(config) assert "plex" not in result def test_remove_wrong_host_from_single_host_stack(self, config: Config) -> None: """Removing wrong host from single-host stack does nothing.""" state_file = config.get_state_path() state_file.write_text("deployed:\n plex: nas\n") remove_stack(config, "plex", "nuc") result = load_state(config) assert result["plex"] == "nas" def test_remove_host_from_nonexistent_stack(self, config: Config) -> None: """Removing host from nonexistent stack doesn't error.""" state_file = config.get_state_path() state_file.write_text("deployed:\n plex: nas\n") remove_stack(config, "unknown", "nas") # Should not raise result = load_state(config) assert result["plex"] == "nas" class TestAddStackHost: """Tests for add_stack_host function.""" def test_add_host_to_new_stack(self, config: Config) -> None: """Adding host to new stack creates single-host entry.""" state_file = config.get_state_path() state_file.write_text("deployed: {}\n") add_stack_host(config, "plex", "nas") result = load_state(config) assert result["plex"] == "nas" def test_add_host_to_list(self, config: Config) -> None: """Adding host to existing list appends it.""" state_file = config.get_state_path() state_file.write_text("deployed:\n glances:\n - nas\n - nuc\n") add_stack_host(config, "glances", "hp") result = load_state(config) assert set(result["glances"]) == {"nas", "nuc", "hp"} def test_add_duplicate_host_to_list(self, config: Config) -> None: """Adding duplicate host to list does nothing.""" state_file = config.get_state_path() state_file.write_text("deployed:\n glances:\n - nas\n - nuc\n") add_stack_host(config, "glances", "nas") result = load_state(config) assert set(result["glances"]) == {"nas", "nuc"} def test_add_second_host_converts_to_list(self, config: Config) -> None: """Adding second host to single-host stack converts to list.""" state_file = config.get_state_path() state_file.write_text("deployed:\n plex: nas\n") add_stack_host(config, "plex", "nuc") result = load_state(config) assert set(result["plex"]) == {"nas", "nuc"} def test_add_same_host_to_single_host_stack(self, config: Config) -> None: """Adding same host to single-host stack does nothing.""" state_file = config.get_state_path() state_file.write_text("deployed:\n plex: nas\n") add_stack_host(config, "plex", "nas") result = load_state(config) assert result["plex"] == "nas" class TestGetOrphanedStacks: """Tests for get_orphaned_stacks function.""" def test_no_orphans(self, config: Config) -> None: """Returns empty dict when all stacks in state are in config.""" state_file = config.get_state_path() state_file.write_text("deployed:\n plex: nas01\n") result = get_orphaned_stacks(config) assert result == {} def test_finds_orphaned_stack(self, config: Config) -> None: """Returns stacks in state but not in config.""" state_file = config.get_state_path() state_file.write_text("deployed:\n plex: nas01\n jellyfin: nas02\n") result = get_orphaned_stacks(config) # plex is in config, jellyfin is not assert result == {"jellyfin": "nas02"} def test_finds_orphaned_multi_host_stack(self, config: Config) -> None: """Returns multi-host orphaned stacks with host list.""" state_file = config.get_state_path() state_file.write_text("deployed:\n plex: nas01\n dozzle:\n - nas01\n - nas02\n") result = get_orphaned_stacks(config) assert result == {"dozzle": ["nas01", "nas02"]} def test_empty_state(self, config: Config) -> None: """Returns empty dict when state is empty.""" result = get_orphaned_stacks(config) assert result == {} def test_all_orphaned(self, tmp_path: Path) -> None: """Returns all stacks when none are in config.""" config_path = tmp_path / "compose-farm.yaml" config_path.write_text("") cfg = Config( compose_dir=tmp_path / "compose", hosts={"nas01": Host(address="192.168.1.10")}, stacks={}, # No stacks in config config_path=config_path, ) state_file = cfg.get_state_path() state_file.write_text("deployed:\n plex: nas01\n jellyfin: nas02\n") result = get_orphaned_stacks(cfg) assert result == {"plex": "nas01", "jellyfin": "nas02"} class TestGetStacksNotInState: """Tests for get_stacks_not_in_state function.""" def test_all_in_state(self, config: Config) -> None: """Returns empty list when all stacks are in state.""" state_file = config.get_state_path() state_file.write_text("deployed:\n plex: nas01\n") result = get_stacks_not_in_state(config) assert result == [] def test_finds_missing_service(self, tmp_path: Path) -> None: """Returns stacks in config but not in state.""" config_path = tmp_path / "compose-farm.yaml" config_path.write_text("") cfg = Config( compose_dir=tmp_path / "compose", hosts={"nas01": Host(address="192.168.1.10")}, stacks={"plex": "nas01", "jellyfin": "nas01"}, config_path=config_path, ) state_file = cfg.get_state_path() state_file.write_text("deployed:\n plex: nas01\n") result = get_stacks_not_in_state(cfg) assert result == ["jellyfin"] def test_empty_state(self, tmp_path: Path) -> None: """Returns all stacks when state is empty.""" config_path = tmp_path / "compose-farm.yaml" config_path.write_text("") cfg = Config( compose_dir=tmp_path / "compose", hosts={"nas01": Host(address="192.168.1.10")}, stacks={"plex": "nas01", "jellyfin": "nas01"}, config_path=config_path, ) result = get_stacks_not_in_state(cfg) assert set(result) == {"plex", "jellyfin"} def test_empty_config(self, config: Config) -> None: """Returns empty list when config has no stacks.""" # config fixture has plex: nas01, but we need empty config config_path = config.config_path config_path.write_text("") cfg = Config( compose_dir=config.compose_dir, hosts={"nas01": Host(address="192.168.1.10")}, stacks={}, config_path=config_path, ) result = get_stacks_not_in_state(cfg) assert result == []