"""Unit tests for NextcloudSetDomainExecutor.""" import asyncio from pathlib import Path from unittest.mock import MagicMock, patch import pytest # Patch the logger before importing the executor with patch("app.utils.logger.get_logger", return_value=MagicMock()): from app.executors.nextcloud_executor import NextcloudSetDomainExecutor class TestNextcloudSetDomainExecutor: """Tests for NextcloudSetDomainExecutor.""" @pytest.fixture def executor(self): """Create executor with mocked logger.""" with patch("app.executors.base.get_logger", return_value=MagicMock()): return NextcloudSetDomainExecutor() @pytest.fixture def temp_nextcloud_stack(self, tmp_path): """Create a temporary Nextcloud stack directory with compose file.""" stack_dir = tmp_path / "opt" / "letsbe" / "stacks" / "nextcloud" stack_dir.mkdir(parents=True) compose_file = stack_dir / "docker-compose.yml" compose_file.write_text("""version: '3.8' services: app: image: nextcloud:latest """) return stack_dir @pytest.fixture def executor_with_temp_stack(self, executor, temp_nextcloud_stack): """Configure executor to use temporary stack directory.""" executor.NEXTCLOUD_STACK_DIR = str(temp_nextcloud_stack) return executor # ========================================================================= # TASK TYPE TEST # ========================================================================= def test_task_type(self, executor): """Test that task_type property returns correct value.""" assert executor.task_type == "NEXTCLOUD_SET_DOMAIN" # ========================================================================= # URL PARSING TESTS # ========================================================================= def test_parse_public_url_with_https(self, executor): """Test URL parsing with explicit https scheme.""" scheme, host, normalized_url = executor._parse_public_url("https://cloud.example.com") assert scheme == "https" assert host == "cloud.example.com" assert normalized_url == "https://cloud.example.com" def test_parse_public_url_with_http(self, executor): """Test URL parsing with http scheme.""" scheme, host, normalized_url = executor._parse_public_url("http://cloud.example.com") assert scheme == "http" assert host == "cloud.example.com" assert normalized_url == "http://cloud.example.com" def test_parse_public_url_with_port(self, executor): """Test URL parsing with port number.""" scheme, host, normalized_url = executor._parse_public_url("https://cloud.example.com:8443") assert scheme == "https" assert host == "cloud.example.com:8443" assert normalized_url == "https://cloud.example.com:8443" def test_parse_public_url_without_scheme_defaults_to_https(self, executor): """Test that URLs without scheme default to https.""" scheme, host, normalized_url = executor._parse_public_url("cloud.example.com") assert scheme == "https" assert host == "cloud.example.com" assert normalized_url == "https://cloud.example.com" def test_parse_public_url_trailing_slash_stripped(self, executor): """Test that trailing slash is stripped from URL.""" scheme, host, normalized_url = executor._parse_public_url("https://cloud.example.com/") assert scheme == "https" assert host == "cloud.example.com" assert normalized_url == "https://cloud.example.com" def test_parse_public_url_with_path(self, executor): """Test URL parsing with path (trailing slash stripped).""" scheme, host, normalized_url = executor._parse_public_url("https://cloud.example.com/nextcloud/") assert scheme == "https" assert host == "cloud.example.com" assert normalized_url == "https://cloud.example.com/nextcloud" def test_parse_public_url_empty_raises_error(self, executor): """Test that empty URL raises ValueError.""" with pytest.raises(ValueError, match="cannot be empty"): executor._parse_public_url("") def test_parse_public_url_whitespace_only_raises_error(self, executor): """Test that whitespace-only URL raises ValueError.""" with pytest.raises(ValueError, match="cannot be empty"): executor._parse_public_url(" ") def test_parse_public_url_invalid_no_host_raises_error(self, executor): """Test that URL with no host raises ValueError.""" with pytest.raises(ValueError, match="no host found"): executor._parse_public_url("https://") # ========================================================================= # SUCCESS CASES # ========================================================================= @pytest.mark.asyncio async def test_success_all_commands(self, executor_with_temp_stack): """Test successful domain configuration with all three occ commands.""" with patch.object(executor_with_temp_stack, "_run_occ_command") as mock_run: mock_run.return_value = (0, "System config value overwritehost set to cloud.example.com", "") result = await executor_with_temp_stack.execute({ "public_url": "https://cloud.example.com" }) assert result.success is True assert result.data["public_url"] == "https://cloud.example.com" assert result.data["host"] == "cloud.example.com" assert result.data["scheme"] == "https" assert result.data["commands_executed"] == 3 assert "overwritehost" in result.data["logs"] assert "overwriteprotocol" in result.data["logs"] assert "overwrite.cli.url" in result.data["logs"] assert result.error is None assert result.duration_ms is not None # Verify all three commands were called assert mock_run.call_count == 3 @pytest.mark.asyncio async def test_success_with_http_scheme(self, executor_with_temp_stack): """Test successful configuration with http scheme.""" with patch.object(executor_with_temp_stack, "_run_occ_command") as mock_run: mock_run.return_value = (0, "success", "") result = await executor_with_temp_stack.execute({ "public_url": "http://cloud.example.com" }) assert result.success is True assert result.data["scheme"] == "http" assert result.data["public_url"] == "http://cloud.example.com" @pytest.mark.asyncio async def test_success_url_without_scheme(self, executor_with_temp_stack): """Test successful configuration when URL lacks scheme (defaults to https).""" with patch.object(executor_with_temp_stack, "_run_occ_command") as mock_run: mock_run.return_value = (0, "success", "") result = await executor_with_temp_stack.execute({ "public_url": "cloud.example.com" }) assert result.success is True assert result.data["scheme"] == "https" assert result.data["host"] == "cloud.example.com" assert result.data["public_url"] == "https://cloud.example.com" # ========================================================================= # COMMAND ARGUMENTS TESTS # ========================================================================= @pytest.mark.asyncio async def test_command_arguments_correct(self, executor_with_temp_stack): """Test that occ commands receive correct arguments.""" calls = [] async def capture_calls(compose_file, occ_args, timeout): calls.append(occ_args) return (0, "success", "") with patch.object(executor_with_temp_stack, "_run_occ_command", side_effect=capture_calls): await executor_with_temp_stack.execute({ "public_url": "https://cloud.example.com" }) assert len(calls) == 3 assert calls[0] == ["config:system:set", "overwritehost", "--value=cloud.example.com"] assert calls[1] == ["config:system:set", "overwriteprotocol", "--value=https"] assert calls[2] == ["config:system:set", "overwrite.cli.url", "--value=https://cloud.example.com"] @pytest.mark.asyncio async def test_command_arguments_with_port(self, executor_with_temp_stack): """Test that host with port is passed correctly to occ command.""" calls = [] async def capture_calls(compose_file, occ_args, timeout): calls.append(occ_args) return (0, "success", "") with patch.object(executor_with_temp_stack, "_run_occ_command", side_effect=capture_calls): await executor_with_temp_stack.execute({ "public_url": "https://cloud.example.com:8443" }) assert calls[0] == ["config:system:set", "overwritehost", "--value=cloud.example.com:8443"] # ========================================================================= # PAYLOAD VALIDATION TESTS # ========================================================================= @pytest.mark.asyncio async def test_missing_public_url_raises_error(self, executor_with_temp_stack): """Test that missing public_url raises ValueError.""" with pytest.raises(ValueError, match="Missing required fields"): await executor_with_temp_stack.execute({}) @pytest.mark.asyncio async def test_invalid_url_returns_failure(self, executor_with_temp_stack): """Test that invalid URL returns failure result.""" result = await executor_with_temp_stack.execute({ "public_url": "" }) assert result.success is False assert "cannot be empty" in result.error # ========================================================================= # COMPOSE FILE TESTS # ========================================================================= @pytest.mark.asyncio async def test_compose_file_not_found(self, executor, tmp_path): """Test failure when compose file doesn't exist.""" nonexistent_dir = tmp_path / "nonexistent" nonexistent_dir.mkdir() executor.NEXTCLOUD_STACK_DIR = str(nonexistent_dir) result = await executor.execute({ "public_url": "https://cloud.example.com" }) assert result.success is False assert "compose file not found" in result.error.lower() assert "docker-compose.yml" in result.error @pytest.mark.asyncio async def test_compose_yml_fallback(self, executor, tmp_path): """Test that compose.yml is used as fallback.""" stack_dir = tmp_path / "nextcloud" stack_dir.mkdir() (stack_dir / "compose.yml").write_text("version: '3'\n") executor.NEXTCLOUD_STACK_DIR = str(stack_dir) with patch.object(executor, "_run_occ_command") as mock_run: mock_run.return_value = (0, "success", "") result = await executor.execute({ "public_url": "https://cloud.example.com" }) assert result.success is True # ========================================================================= # COMMAND FAILURE TESTS # ========================================================================= @pytest.mark.asyncio async def test_first_command_fails(self, executor_with_temp_stack): """Test that failure on first occ command returns partial results.""" with patch.object(executor_with_temp_stack, "_run_occ_command") as mock_run: mock_run.return_value = (1, "", "Error: Unable to write config") result = await executor_with_temp_stack.execute({ "public_url": "https://cloud.example.com" }) assert result.success is False assert result.data["commands_executed"] == 1 assert result.data["failed_command"] == "overwritehost" assert result.data["failed_args"] == ["config:system:set", "overwritehost", "--value=cloud.example.com"] assert "overwritehost" in result.data["logs"] assert "occ overwritehost failed with exit code 1" in result.error @pytest.mark.asyncio async def test_second_command_fails(self, executor_with_temp_stack): """Test that failure on second occ command returns partial results.""" call_count = 0 async def mock_occ(compose_file, occ_args, timeout): nonlocal call_count call_count += 1 if call_count == 1: return (0, "success", "") # First succeeds return (1, "", "Error setting overwriteprotocol") # Second fails with patch.object(executor_with_temp_stack, "_run_occ_command", side_effect=mock_occ): result = await executor_with_temp_stack.execute({ "public_url": "https://cloud.example.com" }) assert result.success is False assert result.data["commands_executed"] == 2 assert result.data["failed_command"] == "overwriteprotocol" assert "overwritehost" in result.data["logs"] assert "overwriteprotocol" in result.data["logs"] assert "overwrite.cli.url" not in result.data["logs"] @pytest.mark.asyncio async def test_third_command_fails(self, executor_with_temp_stack): """Test that failure on third occ command returns partial results.""" call_count = 0 async def mock_occ(compose_file, occ_args, timeout): nonlocal call_count call_count += 1 if call_count <= 2: return (0, "success", "") # First two succeed return (1, "", "Error setting overwrite.cli.url") # Third fails with patch.object(executor_with_temp_stack, "_run_occ_command", side_effect=mock_occ): result = await executor_with_temp_stack.execute({ "public_url": "https://cloud.example.com" }) assert result.success is False assert result.data["commands_executed"] == 3 assert result.data["failed_command"] == "overwrite.cli.url" assert "overwritehost" in result.data["logs"] assert "overwriteprotocol" in result.data["logs"] assert "overwrite.cli.url" in result.data["logs"] # ========================================================================= # TIMEOUT HANDLING TESTS # ========================================================================= @pytest.mark.asyncio async def test_timeout_handling(self, executor_with_temp_stack): """Test that asyncio.TimeoutError is caught and returns failure.""" with patch.object(executor_with_temp_stack, "_run_occ_command") as mock_run: mock_run.side_effect = asyncio.TimeoutError() result = await executor_with_temp_stack.execute({ "public_url": "https://cloud.example.com" }) assert result.success is False assert "timed out" in result.error.lower() assert result.data["commands_executed"] == 0 assert result.duration_ms is not None @pytest.mark.asyncio async def test_timeout_after_partial_success(self, executor_with_temp_stack): """Test timeout after some commands succeeded.""" call_count = 0 async def mock_occ(compose_file, occ_args, timeout): nonlocal call_count call_count += 1 if call_count == 1: return (0, "success", "") raise asyncio.TimeoutError() with patch.object(executor_with_temp_stack, "_run_occ_command", side_effect=mock_occ): result = await executor_with_temp_stack.execute({ "public_url": "https://cloud.example.com" }) assert result.success is False assert "timed out" in result.error.lower() assert result.data["commands_executed"] == 1 assert "overwritehost" in result.data["logs"] # ========================================================================= # EXCEPTION HANDLING TESTS # ========================================================================= @pytest.mark.asyncio async def test_unexpected_exception_handling(self, executor_with_temp_stack): """Test that unexpected exceptions are caught and returned.""" with patch.object(executor_with_temp_stack, "_run_occ_command") as mock_run: mock_run.side_effect = RuntimeError("Unexpected docker error") result = await executor_with_temp_stack.execute({ "public_url": "https://cloud.example.com" }) assert result.success is False assert "Unexpected docker error" in result.error assert result.duration_ms is not None # ========================================================================= # RESULT STRUCTURE TESTS # ========================================================================= @pytest.mark.asyncio async def test_result_structure_on_success(self, executor_with_temp_stack): """Test that successful result contains all expected keys.""" with patch.object(executor_with_temp_stack, "_run_occ_command") as mock_run: mock_run.return_value = (0, "success", "") result = await executor_with_temp_stack.execute({ "public_url": "https://cloud.example.com" }) assert result.success is True assert "public_url" in result.data assert "host" in result.data assert "scheme" in result.data assert "commands_executed" in result.data assert "logs" in result.data assert result.error is None assert result.duration_ms is not None @pytest.mark.asyncio async def test_result_structure_on_failure(self, executor_with_temp_stack): """Test that failure result contains all expected keys including failed_command.""" with patch.object(executor_with_temp_stack, "_run_occ_command") as mock_run: mock_run.return_value = (1, "", "error") result = await executor_with_temp_stack.execute({ "public_url": "https://cloud.example.com" }) assert result.success is False assert "public_url" in result.data assert "host" in result.data assert "scheme" in result.data assert "commands_executed" in result.data assert "failed_command" in result.data assert "failed_args" in result.data assert "logs" in result.data assert result.error is not None assert result.duration_ms is not None # ========================================================================= # OUTPUT COMBINATION TESTS # ========================================================================= def test_combine_output_both(self, executor): """Test combining stdout and stderr.""" result = executor._combine_output("stdout content", "stderr content") assert "stdout content" in result assert "stderr content" in result def test_combine_output_stdout_only(self, executor): """Test combining with only stdout.""" result = executor._combine_output("stdout content", "") assert result == "stdout content" def test_combine_output_stderr_only(self, executor): """Test combining with only stderr.""" result = executor._combine_output("", "stderr content") assert result == "stderr content" def test_combine_output_empty(self, executor): """Test combining empty outputs.""" result = executor._combine_output("", "") assert result == "" # ========================================================================= # FIND COMPOSE FILE TESTS # ========================================================================= def test_find_compose_file_docker_compose_yml(self, executor, tmp_path): """Test finding docker-compose.yml.""" (tmp_path / "docker-compose.yml").write_text("version: '3'\n") result = executor._find_compose_file(tmp_path) assert result == tmp_path / "docker-compose.yml" def test_find_compose_file_compose_yml(self, executor, tmp_path): """Test finding compose.yml as fallback.""" (tmp_path / "compose.yml").write_text("version: '3'\n") result = executor._find_compose_file(tmp_path) assert result == tmp_path / "compose.yml" def test_find_compose_file_prefers_docker_compose_yml(self, executor, tmp_path): """Test that docker-compose.yml is preferred over compose.yml.""" (tmp_path / "docker-compose.yml").write_text("version: '3'\n") (tmp_path / "compose.yml").write_text("version: '3'\n") result = executor._find_compose_file(tmp_path) assert result == tmp_path / "docker-compose.yml" def test_find_compose_file_not_found(self, executor, tmp_path): """Test returning None when no compose file found.""" result = executor._find_compose_file(tmp_path) assert result is None # ========================================================================= # CUSTOM TIMEOUT TESTS # ========================================================================= @pytest.mark.asyncio async def test_custom_timeout_passed(self, executor_with_temp_stack): """Test that custom timeout from payload is passed to _run_occ_command.""" received_timeouts = [] async def capture_timeout(compose_file, occ_args, timeout): received_timeouts.append(timeout) return (0, "success", "") with patch.object(executor_with_temp_stack, "_run_occ_command", side_effect=capture_timeout): await executor_with_temp_stack.execute({ "public_url": "https://cloud.example.com", "timeout": 120, }) assert all(t == 120 for t in received_timeouts) @pytest.mark.asyncio async def test_default_timeout_used(self, executor_with_temp_stack): """Test that default timeout is used when not specified in payload.""" received_timeouts = [] async def capture_timeout(compose_file, occ_args, timeout): received_timeouts.append(timeout) return (0, "success", "") with patch.object(executor_with_temp_stack, "_run_occ_command", side_effect=capture_timeout): await executor_with_temp_stack.execute({ "public_url": "https://cloud.example.com", }) assert all(t == executor_with_temp_stack.DEFAULT_COMMAND_TIMEOUT for t in received_timeouts)