claude-os/tests/test_hooks.py
2025-12-02 15:28:25 -06:00

634 lines
22 KiB
Python

"""
Tests for hooks system functionality.
"""
import pytest
import tempfile
import json
import hashlib
from pathlib import Path
from unittest.mock import patch, mock_open, MagicMock
from app.core.hooks import ProjectHook, get_project_hook
from app.core.sqlite_manager import SQLiteManager
from app.core.kb_types import KBType
@pytest.mark.unit
class TestProjectHook:
"""Test ProjectHook class."""
def test_hook_initialization(self, clean_db, tmp_path):
"""Test hook initialization."""
project = clean_db.create_project("test_project", str(tmp_path))
hook = ProjectHook(project["id"], clean_db)
assert hook.project_id == project["id"]
assert hook.db_manager == clean_db
assert hook.hooks_config_path.name == "hooks.json"
def test_get_hooks_config_path(self, clean_db, tmp_path):
"""Test hooks config path generation."""
project = clean_db.create_project("test_project", str(tmp_path))
hook = ProjectHook(project["id"], clean_db)
config_path = hook._get_hooks_config_path()
expected_path = tmp_path / ".claude-os" / "hooks.json"
assert config_path == expected_path
def test_load_hooks_config_not_exists(self, clean_db, tmp_path):
"""Test loading hooks config when file doesn't exist."""
project = clean_db.create_project("test_project", str(tmp_path))
hook = ProjectHook(project["id"], clean_db)
config = hook._load_hooks_config()
# Should return default config
assert config["version"] == "1.0"
assert config["project_id"] == project["id"]
assert config["hooks"] == {}
assert "created_at" in config
def test_load_hooks_config_exists(self, clean_db, tmp_path):
"""Test loading existing hooks config."""
project = clean_db.create_project("test_project", str(tmp_path))
# Create hooks config file
hooks_dir = Path(tmp_path) / ".claude-os"
hooks_dir.mkdir()
config_file = hooks_dir / "hooks.json"
existing_config = {
"version": "1.0",
"project_id": project["id"],
"created_at": "2023-01-01T00:00:00",
"hooks": {
"knowledge_docs": {
"enabled": True,
"folder_path": "/docs"
}
}
}
config_file.write_text(json.dumps(existing_config))
hook = ProjectHook(project["id"], clean_db)
config = hook._load_hooks_config()
assert config == existing_config
def test_save_hooks_config(self, clean_db, tmp_path):
"""Test saving hooks config."""
project = clean_db.create_project("test_project", str(tmp_path))
hook = ProjectHook(project["id"], clean_db)
# Modify config
config = hook._load_hooks_config()
config["hooks"]["test_hook"] = {"enabled": True}
hook._save_hooks_config(config)
# Verify file was saved
config_file = Path(tmp_path) / ".claude-os" / "hooks.json"
assert config_file.exists()
saved_config = json.loads(config_file.read_text())
assert saved_config["hooks"]["test_hook"]["enabled"] is True
assert "updated_at" in saved_config
def test_enable_kb_autosync_success(self, clean_db, tmp_path):
"""Test enabling KB autosync successfully."""
project = clean_db.create_project("test_project", str(tmp_path))
# Create folder to watch
watch_dir = tmp_path / "docs"
watch_dir.mkdir()
hook = ProjectHook(project["id"], clean_db)
result = hook.enable_kb_autosync(
mcp_type="knowledge_docs",
folder_path=str(watch_dir),
file_patterns=["*.md", "*.txt"]
)
assert result["enabled"] is True
assert result["mcp_type"] == "knowledge_docs"
assert result["folder_path"] == str(watch_dir)
assert result["file_patterns"] == ["*.md", "*.txt"]
assert "created_at" in result
assert "last_sync" in result
assert "synced_files" in result
def test_enable_kb_autosync_invalid_folder(self, clean_db, tmp_path):
"""Test enabling KB autosync with invalid folder."""
project = clean_db.create_project("test_project", str(tmp_path))
hook = ProjectHook(project["id"], clean_db)
with pytest.raises(ValueError, match="Folder does not exist"):
hook.enable_kb_autosync(
mcp_type="knowledge_docs",
folder_path="/nonexistent/folder"
)
def test_enable_kb_autosync_invalid_mcp_type(self, clean_db, tmp_path):
"""Test enabling KB autosync with invalid MCP type."""
project = clean_db.create_project("test_project", str(tmp_path))
hook = ProjectHook(project["id"], clean_db)
with pytest.raises(ValueError, match="Invalid MCP type"):
hook.enable_kb_autosync(
mcp_type="invalid_type",
folder_path=str(tmp_path)
)
def test_enable_kb_autosync_default_patterns(self, clean_db, tmp_path):
"""Test enabling KB autosync with default file patterns."""
project = clean_db.create_project("test_project", str(tmp_path))
watch_dir = tmp_path / "docs"
watch_dir.mkdir()
hook = ProjectHook(project["id"], clean_db)
result = hook.enable_kb_autosync(
mcp_type="knowledge_docs",
folder_path=str(watch_dir)
# No file_patterns specified
)
# Should use default patterns from Config
assert "file_patterns" in result
assert len(result["file_patterns"]) > 0
def test_disable_kb_autosync(self, clean_db, tmp_path):
"""Test disabling KB autosync."""
project = clean_db.create_project("test_project", str(tmp_path))
hook = ProjectHook(project["id"], clean_db)
# First enable it
hook.enable_kb_autosync(
mcp_type="knowledge_docs",
folder_path=str(tmp_path)
)
# Then disable it
hook.disable_kb_autosync("knowledge_docs")
# Check config was updated
config = hook._load_hooks_config()
assert config["hooks"]["knowledge_docs"]["enabled"] is False
def test_disable_kb_autosync_not_found(self, clean_db, tmp_path):
"""Test disabling KB autosync for non-existent hook."""
project = clean_db.create_project("test_project", str(tmp_path))
hook = ProjectHook(project["id"], clean_db)
with pytest.raises(ValueError, match="Hook for .* not found"):
hook.disable_kb_autosync("nonexistent_hook")
@patch('app.core.hooks.ingest_documents')
def test_sync_kb_folder_success(self, mock_ingest, clean_db, tmp_path):
"""Test successful KB folder sync."""
project = clean_db.create_project("test_project", str(tmp_path))
# Create KB
kb = clean_db.create_collection("test_kb", KBType.GENERIC)
clean_db.assign_kb_to_project(project["id"], kb["id"], "knowledge_docs")
# Create files to sync
docs_dir = tmp_path / "docs"
docs_dir.mkdir()
(docs_dir / "file1.txt").write_text("Content 1")
(docs_dir / "file2.md").write_text("# Content 2")
(docs_dir / "ignore.xyz").write_text("Should be ignored")
# Enable hook
hook = ProjectHook(project["id"], clean_db)
hook.enable_kb_autosync(
mcp_type="knowledge_docs",
folder_path=str(docs_dir),
file_patterns=[".txt", ".md"]
)
result = hook.sync_kb_folder("knowledge_docs")
assert result["mcp_type"] == "knowledge_docs"
assert result["folder_path"] == str(docs_dir)
assert result["synced_files"] == 2
assert result["skipped_files"] == 0
assert len(result["errors"]) == 0
assert len(result["files_synced"]) == 2
assert "file1.txt" in str(result["files_synced"])
assert "file2.md" in str(result["files_synced"])
# Check ingest was called (once per file)
assert mock_ingest.call_count == 2
@patch('app.core.hooks.ingest_documents')
def test_sync_kb_folder_with_unchanged_files(self, mock_ingest, clean_db, tmp_path):
"""Test KB folder sync with unchanged files."""
project = clean_db.create_project("test_project", str(tmp_path))
# Create KB
kb = clean_db.create_collection("test_kb", KBType.GENERIC)
clean_db.assign_kb_to_project(project["id"], kb["id"], "knowledge_docs")
# Create files
docs_dir = tmp_path / "docs"
docs_dir.mkdir()
test_file = docs_dir / "file1.txt"
test_file.write_text("Content 1")
# Enable hook
hook = ProjectHook(project["id"], clean_db)
hook.enable_kb_autosync(
mcp_type="knowledge_docs",
folder_path=str(docs_dir),
file_patterns=[".txt"]
)
# First sync
hook.sync_kb_folder("knowledge_docs")
# Reset mock
mock_ingest.reset_mock()
# Second sync (should skip unchanged file)
result = hook.sync_kb_folder("knowledge_docs")
assert result["synced_files"] == 0
assert result["skipped_files"] == 1
assert len(result["errors"]) == 0
# Ingest should not be called for unchanged files
mock_ingest.assert_not_called()
@patch('app.core.hooks.ingest_documents')
def test_sync_kb_folder_with_errors(self, mock_ingest, clean_db, tmp_path):
"""Test KB folder sync with ingestion errors."""
project = clean_db.create_project("test_project", str(tmp_path))
# Create KB
kb = clean_db.create_collection("test_kb", KBType.GENERIC)
clean_db.assign_kb_to_project(project["id"], kb["id"], "knowledge_docs")
# Create files
docs_dir = tmp_path / "docs"
docs_dir.mkdir()
(docs_dir / "file1.txt").write_text("Content 1")
(docs_dir / "file2.txt").write_text("Content 2")
# Mock ingest to fail for second file
mock_ingest.side_effect = [
None, # Success for first file
Exception("Ingestion failed") # Fail for second
]
# Enable hook
hook = ProjectHook(project["id"], clean_db)
hook.enable_kb_autosync(
mcp_type="knowledge_docs",
folder_path=str(docs_dir),
file_patterns=[".txt"]
)
result = hook.sync_kb_folder("knowledge_docs")
assert result["synced_files"] == 1
assert result["skipped_files"] == 0
assert len(result["errors"]) == 1
assert "Ingestion failed" in result["errors"][0]["error"]
def test_sync_kb_folder_not_enabled(self, clean_db, tmp_path):
"""Test syncing folder for hook that's not enabled."""
project = clean_db.create_project("test_project", str(tmp_path))
hook = ProjectHook(project["id"], clean_db)
with pytest.raises(ValueError, match="Hook for .* not found"):
hook.sync_kb_folder("knowledge_docs")
def test_sync_kb_folder_no_kb_assigned(self, clean_db, tmp_path):
"""Test syncing folder when no KB is assigned."""
project = clean_db.create_project("test_project", str(tmp_path))
# Create docs dir but don't assign KB
docs_dir = tmp_path / "docs"
docs_dir.mkdir()
hook = ProjectHook(project["id"], clean_db)
hook.enable_kb_autosync(
mcp_type="knowledge_docs",
folder_path=str(docs_dir)
)
with pytest.raises(ValueError, match="No KB assigned"):
hook.sync_kb_folder("knowledge_docs")
def test_sync_all_folders(self, clean_db, tmp_path):
"""Test syncing all enabled folders."""
project = clean_db.create_project("test_project", str(tmp_path))
# Create KBs
kb1 = clean_db.create_collection("kb1", KBType.GENERIC)
kb2 = clean_db.create_collection("kb2", KBType.GENERIC)
clean_db.assign_kb_to_project(project["id"], kb1["id"], "knowledge_docs")
clean_db.assign_kb_to_project(project["id"], kb2["id"], "project_profile")
# Create folders
docs_dir = tmp_path / "docs"
profile_dir = tmp_path / "profile"
docs_dir.mkdir()
profile_dir.mkdir()
(docs_dir / "file1.txt").write_text("Docs content")
(profile_dir / "file2.txt").write_text("Profile content")
# Enable hooks
hook = ProjectHook(project["id"], clean_db)
hook.enable_kb_autosync("knowledge_docs", str(docs_dir))
hook.enable_kb_autosync("project_profile", str(profile_dir))
with patch('app.core.hooks.ingest_documents') as mock_ingest:
mock_ingest.return_value = None
results = hook.sync_all_folders()
assert "knowledge_docs" in results
assert "project_profile" in results
assert results["knowledge_docs"]["synced_files"] == 1
assert results["project_profile"]["synced_files"] == 1
def test_get_hook_status_single(self, clean_db, tmp_path):
"""Test getting status for single hook."""
project = clean_db.create_project("test_project", str(tmp_path))
hook = ProjectHook(project["id"], clean_db)
hook.enable_kb_autosync(
mcp_type="knowledge_docs",
folder_path=str(tmp_path)
)
status = hook.get_hook_status("knowledge_docs")
assert status["enabled"] is True
assert status["folder_path"] == str(tmp_path)
assert "file_patterns" in status
assert "created_at" in status
def test_get_hook_status_all(self, clean_db, tmp_path):
"""Test getting status for all hooks."""
project = clean_db.create_project("test_project", str(tmp_path))
hook = ProjectHook(project["id"], clean_db)
hook.enable_kb_autosync("knowledge_docs", str(tmp_path))
hook.enable_kb_autosync("project_profile", str(tmp_path))
status = hook.get_hook_status()
assert status["project_id"] == project["id"]
assert status["total_hooks"] == 2
assert status["enabled_hooks"] == 2
assert "knowledge_docs" in status["hooks"]
assert "project_profile" in status["hooks"]
assert status["hooks_config_path"] == str(hook.hooks_config_path)
def test_get_hook_status_not_found(self, clean_db, tmp_path):
"""Test getting status for non-existent hook."""
project = clean_db.create_project("test_project", str(tmp_path))
hook = ProjectHook(project["id"], clean_db)
status = hook.get_hook_status("nonexistent_hook")
assert "error" in status
assert "not found" in status["error"]
def test_compute_file_hash(self, tmp_path):
"""Test file hash computation."""
hook = ProjectHook.__new__(ProjectHook) # Create without __init__
# Create test file
test_file = tmp_path / "test.txt"
test_content = "Test content for hashing"
test_file.write_text(test_content)
computed_hash = hook._compute_file_hash(test_file)
# Compute expected hash
expected_hash = hashlib.sha256(test_content.encode()).hexdigest()
assert computed_hash == expected_hash
def test_compute_file_hash_binary(self, tmp_path):
"""Test file hash computation for binary file."""
hook = ProjectHook.__new__(ProjectHook) # Create without __init__
# Create binary file
test_file = tmp_path / "test.bin"
test_content = b"\x00\x01\x02\x03\x04\x05"
test_file.write_bytes(test_content)
computed_hash = hook._compute_file_hash(test_file)
# Compute expected hash
expected_hash = hashlib.sha256(test_content).hexdigest()
assert computed_hash == expected_hash
@pytest.mark.unit
class TestProjectHookUtility:
"""Test ProjectHook utility functions."""
def test_get_project_hook(self, clean_db, tmp_path):
"""Test get_project_hook utility function."""
project = clean_db.create_project("test_project", str(tmp_path))
hook1 = get_project_hook(project["id"], clean_db)
hook2 = get_project_hook(project["id"], clean_db)
# Should return new instances each time
assert hook1 is not hook2
assert hook1.project_id == project["id"]
assert hook2.project_id == project["id"]
def test_get_project_hook_different_projects(self, clean_db, tmp_path):
"""Test get_project_hook with different projects."""
path1 = tmp_path / "project1"
path2 = tmp_path / "project2"
path1.mkdir()
path2.mkdir()
project1 = clean_db.create_project("project1", str(path1))
project2 = clean_db.create_project("project2", str(path2))
hook1 = get_project_hook(project1["id"], clean_db)
hook2 = get_project_hook(project2["id"], clean_db)
assert hook1.project_id == project1["id"]
assert hook2.project_id == project2["id"]
assert hook1.project_id != hook2.project_id
@patch('app.core.hooks.ProjectHook')
def test_get_project_hook_with_invalid_project(self, mock_hook_class, clean_db):
"""Test get_project_hook with invalid project ID."""
mock_hook_class.side_effect = ValueError("Project not found")
with pytest.raises(ValueError, match="Project not found"):
get_project_hook(99999, clean_db)
@pytest.mark.integration
class TestProjectHookIntegration:
"""Integration tests for ProjectHook."""
def test_full_hook_lifecycle(self, clean_db, tmp_path):
"""Test complete hook lifecycle."""
project = clean_db.create_project("test_project", str(tmp_path))
# Create KB
kb = clean_db.create_collection("test_kb", KBType.GENERIC)
clean_db.assign_kb_to_project(project["id"], kb["id"], "knowledge_docs")
# Create folder and files
docs_dir = tmp_path / "docs"
docs_dir.mkdir()
test_file = docs_dir / "test.txt"
test_file.write_text("Initial content")
# Enable hook
hook = ProjectHook(project["id"], clean_db)
result = hook.enable_kb_autosync(
mcp_type="knowledge_docs",
folder_path=str(docs_dir)
)
assert result["enabled"] is True
# Sync (should ingest file)
with patch('app.core.hooks.ingest_documents') as mock_ingest:
mock_ingest.return_value = None
sync_result = hook.sync_kb_folder("knowledge_docs")
assert sync_result["synced_files"] == 1
mock_ingest.assert_called_once()
# Modify file and sync again (should ingest again)
test_file.write_text("Modified content")
with patch('app.core.hooks.ingest_documents') as mock_ingest:
mock_ingest.return_value = None
sync_result = hook.sync_kb_folder("knowledge_docs")
assert sync_result["synced_files"] == 1
mock_ingest.assert_called_once()
# Disable hook
hook.disable_kb_autosync("knowledge_docs")
status = hook.get_hook_status("knowledge_docs")
assert status["enabled"] is False
def test_hook_config_persistence(self, clean_db, tmp_path):
"""Test that hook config persists across instances."""
project = clean_db.create_project("test_project", str(tmp_path))
# Create hook and enable autosync
hook1 = ProjectHook(project["id"], clean_db)
hook1.enable_kb_autosync(
mcp_type="knowledge_docs",
folder_path=str(tmp_path),
file_patterns=[".txt", ".md"]
)
# Create new instance
hook2 = ProjectHook(project["id"], clean_db)
status = hook2.get_hook_status("knowledge_docs")
# Should have persisted config
assert status["enabled"] is True
assert status["folder_path"] == str(tmp_path)
assert status["file_patterns"] == [".txt", ".md"]
def test_multiple_hooks_independence(self, clean_db, tmp_path):
"""Test that multiple hooks work independently."""
project = clean_db.create_project("test_project", str(tmp_path))
# Create KBs
kb1 = clean_db.create_collection("kb1", KBType.GENERIC)
kb2 = clean_db.create_collection("kb2", KBType.GENERIC)
clean_db.assign_kb_to_project(project["id"], kb1["id"], "knowledge_docs")
clean_db.assign_kb_to_project(project["id"], kb2["id"], "project_profile")
# Create folders
docs_dir = tmp_path / "docs"
profile_dir = tmp_path / "profile"
docs_dir.mkdir()
profile_dir.mkdir()
(docs_dir / "doc.txt").write_text("Docs content")
(profile_dir / "profile.txt").write_text("Profile content")
# Enable both hooks
hook = ProjectHook(project["id"], clean_db)
hook.enable_kb_autosync("knowledge_docs", str(docs_dir))
hook.enable_kb_autosync("project_profile", str(profile_dir))
# Sync both
with patch('app.core.hooks.ingest_documents') as mock_ingest:
mock_ingest.return_value = None
results = hook.sync_all_folders()
# Both should have synced
assert results["knowledge_docs"]["synced_files"] == 1
assert results["project_profile"]["synced_files"] == 1
# Should have called ingest twice
assert mock_ingest.call_count == 2
def test_hook_error_recovery(self, clean_db, tmp_path):
"""Test hook error recovery and resilience."""
project = clean_db.create_project("test_project", str(tmp_path))
# Create KB
kb = clean_db.create_collection("test_kb", KBType.GENERIC)
clean_db.assign_kb_to_project(project["id"], kb["id"], "knowledge_docs")
# Create folder with problematic file
docs_dir = tmp_path / "docs"
docs_dir.mkdir()
(docs_dir / "good.txt").write_text("Good content")
(docs_dir / "bad.txt").write_text("Bad content")
# Enable hook
hook = ProjectHook(project["id"], clean_db)
hook.enable_kb_autosync("knowledge_docs", str(docs_dir))
# Mock ingest to fail for bad file
with patch('app.core.hooks.ingest_documents') as mock_ingest:
def ingest_side_effect(kb_name, documents, metadatas):
# Fail if contains "bad"
if any("bad" in meta.get("filename", "") for meta in metadatas):
raise Exception("Bad file detected")
return None
mock_ingest.side_effect = ingest_side_effect
# Sync should handle error gracefully
result = hook.sync_kb_folder("knowledge_docs")
# Should have one success and one error
assert result["synced_files"] == 1
assert len(result["errors"]) == 1
assert "Bad file detected" in result["errors"][0]["error"]