Files
claude-scripts/tests/hooks/test_posttooluse.py

262 lines
11 KiB
Python

"""Test PostToolUse hook functionality."""
import tempfile
from unittest.mock import patch
from code_quality_guard import QualityConfig, posttooluse_hook
class TestPostToolUseHook:
"""Test PostToolUse hook behavior."""
def test_non_write_tool_allowed(self):
"""Test that non-write/edit tools are always allowed."""
config = QualityConfig()
hook_data = {
"tool_name": "Read",
"tool_output": {"status": "success"},
}
result = posttooluse_hook(hook_data, config)
assert result["decision"] == "allow"
def test_file_path_extraction_dict(self):
"""Test file path extraction from dict output."""
config = QualityConfig()
test_file = f"{tempfile.gettempdir()}/test.py"
# Test with file_path key
hook_data = {
"tool_name": "Write",
"tool_output": {"file_path": test_file},
}
with patch("pathlib.Path.exists", return_value=True):
with patch("pathlib.Path.read_text", return_value="def test(): pass"):
result = posttooluse_hook(hook_data, config)
assert result["decision"] == "allow"
# Test with path key
hook_data["tool_output"] = {"path": test_file}
with patch("pathlib.Path.exists", return_value=True):
with patch("pathlib.Path.read_text", return_value="def test(): pass"):
result = posttooluse_hook(hook_data, config)
assert result["decision"] == "allow"
def test_file_path_extraction_string(self):
"""Test file path extraction from string output."""
config = QualityConfig()
hook_data = {
"tool_name": "Write",
"tool_output": "File written successfully: /tmp/test.py",
}
with patch("pathlib.Path.exists", return_value=True):
with patch("pathlib.Path.read_text", return_value="def test(): pass"):
result = posttooluse_hook(hook_data, config)
assert result["decision"] == "allow"
def test_non_python_file_skipped(self):
"""Test that non-Python files are skipped."""
config = QualityConfig()
hook_data = {
"tool_name": "Write",
"tool_output": {"file_path": f"{tempfile.gettempdir()}/test.js"},
}
result = posttooluse_hook(hook_data, config)
assert result["decision"] == "allow"
def test_nonexistent_file_skipped(self):
"""Test that nonexistent files are skipped."""
config = QualityConfig()
hook_data = {
"tool_name": "Write",
"tool_output": {"file_path": f"{tempfile.gettempdir()}/nonexistent.py"},
}
with patch("pathlib.Path.exists", return_value=False):
result = posttooluse_hook(hook_data, config)
assert result["decision"] == "allow"
def test_state_tracking_degradation(self):
"""Test state tracking detects quality degradation."""
config = QualityConfig(state_tracking_enabled=True)
hook_data = {
"tool_name": "Write",
"tool_output": {"file_path": f"{tempfile.gettempdir()}/test.py"},
}
with patch("pathlib.Path.exists", return_value=True):
with patch("pathlib.Path.read_text", return_value="def test(): pass"):
with patch("code_quality_guard.check_state_changes") as mock_check:
mock_check.return_value = [
"⚠️ Reduced functions: 5 → 2",
"⚠️ File size increased significantly: 100 → 250 lines",
]
result = posttooluse_hook(hook_data, config)
assert result["decision"] == "allow"
assert "Post-write quality notes" in result["message"]
assert "Reduced functions" in result["message"]
def test_cross_file_duplicates(self):
"""Test cross-file duplicate detection."""
config = QualityConfig(cross_file_check_enabled=True)
hook_data = {
"tool_name": "Write",
"tool_output": {"file_path": f"{tempfile.gettempdir()}/test.py"},
}
with patch("pathlib.Path.exists", return_value=True):
with patch("pathlib.Path.read_text", return_value="def test(): pass"):
with patch(
"code_quality_guard.check_cross_file_duplicates",
) as mock_check:
mock_check.return_value = ["⚠️ Cross-file duplication detected"]
result = posttooluse_hook(hook_data, config)
assert result["decision"] == "allow"
assert "Cross-file duplication" in result["message"]
def test_naming_convention_violations(self, non_pep8_code):
"""Test naming convention verification."""
config = QualityConfig(verify_naming=True)
hook_data = {
"tool_name": "Write",
"tool_output": {"file_path": f"{tempfile.gettempdir()}/test.py"},
}
with patch("pathlib.Path.exists", return_value=True):
with patch("pathlib.Path.read_text", return_value=non_pep8_code):
result = posttooluse_hook(hook_data, config)
assert result["decision"] == "allow"
assert "Non-PEP8 function names" in result["message"]
assert "Non-PEP8 class names" in result["message"]
def test_show_success_message(self, clean_code):
"""Test success message when enabled."""
config = QualityConfig(show_success=True, verify_naming=False)
hook_data = {
"tool_name": "Write",
"tool_output": {"file_path": f"{tempfile.gettempdir()}/test.py"},
}
with patch("pathlib.Path.exists", return_value=True):
with patch("pathlib.Path.read_text", return_value=clean_code):
result = posttooluse_hook(hook_data, config)
assert result["decision"] == "allow"
assert "passed post-write verification" in result["message"]
def test_no_message_when_success_disabled(self, clean_code):
"""Test no message when show_success is disabled."""
config = QualityConfig(show_success=False, verify_naming=False)
hook_data = {
"tool_name": "Write",
"tool_output": {"file_path": f"{tempfile.gettempdir()}/test.py"},
}
with patch("pathlib.Path.exists", return_value=True):
with patch("pathlib.Path.read_text", return_value=clean_code):
result = posttooluse_hook(hook_data, config)
assert result["decision"] == "allow"
assert "message" not in result
def test_all_features_combined(self):
"""Test all PostToolUse features combined."""
config = QualityConfig(
state_tracking_enabled=True,
cross_file_check_enabled=True,
verify_naming=True,
show_success=False,
)
hook_data = {
"tool_name": "Write",
"tool_output": {"file_path": f"{tempfile.gettempdir()}/test.py"},
}
with patch("pathlib.Path.exists", return_value=True):
with patch("pathlib.Path.read_text", return_value="def test(): pass"):
with patch("code_quality_guard.check_state_changes") as mock_state:
with patch(
"code_quality_guard.check_cross_file_duplicates",
) as mock_cross:
with patch(
"code_quality_guard.verify_naming_conventions",
) as mock_naming:
mock_state.return_value = ["⚠️ Issue 1"]
mock_cross.return_value = ["⚠️ Issue 2"]
mock_naming.return_value = ["⚠️ Issue 3"]
result = posttooluse_hook(hook_data, config)
assert result["decision"] == "allow"
assert "Issue 1" in result["message"]
assert "Issue 2" in result["message"]
assert "Issue 3" in result["message"]
def test_edit_tool_output(self):
"""Test Edit tool output handling."""
config = QualityConfig(verify_naming=True)
hook_data = {
"tool_name": "Edit",
"tool_output": {
"file_path": f"{tempfile.gettempdir()}/test.py",
"status": "success",
},
}
with patch("pathlib.Path.exists", return_value=True):
with patch("pathlib.Path.read_text", return_value="def test(): pass"):
result = posttooluse_hook(hook_data, config)
assert result["decision"] == "allow"
def test_multiedit_tool_output(self):
"""Test MultiEdit tool output handling."""
config = QualityConfig(verify_naming=True)
hook_data = {
"tool_name": "MultiEdit",
"tool_output": {
"file_path": f"{tempfile.gettempdir()}/test.py",
"edits_applied": 3,
},
}
with patch("pathlib.Path.exists", return_value=True):
with patch("pathlib.Path.read_text", return_value="def test(): pass"):
result = posttooluse_hook(hook_data, config)
assert result["decision"] == "allow"
def test_features_disabled(self):
"""Test with all features disabled."""
config = QualityConfig(
state_tracking_enabled=False,
cross_file_check_enabled=False,
verify_naming=False,
show_success=False,
)
hook_data = {
"tool_name": "Write",
"tool_output": {"file_path": f"{tempfile.gettempdir()}/test.py"},
}
with patch("pathlib.Path.exists", return_value=True):
with patch("pathlib.Path.read_text", return_value="def test(): pass"):
# Should not call any check functions
with patch("code_quality_guard.check_state_changes") as mock_state:
with patch(
"code_quality_guard.check_cross_file_duplicates",
) as mock_cross:
with patch(
"code_quality_guard.verify_naming_conventions",
) as mock_naming:
result = posttooluse_hook(hook_data, config)
# Verify no checks were called
mock_state.assert_not_called()
mock_cross.assert_not_called()
mock_naming.assert_not_called()
assert result["decision"] == "allow"
assert "message" not in result