"""Tests for Docker service functionality.""" import pytest from datetime import datetime, timezone from contextlib import asynccontextmanager from unittest.mock import AsyncMock, MagicMock, patch from app.models.docker_container import DockerContainer from app.models.docker_image import DockerImage from app.models.docker_volume import DockerVolume from app.models.docker_alert import DockerAlert from app.crud.docker_container import DockerContainerRepository from app.crud.docker_image import DockerImageRepository from app.crud.docker_volume import DockerVolumeRepository from app.crud.docker_alert import DockerAlertRepository class TestDockerContainerRepository: """Tests for DockerContainerRepository.""" @pytest.mark.asyncio async def test_upsert_creates_new_container(self, db_session): """Test that upsert creates a new container when it doesn't exist.""" repo = DockerContainerRepository(db_session) container = await repo.upsert( host_id="test-host-1", container_id="abc123def456", name="nginx", image="nginx:latest", state="running", status="Up 2 hours" ) assert container.name == "nginx" assert container.state == "running" assert container.container_id == "abc123def456" @pytest.mark.asyncio async def test_upsert_updates_existing_container(self, db_session): """Test that upsert updates an existing container.""" repo = DockerContainerRepository(db_session) # Create first await repo.upsert( host_id="test-host-1", container_id="abc123def456", name="nginx", state="running" ) await db_session.commit() # Update container = await repo.upsert( host_id="test-host-1", container_id="abc123def456", name="nginx", state="exited" ) assert container.state == "exited" @pytest.mark.asyncio async def test_list_by_host(self, db_session): """Test listing containers by host.""" repo = DockerContainerRepository(db_session) await repo.upsert(host_id="host-1", container_id="c1", name="container1", state="running") await repo.upsert(host_id="host-1", container_id="c2", name="container2", state="exited") await repo.upsert(host_id="host-2", container_id="c3", name="container3", state="running") await db_session.commit() host1_containers = await repo.list_by_host("host-1") assert len(host1_containers) == 2 @pytest.mark.asyncio async def test_count_by_host(self, db_session): """Test counting containers by host.""" repo = DockerContainerRepository(db_session) await repo.upsert(host_id="host-1", container_id="c1", name="container1", state="running") await repo.upsert(host_id="host-1", container_id="c2", name="container2", state="running") await repo.upsert(host_id="host-1", container_id="c3", name="container3", state="exited") await db_session.commit() counts = await repo.count_by_host("host-1") assert counts["total"] == 3 assert counts["running"] == 2 class TestDockerAlertRepository: """Tests for DockerAlertRepository.""" @pytest.mark.asyncio async def test_create_alert(self, db_session): """Test creating a new alert.""" repo = DockerAlertRepository(db_session) alert = await repo.create( host_id="test-host-1", container_name="nginx", severity="error", message="Container is down" ) await db_session.commit() assert alert.id is not None assert alert.state == "open" assert alert.severity == "error" @pytest.mark.asyncio async def test_get_open_alert(self, db_session): """Test getting an open alert for a container.""" repo = DockerAlertRepository(db_session) await repo.create( host_id="test-host-1", container_name="nginx", severity="error", message="Container is down" ) await db_session.commit() alert = await repo.get_open_alert("test-host-1", "nginx") assert alert is not None assert alert.container_name == "nginx" @pytest.mark.asyncio async def test_acknowledge_alert(self, db_session): """Test acknowledging an alert.""" repo = DockerAlertRepository(db_session) alert = await repo.create( host_id="test-host-1", container_name="nginx", severity="error", message="Container is down" ) await db_session.commit() acknowledged = await repo.acknowledge(alert.id, "admin") assert acknowledged is not None assert acknowledged.state == "acknowledged" assert acknowledged.acknowledged_by == "admin" @pytest.mark.asyncio async def test_close_alert(self, db_session): """Test closing an alert.""" repo = DockerAlertRepository(db_session) alert = await repo.create( host_id="test-host-1", container_name="nginx", severity="error", message="Container is down" ) await db_session.commit() closed = await repo.close(alert.id) assert closed is not None assert closed.state == "closed" assert closed.closed_at is not None @pytest.mark.asyncio async def test_close_for_container(self, db_session): """Test closing all alerts for a container.""" repo = DockerAlertRepository(db_session) await repo.create(host_id="host-1", container_name="nginx", severity="error", message="Down") await repo.create(host_id="host-1", container_name="nginx", severity="warning", message="Health check") await db_session.commit() count = await repo.close_for_container("host-1", "nginx") assert count == 2 class TestDockerAlertDetection: """Tests for Docker alert detection logic.""" @pytest.mark.asyncio async def test_detect_container_down(self): """Test detection of container that should be running but is stopped.""" container = MagicMock() container.state = "exited" container.health = None container.labels = {"homelab.monitor": "true", "homelab.desired": "running"} container.name = "nginx" container.host_id = "host-1" # Container is exited but should be running -> should trigger alert expected_state = container.labels.get("homelab.desired", "running") should_alert = expected_state == "running" and container.state != "running" assert should_alert is True @pytest.mark.asyncio async def test_detect_unhealthy_container(self): """Test detection of unhealthy container.""" container = MagicMock() container.state = "running" container.health = "unhealthy" container.labels = {"homelab.monitor": "true"} container.name = "nginx" # Container is unhealthy -> should trigger warning should_alert = container.health == "unhealthy" assert should_alert is True @pytest.mark.asyncio async def test_no_alert_for_healthy_running_container(self): """Test that healthy running containers don't trigger alerts.""" container = MagicMock() container.state = "running" container.health = "healthy" container.labels = {"homelab.monitor": "true", "homelab.desired": "running"} expected_state = container.labels.get("homelab.desired", "running") should_close_alert = container.state == "running" and container.health in ("healthy", None) assert should_close_alert is True class TestDockerServiceSSH: """Tests for Docker service SSH functionality.""" @pytest.mark.asyncio async def test_parse_docker_json_lines(self): """Test parsing Docker JSON output.""" from app.services.docker_service import DockerService service = DockerService() output = '{"ID":"abc123","Names":"nginx","State":"running"}\n{"ID":"def456","Names":"redis","State":"exited"}' result = await service._parse_docker_json_lines(output) assert len(result) == 2 assert result[0]["ID"] == "abc123" assert result[1]["State"] == "exited" def test_parse_size(self): """Test parsing Docker size strings.""" from app.services.docker_service import DockerService service = DockerService() assert service._parse_size("100MB") == 100 * 1024 * 1024 assert service._parse_size("1.5GB") == int(1.5 * 1024 * 1024 * 1024) assert service._parse_size("500KB") == 500 * 1024 assert service._parse_size("") == 0 assert service._parse_size("invalid") == 0 class TestDockerServiceHostFiltering: """Tests for Docker host listing and filtering.""" @pytest.mark.asyncio async def test_get_docker_hosts_filters_role_docker(self): """Only hosts in inventory group role_docker must be visible in Docker section.""" from app.services.docker_service import docker_service host1 = MagicMock() host1.id = "host-1" host1.name = "docker-1" host1.ip_address = "10.0.0.1" host1.deleted_at = None host1.docker_enabled = True host1.docker_version = None host1.docker_status = None host1.docker_last_collect_at = None host2 = MagicMock() host2.id = "host-2" host2.name = "docker-2" host2.ip_address = "10.0.0.2" host2.deleted_at = None host2.docker_enabled = False host2.docker_version = None host2.docker_status = None host2.docker_last_collect_at = None host3 = MagicMock() host3.id = "host-3" host3.name = "not-docker" host3.ip_address = "10.0.0.3" host3.deleted_at = None host3.docker_enabled = True host3.docker_version = None host3.docker_status = None host3.docker_last_collect_at = None inv_h1 = MagicMock() inv_h1.name = "docker-1" inv_h1.groups = ["role_docker"] inv_h2 = MagicMock() inv_h2.name = "docker-2" inv_h2.groups = ["role_docker"] inv_h3 = MagicMock() inv_h3.name = "not-docker" inv_h3.groups = ["role_web"] @asynccontextmanager async def _dummy_session_ctx(): yield MagicMock() host_repo_instance = MagicMock() host_repo_instance.list = AsyncMock(return_value=[host1, host2, host3]) container_repo_instance = MagicMock() container_repo_instance.count_by_host = AsyncMock(return_value={"total": 0, "running": 0}) image_repo_instance = MagicMock() image_repo_instance.count_by_host = AsyncMock(return_value={"total": 0, "total_size": 0}) volume_repo_instance = MagicMock() volume_repo_instance.count_by_host = AsyncMock(return_value=0) alert_repo_instance = MagicMock() alert_repo_instance.count_open_by_host = AsyncMock(return_value=0) with patch("app.services.docker_service.async_session_maker", new=_dummy_session_ctx): with patch("app.services.docker_service.ansible_service.get_hosts_from_inventory", return_value=[inv_h1, inv_h2, inv_h3]): with patch("app.services.docker_service.HostRepository", return_value=host_repo_instance): with patch("app.services.docker_service.DockerContainerRepository", return_value=container_repo_instance): with patch("app.services.docker_service.DockerImageRepository", return_value=image_repo_instance): with patch("app.services.docker_service.DockerVolumeRepository", return_value=volume_repo_instance): with patch("app.services.docker_service.DockerAlertRepository", return_value=alert_repo_instance): hosts = await docker_service.get_docker_hosts() assert [h["host_name"] for h in hosts] == ["docker-1", "docker-2"] by_id = {h["host_id"]: h for h in hosts} assert by_id["host-1"]["docker_enabled"] is True assert by_id["host-2"]["docker_enabled"] is False class TestDockerActions: """Tests for Docker container actions.""" @pytest.mark.asyncio async def test_start_container_success(self): """Test successful container start.""" from app.services.docker_actions import DockerActionsService service = DockerActionsService() with patch.object(service, '_get_host_info', new_callable=AsyncMock) as mock_host: mock_host.return_value = {"id": "host-1", "name": "test-host", "ip": "192.168.1.10"} with patch.object(service, '_ssh_connect', new_callable=AsyncMock) as mock_connect: mock_conn = AsyncMock() mock_connect.return_value = mock_conn with patch.object(service, '_ssh_exec', new_callable=AsyncMock) as mock_exec: mock_exec.return_value = { "success": True, "stdout": "abc123", "stderr": "", "exit_code": 0 } result = await service.start_container("host-1", "abc123") assert result["success"] is True assert result["action"] == "start" @pytest.mark.asyncio async def test_stop_container_success(self): """Test successful container stop.""" from app.services.docker_actions import DockerActionsService service = DockerActionsService() with patch.object(service, '_get_host_info', new_callable=AsyncMock) as mock_host: mock_host.return_value = {"id": "host-1", "name": "test-host", "ip": "192.168.1.10"} with patch.object(service, '_ssh_connect', new_callable=AsyncMock) as mock_connect: mock_conn = AsyncMock() mock_connect.return_value = mock_conn with patch.object(service, '_ssh_exec', new_callable=AsyncMock) as mock_exec: mock_exec.return_value = { "success": True, "stdout": "abc123", "stderr": "", "exit_code": 0 } result = await service.stop_container("host-1", "abc123") assert result["success"] is True assert result["action"] == "stop"