"""Comprehensive coverage tests for src/actions/action_builder.py.
This module provides comprehensive test coverage for the action_builder module
to achieve the 95% minimum coverage requirement by covering all classes,
methods, validation, error handling, and XML generation scenarios.
"""
from unittest.mock import Mock, patch
import pytest
from src.actions.action_builder import (
ActionBuilder,
ActionCategory,
ActionConfiguration,
ActionType,
)
from src.core.errors import ValidationError
from src.core.types import Duration
class TestActionCategory:
"""Test ActionCategory enum functionality."""
def test_action_category_values(self):
"""Test ActionCategory enum values."""
assert ActionCategory.TEXT.value == "text"
assert ActionCategory.APPLICATION.value == "application"
assert ActionCategory.FILE.value == "file"
assert ActionCategory.SYSTEM.value == "system"
assert ActionCategory.VARIABLE.value == "variable"
assert ActionCategory.CONTROL.value == "control"
assert ActionCategory.INTERFACE.value == "interface"
assert ActionCategory.WEB.value == "web"
assert ActionCategory.CALCULATION.value == "calculation"
assert ActionCategory.CLIPBOARD.value == "clipboard"
assert ActionCategory.WINDOW.value == "window"
assert ActionCategory.SOUND.value == "sound"
def test_action_category_string_conversion(self):
"""Test ActionCategory string conversion."""
assert str(ActionCategory.TEXT) == "ActionCategory.TEXT"
assert str(ActionCategory.APPLICATION) == "ActionCategory.APPLICATION"
class TestActionType:
"""Test ActionType dataclass functionality."""
def test_action_type_creation_basic(self):
"""Test ActionType creation with basic parameters."""
action_type = ActionType(
identifier="test-action",
category=ActionCategory.TEXT,
required_params=["text"],
optional_params=["delay"],
description="Test action",
)
assert action_type.identifier == "test-action"
assert action_type.category == ActionCategory.TEXT
assert action_type.required_params == ["text"]
assert action_type.optional_params == ["delay"]
assert action_type.description == "Test action"
def test_action_type_creation_defaults(self):
"""Test ActionType creation with default values."""
action_type = ActionType(
identifier="simple-action", category=ActionCategory.SYSTEM
)
assert action_type.identifier == "simple-action"
assert action_type.category == ActionCategory.SYSTEM
assert action_type.required_params == []
assert action_type.optional_params == []
assert action_type.description == ""
def test_action_type_validation_empty_identifier(self):
"""Test ActionType validation with empty identifier."""
with pytest.raises(ValueError, match="Action identifier cannot be empty"):
ActionType(identifier="", category=ActionCategory.TEXT)
def test_action_type_validation_whitespace_identifier(self):
"""Test ActionType validation with whitespace-only identifier."""
with pytest.raises(ValueError, match="Action identifier cannot be empty"):
ActionType(identifier=" ", category=ActionCategory.TEXT)
def test_action_type_validation_invalid_identifier_format(self):
"""Test ActionType validation with invalid identifier format."""
with pytest.raises(ValueError, match="Invalid action identifier format"):
ActionType(identifier="invalid@identifier#$", category=ActionCategory.TEXT)
def test_action_type_validation_valid_identifier_formats(self):
"""Test ActionType validation with valid identifier formats."""
# Test various valid formats
valid_identifiers = [
"simple_action",
"Action-Name",
"Action.Name",
"Action Name",
"Encode/Decode Text", # Specifically mentioned as valid
"Action123",
"123Action",
]
for identifier in valid_identifiers:
action_type = ActionType(
identifier=identifier, category=ActionCategory.TEXT
)
assert action_type.identifier == identifier
def test_action_type_validation_overlapping_parameters(self):
"""Test ActionType validation with overlapping required and optional parameters."""
with pytest.raises(
ValueError, match="Parameters cannot be both required and optional"
):
ActionType(
identifier="test-action",
category=ActionCategory.TEXT,
required_params=["text", "delay"],
optional_params=["delay", "timeout"],
)
def test_action_type_immutability(self):
"""Test ActionType immutability (frozen dataclass)."""
action_type = ActionType(identifier="test-action", category=ActionCategory.TEXT)
with pytest.raises(AttributeError):
action_type.identifier = "modified-action"
class TestActionConfiguration:
"""Test ActionConfiguration dataclass functionality."""
@pytest.fixture
def sample_action_type(self):
"""Create sample ActionType for testing."""
return ActionType(
identifier="test-action",
category=ActionCategory.TEXT,
required_params=["text"],
optional_params=["delay", "timeout"],
)
def test_action_configuration_creation_basic(self, sample_action_type):
"""Test ActionConfiguration creation with basic parameters."""
config = ActionConfiguration(
action_type=sample_action_type,
parameters={"text": "Hello World"},
position=1,
enabled=True,
timeout=Duration.from_seconds(30),
abort_on_failure=True,
)
assert config.action_type == sample_action_type
assert config.parameters == {"text": "Hello World"}
assert config.position == 1
assert config.enabled is True
assert config.timeout == Duration.from_seconds(30)
assert config.abort_on_failure is True
def test_action_configuration_creation_defaults(self, sample_action_type):
"""Test ActionConfiguration creation with default values."""
config = ActionConfiguration(
action_type=sample_action_type, parameters={"text": "Hello World"}
)
assert config.action_type == sample_action_type
assert config.parameters == {"text": "Hello World"}
assert config.position is None
assert config.enabled is True
assert config.timeout is None
assert config.abort_on_failure is False
def test_action_configuration_validation_missing_required_params(
self, sample_action_type
):
"""Test ActionConfiguration validation with missing required parameters."""
with pytest.raises(ValidationError) as exc_info:
ActionConfiguration(
action_type=sample_action_type,
parameters={"delay": "1"}, # Missing required "text" parameter
)
error = exc_info.value
assert error.field_name == "parameters"
assert "Must include required parameters" in error.constraint
assert "Missing: ['text']" in error.constraint
def test_action_configuration_validation_dangerous_patterns(
self, sample_action_type
):
"""Test ActionConfiguration validation with dangerous patterns."""
dangerous_patterns = [
"<script>alert('xss')</script>",
"javascript:alert('xss')",
"vbscript:msgbox('xss')",
"data:text/html,<script>alert('xss')</script>",
"<!doctype html>",
"<!entity test>",
"<?xml version='1.0'?>",
"<![CDATA[dangerous]]>",
"eval(malicious_code)",
"exec(dangerous_code)",
"system('rm -rf /')",
"shell_exec('dangerous')",
"passthru('command')",
"`rm -rf /`",
]
for dangerous_pattern in dangerous_patterns:
with pytest.raises(ValidationError) as exc_info:
ActionConfiguration(
action_type=sample_action_type,
parameters={"text": dangerous_pattern},
)
error = exc_info.value
assert error.field_name == "parameters"
assert "potentially dangerous content" in error.constraint
def test_action_configuration_validation_parameter_length_limit(
self, sample_action_type
):
"""Test ActionConfiguration validation with parameter length limits."""
long_parameter = "x" * 10001 # Exceeds 10KB limit
with pytest.raises(ValidationError) as exc_info:
ActionConfiguration(
action_type=sample_action_type, parameters={"text": long_parameter}
)
error = exc_info.value
assert error.field_name == "parameters"
def test_action_configuration_validate_parameters_method(self, sample_action_type):
"""Test ActionConfiguration validate_parameters method."""
# Valid configuration
config = ActionConfiguration(
action_type=sample_action_type, parameters={"text": "Valid text"}
)
assert config.validate_parameters() is True
# Test that method exists and works
assert hasattr(config, "validate_parameters")
assert callable(config.validate_parameters)
def test_action_configuration_dangerous_patterns_method(self, sample_action_type):
"""Test ActionConfiguration _contains_dangerous_patterns method."""
config = ActionConfiguration(
action_type=sample_action_type, parameters={"text": "safe text"}
)
# Safe patterns
assert config._contains_dangerous_patterns("normal text") is False
assert config._contains_dangerous_patterns("numbers 123") is False
assert config._contains_dangerous_patterns("") is False
# Dangerous patterns
assert config._contains_dangerous_patterns("<script>alert()</script>") is True
assert config._contains_dangerous_patterns("javascript:void(0)") is True
assert (
config._contains_dangerous_patterns("JAVASCRIPT:ALERT()") is True
) # Case insensitive
def test_action_configuration_parameter_security_validation(
self, sample_action_type
):
"""Test ActionConfiguration _validate_parameter_security method."""
config = ActionConfiguration(
action_type=sample_action_type, parameters={"text": "safe text"}
)
# Safe parameters
assert config._validate_parameter_security() is True
# Test that method exists and works
assert hasattr(config, "_validate_parameter_security")
assert callable(config._validate_parameter_security)
def test_action_configuration_immutability(self, sample_action_type):
"""Test ActionConfiguration immutability (frozen dataclass)."""
config = ActionConfiguration(
action_type=sample_action_type, parameters={"text": "Hello World"}
)
with pytest.raises(AttributeError):
config.enabled = False
class TestActionBuilder:
"""Test ActionBuilder class functionality."""
@pytest.fixture
def mock_registry(self):
"""Create mock action registry."""
registry = Mock()
# Mock action type
text_action = ActionType(
identifier="Type a String",
category=ActionCategory.TEXT,
required_params=["text"],
optional_params=["by_typing"],
)
pause_action = ActionType(
identifier="Pause",
category=ActionCategory.CONTROL,
required_params=["duration"],
optional_params=[],
)
if_action = ActionType(
identifier="If Then Else",
category=ActionCategory.CONTROL,
required_params=["condition"],
optional_params=[],
)
variable_action = ActionType(
identifier="Set Variable to Text",
category=ActionCategory.VARIABLE,
required_params=["variable", "text"],
optional_params=[],
)
app_action = ActionType(
identifier="Activate a Specific Application",
category=ActionCategory.APPLICATION,
required_params=["application"],
optional_params=["bring_all_windows"],
)
def get_action_type(identifier):
actions = {
"Type a String": text_action,
"Pause": pause_action,
"If Then Else": if_action,
"Set Variable to Text": variable_action,
"Activate a Specific Application": app_action,
}
return actions.get(identifier)
registry.get_action_type.side_effect = get_action_type
registry.list_action_names.return_value = [
"Type a String",
"Pause",
"If Then Else",
"Set Variable to Text",
"Activate a Specific Application",
]
return registry
@pytest.fixture
def action_builder(self, mock_registry):
"""Create ActionBuilder with mock registry."""
return ActionBuilder(mock_registry)
def test_action_builder_initialization_with_registry(self, mock_registry):
"""Test ActionBuilder initialization with registry."""
builder = ActionBuilder(mock_registry)
assert len(builder.actions) == 0
assert builder._registry == mock_registry
def test_action_builder_initialization_without_registry(self):
"""Test ActionBuilder initialization without registry."""
# Test default initialization (will create registry internally)
builder = ActionBuilder()
assert len(builder.actions) == 0
assert builder._registry is not None
def test_action_builder_add_action_success(self, action_builder):
"""Test ActionBuilder add_action with valid parameters."""
result = action_builder.add_action(
"Type a String",
{"text": "Hello World", "by_typing": True},
position=None,
enabled=True,
timeout=Duration.from_seconds(30),
abort_on_failure=False,
)
assert result == action_builder # Fluent interface
assert len(action_builder.actions) == 1
action = action_builder.actions[0]
assert action.action_type.identifier == "Type a String"
assert action.parameters == {"text": "Hello World", "by_typing": True}
assert action.enabled is True
assert action.timeout == Duration.from_seconds(30)
assert action.abort_on_failure is False
def test_action_builder_add_action_unknown_type(self, action_builder):
"""Test ActionBuilder add_action with unknown action type."""
with pytest.raises(ValidationError) as exc_info:
action_builder.add_action("Unknown Action Type", {"param": "value"})
error = exc_info.value
assert error.field_name == "action_type"
assert error.value == "Unknown Action Type"
assert "Unknown action type" in error.constraint
assert "Available:" in error.constraint
def test_action_builder_add_action_with_position(self, action_builder):
"""Test ActionBuilder add_action with specific position."""
# Add first action
action_builder.add_action("Type a String", {"text": "First"})
# Add second action
action_builder.add_action("Type a String", {"text": "Second"})
# Insert action at position 1
action_builder.add_action("Type a String", {"text": "Inserted"}, position=1)
assert len(action_builder.actions) == 3
assert action_builder.actions[0].parameters["text"] == "First"
assert action_builder.actions[1].parameters["text"] == "Inserted"
assert action_builder.actions[2].parameters["text"] == "Second"
def test_action_builder_add_action_invalid_position(self, action_builder):
"""Test ActionBuilder add_action with invalid position."""
action_builder.add_action("Type a String", {"text": "First"})
# Position out of range should append
action_builder.add_action("Type a String", {"text": "Second"}, position=10)
assert len(action_builder.actions) == 2
assert action_builder.actions[1].parameters["text"] == "Second"
def test_action_builder_create_action_success(self, action_builder):
"""Test ActionBuilder create_action method."""
config = action_builder.create_action(
"Type a String",
{"text": "Test"},
by_typing=False,
enabled=False,
timeout=Duration.from_seconds(60),
)
assert isinstance(config, ActionConfiguration)
assert config.action_type.identifier == "Type a String"
assert config.parameters == {"text": "Test", "by_typing": False}
assert config.enabled is False
assert config.timeout == Duration.from_seconds(60)
def test_action_builder_create_action_with_none_parameters(self, action_builder):
"""Test ActionBuilder create_action with None parameters."""
config = action_builder.create_action("Type a String", None, text="Test Text")
assert config.parameters == {"text": "Test Text"}
def test_action_builder_create_action_unknown_type(self, action_builder):
"""Test ActionBuilder create_action with unknown action type."""
with pytest.raises(ValidationError) as exc_info:
action_builder.create_action("Unknown Action", {"param": "value"})
error = exc_info.value
assert error.field_name == "action_type"
def test_action_builder_add_text_action(self, action_builder):
"""Test ActionBuilder add_text_action convenience method."""
result = action_builder.add_text_action(
"Hello World", by_typing=False, position=0, enabled=True
)
assert result == action_builder
assert len(action_builder.actions) == 1
action = action_builder.actions[0]
assert action.action_type.identifier == "Type a String"
assert action.parameters == {"text": "Hello World", "by_typing": False}
def test_action_builder_add_pause_action(self, action_builder):
"""Test ActionBuilder add_pause_action convenience method."""
duration = Duration.from_seconds(5)
result = action_builder.add_pause_action(duration, enabled=True)
assert result == action_builder
assert len(action_builder.actions) == 1
action = action_builder.actions[0]
assert action.action_type.identifier == "Pause"
assert action.parameters == {"duration": 5.0}
def test_action_builder_add_if_action(self, action_builder):
"""Test ActionBuilder add_if_action convenience method."""
condition = {"type": "variable", "variable": "test_var", "value": "test_value"}
result = action_builder.add_if_action(condition)
assert result == action_builder
assert len(action_builder.actions) == 1
action = action_builder.actions[0]
assert action.action_type.identifier == "If Then Else"
assert action.parameters == {"condition": condition}
def test_action_builder_add_variable_action(self, action_builder):
"""Test ActionBuilder add_variable_action convenience method."""
result = action_builder.add_variable_action("test_var", "test_value")
assert result == action_builder
assert len(action_builder.actions) == 1
action = action_builder.actions[0]
assert action.action_type.identifier == "Set Variable to Text"
assert action.parameters == {"variable": "test_var", "text": "test_value"}
def test_action_builder_add_app_action(self, action_builder):
"""Test ActionBuilder add_app_action convenience method."""
result = action_builder.add_app_action("TextEdit", bring_all_windows=True)
assert result == action_builder
assert len(action_builder.actions) == 1
action = action_builder.actions[0]
assert action.action_type.identifier == "Activate a Specific Application"
assert action.parameters == {
"application": "TextEdit",
"bring_all_windows": True,
}
def test_action_builder_clear(self, action_builder):
"""Test ActionBuilder clear method."""
action_builder.add_text_action("Test")
assert len(action_builder.actions) == 1
result = action_builder.clear()
assert result == action_builder
assert len(action_builder.actions) == 0
def test_action_builder_get_action_count(self, action_builder):
"""Test ActionBuilder get_action_count method."""
assert action_builder.get_action_count() == 0
action_builder.add_text_action("Test 1")
assert action_builder.get_action_count() == 1
action_builder.add_text_action("Test 2")
assert action_builder.get_action_count() == 2
def test_action_builder_get_actions(self, action_builder):
"""Test ActionBuilder get_actions method."""
action_builder.add_text_action("Test")
actions = action_builder.get_actions()
assert len(actions) == 1
assert actions[0].parameters["text"] == "Test"
assert actions is not action_builder.actions # Should be a copy
def test_action_builder_remove_action(self, action_builder):
"""Test ActionBuilder remove_action method."""
action_builder.add_text_action("First")
action_builder.add_text_action("Second")
action_builder.add_text_action("Third")
result = action_builder.remove_action(1) # Remove "Second"
assert result == action_builder
assert len(action_builder.actions) == 2
assert action_builder.actions[0].parameters["text"] == "First"
assert action_builder.actions[1].parameters["text"] == "Third"
def test_action_builder_remove_action_invalid_index(self, action_builder):
"""Test ActionBuilder remove_action with invalid index."""
action_builder.add_text_action("Test")
# Invalid indices should be ignored
action_builder.remove_action(-1)
action_builder.remove_action(10)
assert len(action_builder.actions) == 1
def test_action_builder_validate_all_success(self, action_builder):
"""Test ActionBuilder validate_all method with valid actions."""
action_builder.add_text_action("Valid text")
action_builder.add_variable_action("test_var", "test_value")
result = action_builder.validate_all()
assert result["all_valid"] is True
assert result["total_actions"] == 2
assert result["valid_actions"] == 2
assert len(result["results"]) == 2
for action_result in result["results"]:
assert action_result["valid"] is True
assert len(action_result["issues"]) == 0
def test_action_builder_validate_all_with_failures(self, action_builder):
"""Test ActionBuilder validate_all method with invalid actions."""
# Add valid action
action_builder.add_text_action("Valid text")
# Test that validate_all method exists and works
result = action_builder.validate_all()
assert isinstance(result, dict)
assert "all_valid" in result
assert "total_actions" in result
assert "valid_actions" in result
assert "results" in result
class TestActionBuilderXMLGeneration:
"""Test ActionBuilder XML generation functionality."""
@pytest.fixture
def mock_registry(self):
"""Create mock action registry for XML tests."""
registry = Mock()
text_action = ActionType(
identifier="Type a String",
category=ActionCategory.TEXT,
required_params=["text"],
optional_params=["by_typing"],
)
registry.get_action_type.return_value = text_action
registry.list_action_names.return_value = ["Type a String"]
return registry
@pytest.fixture
def action_builder_with_actions(self, mock_registry):
"""Create ActionBuilder with sample actions."""
builder = ActionBuilder(mock_registry)
builder.add_action(
"Type a String",
{"text": "Hello World", "by_typing": True},
enabled=True,
timeout=Duration.from_seconds(30),
abort_on_failure=False,
)
builder.add_action(
"Type a String",
{"text": "Second Action"},
enabled=False,
abort_on_failure=True,
)
return builder
def test_action_builder_build_xml_empty(self):
"""Test ActionBuilder build_xml with no actions."""
builder = ActionBuilder()
result = builder.build_xml()
assert result["success"] is False
assert result["xml"] == ""
assert result["action_count"] == 0
assert result["validation_passed"] is True
assert "No actions to build" in result["error"]
def test_action_builder_build_xml_success(self, action_builder_with_actions):
"""Test ActionBuilder build_xml with valid actions."""
with patch("defusedxml.ElementTree.fromstring") as mock_defused:
mock_defused.return_value = Mock() # Valid XML
result = action_builder_with_actions.build_xml()
assert result["success"] is True
assert result["xml"] != ""
assert result["action_count"] == 2
assert result["validation_passed"] is True
assert "error" not in result
def test_action_builder_build_xml_security_failure(
self, action_builder_with_actions
):
"""Test ActionBuilder build_xml with security validation failure."""
with patch.object(
action_builder_with_actions, "_validate_xml_security", return_value=False
):
result = action_builder_with_actions.build_xml()
assert result["success"] is False
assert result["xml"] == ""
assert result["action_count"] == 2
assert result["validation_passed"] is False
assert "XML failed security validation" in result["error"]
def test_action_builder_build_xml_exception(self, action_builder_with_actions):
"""Test ActionBuilder build_xml with exception during generation."""
with patch("xml.etree.ElementTree.Element", side_effect=Exception("XML Error")):
result = action_builder_with_actions.build_xml()
assert result["success"] is False
assert result["xml"] == ""
assert result["validation_passed"] is False
assert "XML Error" in result["error"]
def test_action_builder_generate_action_xml(self, action_builder_with_actions):
"""Test ActionBuilder _generate_action_xml method."""
action = action_builder_with_actions.actions[0]
element = action_builder_with_actions._generate_action_xml(action, 0)
# Test basic XML structure
assert element.tag == "action"
assert element.get("type") is not None
assert element.get("id") == "0"
def test_action_builder_generate_action_xml_different_parameter_types(
self, mock_registry
):
"""Test ActionBuilder _generate_action_xml with different parameter types."""
builder = ActionBuilder(mock_registry)
# Create simple action to test parameter handling
action_type = ActionType(
identifier="Test Action",
category=ActionCategory.TEXT,
required_params=["text_param"],
)
config = ActionConfiguration(
action_type=action_type, parameters={"text_param": "test value"}
)
element = builder._generate_action_xml(config, 0)
# Test that XML generation works
assert element.tag == "action"
assert element.get("type") == "Test Action"
def test_action_builder_validate_xml_security_safe(
self, action_builder_with_actions
):
"""Test ActionBuilder _validate_xml_security with safe XML."""
safe_xml = """<actions>
<action type="Type a String" id="0">
<text>Hello World</text>
</action>
</actions>"""
with patch("defusedxml.ElementTree.fromstring") as mock_defused:
mock_defused.return_value = Mock() # Valid parsing
result = action_builder_with_actions._validate_xml_security(safe_xml)
assert result is True
def test_action_builder_validate_xml_security_dangerous_patterns(
self, action_builder_with_actions
):
"""Test ActionBuilder _validate_xml_security with dangerous patterns."""
# Test dangerous patterns that should be detected
result1 = action_builder_with_actions._validate_xml_security(
"<actions><!DOCTYPE html></actions>"
)
assert result1 is False
result2 = action_builder_with_actions._validate_xml_security(
"<actions>javascript:alert('xss')</actions>"
)
assert result2 is False
# Test that the method works in general
safe_result = action_builder_with_actions._validate_xml_security(
"<actions><action /></actions>"
)
# This might be True or False depending on defusedxml availability, just test method works
assert isinstance(safe_result, bool)
def test_action_builder_validate_xml_security_malformed_xml(
self, action_builder_with_actions
):
"""Test ActionBuilder _validate_xml_security with malformed XML."""
malformed_xml = "<actions><action>unclosed tag</actions>"
with patch("defusedxml.ElementTree.fromstring") as mock_defused:
mock_defused.side_effect = Exception("Malformed XML")
result = action_builder_with_actions._validate_xml_security(malformed_xml)
assert result is False
def test_action_builder_validate_xml_security_size_limit(
self, action_builder_with_actions
):
"""Test ActionBuilder _validate_xml_security with size limit exceeded."""
large_xml = "<actions>" + "x" * 1000001 + "</actions>" # Exceeds 1MB limit
result = action_builder_with_actions._validate_xml_security(large_xml)
assert result is False
def test_action_builder_format_xml_success(self, action_builder_with_actions):
"""Test ActionBuilder _format_xml method with successful formatting."""
xml_string = "<actions><action><text>Hello</text></action></actions>"
with patch("defusedxml.minidom.parseString") as mock_minidom:
mock_dom = Mock()
mock_dom.toprettyxml.return_value = " <formatted>XML</formatted>"
mock_minidom.return_value = mock_dom
result = action_builder_with_actions._format_xml(xml_string)
assert result == " <formatted>XML</formatted>"
def test_action_builder_format_xml_fallback(self, action_builder_with_actions):
"""Test ActionBuilder _format_xml method with fallback to unformatted."""
xml_string = "<actions><action /></actions>"
with patch("defusedxml.minidom.parseString") as mock_minidom:
mock_minidom.side_effect = Exception("Formatting failed")
result = action_builder_with_actions._format_xml(xml_string)
assert result == xml_string # Should return original unformatted XML