claude-os/tests/test_file_watcher.py
2025-10-27 21:57:06 -05:00

700 lines
No EOL
23 KiB
Python

"""
Tests for file watcher system functionality.
"""
import pytest
import tempfile
import threading
import time
from pathlib import Path
from unittest.mock import patch, MagicMock, Mock
from app.core.file_watcher import (
ProjectFileHandler,
ProjectWatcher,
GlobalFileWatcher,
get_global_watcher
)
@pytest.mark.unit
class TestProjectFileHandler:
"""Test ProjectFileHandler class."""
def test_file_handler_initialization(self):
"""Test file handler initialization."""
callback = Mock()
handler = ProjectFileHandler(123, "knowledge_docs", callback)
assert handler.project_id == 123
assert handler.mcp_type == "knowledge_docs"
assert handler.on_change == callback
assert handler.debounce_delay == 2.0
assert handler.debounce_timer is None
def test_on_modified_file(self):
"""Test handling file modification."""
callback = Mock()
handler = ProjectFileHandler(123, "knowledge_docs", callback)
# Create mock event
mock_event = Mock()
mock_event.is_directory = False
handler.on_modified(mock_event)
# Should have a debounce timer
assert handler.debounce_timer is not None
assert isinstance(handler.debounce_timer, threading.Timer)
def test_on_created_file(self):
"""Test handling file creation."""
callback = Mock()
handler = ProjectFileHandler(123, "knowledge_docs", callback)
# Create mock event
mock_event = Mock()
mock_event.is_directory = False
handler.on_created(mock_event)
# Should have a debounce timer
assert handler.debounce_timer is not None
def test_on_modified_directory_ignored(self):
"""Test that directory modifications are ignored."""
callback = Mock()
handler = ProjectFileHandler(123, "knowledge_docs", callback)
# Create mock directory event
mock_event = Mock()
mock_event.is_directory = True
handler.on_modified(mock_event)
# Should not create timer for directories
assert handler.debounce_timer is None
def test_on_created_directory_ignored(self):
"""Test that directory creations are ignored."""
callback = Mock()
handler = ProjectFileHandler(123, "knowledge_docs", callback)
# Create mock directory event
mock_event = Mock()
mock_event.is_directory = True
handler.on_created(mock_event)
# Should not create timer for directories
assert handler.debounce_timer is None
def test_debounce_sync_cancels_previous_timer(self):
"""Test that debounce cancels previous timer."""
callback = Mock()
handler = ProjectFileHandler(123, "knowledge_docs", callback)
# Create mock event
mock_event = Mock()
mock_event.is_directory = False
# First event
handler.on_modified(mock_event)
first_timer = handler.debounce_timer
# Second event (should cancel first)
handler.on_modified(mock_event)
second_timer = handler.debounce_timer
# Timers should be different
assert first_timer is not second_timer
assert handler.debounce_timer is second_timer
@patch('threading.Timer')
def test_trigger_sync_callback(self, mock_timer_class):
"""Test that debounce timer triggers callback."""
callback = Mock()
handler = ProjectFileHandler(123, "knowledge_docs", callback)
# Mock timer
mock_timer = Mock()
mock_timer_class.return_value = mock_timer
# Trigger file event
mock_event = Mock()
mock_event.is_directory = False
handler.on_modified(mock_event)
# Get the timer callback
timer_callback = mock_timer_class.call_args[0][0]
# Execute the callback
timer_callback()
# Should call the original callback
callback.assert_called_once()
@patch('threading.Timer')
def test_trigger_sync_error_handling(self, mock_timer_class):
"""Test error handling in sync callback."""
callback = Mock(side_effect=Exception("Sync error"))
handler = ProjectFileHandler(123, "knowledge_docs", callback)
# Mock timer
mock_timer = Mock()
mock_timer_class.return_value = mock_timer
# Trigger file event
mock_event = Mock()
mock_event.is_directory = False
handler.on_modified(mock_event)
# Get and execute timer callback
timer_callback = mock_timer_class.call_args[0][0]
# Should handle error gracefully
# In real implementation, this would be caught by logger
timer_callback()
@pytest.mark.integration
class TestProjectWatcher:
"""Test ProjectWatcher class."""
@patch('app.core.file_watcher.ProjectHook')
@patch('app.core.file_watcher.Observer')
def test_watcher_initialization(self, mock_observer_class, mock_hook_class):
"""Test project watcher initialization."""
project_id = 123
mock_hook = Mock()
mock_hook_class.return_value = mock_hook
watcher = ProjectWatcher(project_id)
assert watcher.project_id == project_id
assert watcher.hook == mock_hook
assert watcher.observer is None
assert watcher.event_handlers == {}
assert watcher.watched_paths == {}
@patch('app.core.file_watcher.ProjectHook')
@patch('app.core.file_watcher.Observer')
def test_start_with_no_hooks(self, mock_observer_class, mock_hook_class):
"""Test starting watcher with no hooks configured."""
project_id = 123
mock_hook = Mock()
mock_hook.get_hook_status.return_value = {"hooks": {}}
mock_hook_class.return_value = mock_hook
watcher = ProjectWatcher(project_id)
watcher.start()
# Should not start observer
mock_observer_class.return_value.start.assert_not_called()
@patch('app.core.file_watcher.ProjectHook')
@patch('app.core.file_watcher.Observer')
def test_start_with_disabled_hooks(self, mock_observer_class, mock_hook_class):
"""Test starting watcher with disabled hooks."""
project_id = 123
mock_hook = Mock()
mock_hook.get_hook_status.return_value = {
"hooks": {
"knowledge_docs": {
"enabled": False,
"folder_path": "/path/to/docs"
}
}
}
mock_hook_class.return_value = mock_hook
watcher = ProjectWatcher(project_id)
watcher.start()
# Should not start observer
mock_observer_class.return_value.start.assert_not_called()
@patch('app.core.file_watcher.ProjectHook')
@patch('app.core.file_watcher.Observer')
def test_start_with_enabled_hooks(self, mock_observer_class, mock_hook_class, tmp_path):
"""Test starting watcher with enabled hooks."""
project_id = 123
mock_hook = Mock()
# Create a real directory for testing
watch_dir = tmp_path / "watched"
watch_dir.mkdir()
mock_hook.get_hook_status.return_value = {
"hooks": {
"knowledge_docs": {
"enabled": True,
"folder_path": str(watch_dir)
}
}
}
mock_hook_class.return_value = mock_hook
mock_observer = Mock()
mock_observer_class.return_value = mock_observer
watcher = ProjectWatcher(project_id)
watcher.start()
# Should start observer
mock_observer.start.assert_called_once()
# Should schedule watching
mock_observer.schedule.assert_called_once()
schedule_args = mock_observer.schedule.call_args
assert schedule_args[0][1] == str(watch_dir) # path
assert schedule_args[0][2] is True # recursive
@patch('app.core.file_watcher.ProjectHook')
@patch('app.core.file_watcher.Observer')
def test_start_with_nonexistent_folder(self, mock_observer_class, mock_hook_class):
"""Test starting watcher with non-existent folder."""
project_id = 123
mock_hook = Mock()
mock_hook.get_hook_status.return_value = {
"hooks": {
"knowledge_docs": {
"enabled": True,
"folder_path": "/nonexistent/path"
}
}
}
mock_hook_class.return_value = mock_hook
watcher = ProjectWatcher(project_id)
watcher.start()
# Should not schedule watching for non-existent path
mock_observer_class.return_value.schedule.assert_not_called()
@patch('app.core.file_watcher.ProjectHook')
@patch('app.core.file_watcher.Observer')
def test_start_multiple_hooks(self, mock_observer_class, mock_hook_class, tmp_path):
"""Test starting watcher with multiple enabled hooks."""
project_id = 123
mock_hook = Mock()
# Create directories
docs_dir = tmp_path / "docs"
profile_dir = tmp_path / "profile"
docs_dir.mkdir()
profile_dir.mkdir()
mock_hook.get_hook_status.return_value = {
"hooks": {
"knowledge_docs": {
"enabled": True,
"folder_path": str(docs_dir)
},
"project_profile": {
"enabled": True,
"folder_path": str(profile_dir)
}
}
}
mock_hook_class.return_value = mock_hook
mock_observer = Mock()
mock_observer_class.return_value = mock_observer
watcher = ProjectWatcher(project_id)
watcher.start()
# Should schedule both paths
assert mock_observer.schedule.call_count == 2
# Check event handlers were created
assert len(watcher.event_handlers) == 2
assert "knowledge_docs" in watcher.event_handlers
assert "project_profile" in watcher.event_handlers
@patch('app.core.file_watcher.ProjectHook')
@patch('app.core.file_watcher.Observer')
def test_stop_watcher(self, mock_observer_class, mock_hook_class):
"""Test stopping watcher."""
project_id = 123
mock_hook = Mock()
mock_hook.get_hook_status.return_value = {"hooks": {}}
mock_hook_class.return_value = mock_hook
mock_observer = Mock()
mock_observer_class.return_value = mock_observer
watcher = ProjectWatcher(project_id)
watcher.observer = mock_observer
watcher.event_handlers["test"] = Mock()
watcher.watched_paths["test"] = "/path"
watcher.stop()
# Should stop observer
mock_observer.stop.assert_called_once()
mock_observer.join.assert_called_once()
# Should clear state
assert watcher.observer is None
assert len(watcher.event_handlers) == 0
assert len(watcher.watched_paths) == 0
@patch('app.core.file_watcher.ProjectHook')
@patch('app.core.file_watcher.Observer')
def test_restart_watcher(self, mock_observer_class, mock_hook_class):
"""Test restarting watcher."""
project_id = 123
mock_hook = Mock()
mock_hook.get_hook_status.return_value = {"hooks": {}}
mock_hook_class.return_value = mock_hook
mock_observer = Mock()
mock_observer_class.return_value = mock_observer
watcher = ProjectWatcher(project_id)
watcher.observer = mock_observer
watcher.restart()
# Should stop and start
mock_observer.stop.assert_called_once()
mock_observer.join.assert_called_once()
mock_observer.start.assert_called_once()
@patch('app.core.file_watcher.ProjectHook')
@patch('app.core.file_watcher.Observer')
def test_on_folder_change(self, mock_observer_class, mock_hook_class):
"""Test folder change handling."""
project_id = 123
mock_hook = Mock()
mock_hook.get_hook_status.return_value = {"hooks": {}}
mock_hook_class.return_value = mock_hook
watcher = ProjectWatcher(project_id)
# Mock sync result
mock_hook.sync_kb_folder.return_value = {
"synced_files": ["file1.txt", "file2.txt"],
"errors": []
}
watcher._on_folder_change("knowledge_docs")
# Should call sync
mock_hook.sync_kb_folder.assert_called_once_with("knowledge_docs")
@patch('app.core.file_watcher.ProjectHook')
@patch('app.core.file_watcher.Observer')
def test_on_folder_change_with_errors(self, mock_observer_class, mock_hook_class):
"""Test folder change handling with sync errors."""
project_id = 123
mock_hook = Mock()
mock_hook.get_hook_status.return_value = {"hooks": {}}
mock_hook_class.return_value = mock_hook
watcher = ProjectWatcher(project_id)
# Mock sync result with errors
mock_hook.sync_kb_folder.return_value = {
"synced_files": ["file1.txt"],
"errors": [{"file": "file2.txt", "error": "Permission denied"}]
}
watcher._on_folder_change("knowledge_docs")
# Should still call sync despite errors
mock_hook.sync_kb_folder.assert_called_once_with("knowledge_docs")
@pytest.mark.integration
class TestGlobalFileWatcher:
"""Test GlobalFileWatcher class."""
def test_global_watcher_initialization(self):
"""Test global watcher initialization."""
global_watcher = GlobalFileWatcher()
assert global_watcher.watchers == {}
assert global_watcher.enabled is False
assert hasattr(global_watcher, 'lock')
def test_start_project(self):
"""Test starting to watch a project."""
global_watcher = GlobalFileWatcher()
with patch('app.core.file_watcher.ProjectWatcher') as mock_pw_class:
mock_pw = Mock()
mock_pw_class.return_value = mock_pw
global_watcher.start_project(123)
assert 123 in global_watcher.watchers
assert global_watcher.watchers[123] == mock_pw
mock_pw.start.assert_called_once()
def test_start_project_already_exists(self):
"""Test starting project that's already being watched."""
global_watcher = GlobalFileWatcher()
with patch('app.core.file_watcher.ProjectWatcher') as mock_pw_class:
mock_pw1 = Mock()
mock_pw2 = Mock()
mock_pw_class.side_effect = [mock_pw1, mock_pw2]
# Start project twice
global_watcher.start_project(123)
global_watcher.start_project(123)
# Should still only have one watcher
assert len(global_watcher.watchers) == 1
assert global_watcher.watchers[123] == mock_pw1
mock_pw2.start.assert_not_called()
def test_stop_project(self):
"""Test stopping watching a project."""
global_watcher = GlobalFileWatcher()
with patch('app.core.file_watcher.ProjectWatcher') as mock_pw_class:
mock_pw = Mock()
mock_pw_class.return_value = mock_pw
# Start then stop
global_watcher.start_project(123)
global_watcher.stop_project(123)
assert 123 not in global_watcher.watchers
mock_pw.stop.assert_called_once()
def test_stop_project_not_watched(self):
"""Test stopping project that's not being watched."""
global_watcher = GlobalFileWatcher()
# Should not raise error
global_watcher.stop_project(123)
assert 123 not in global_watcher.watchers
def test_restart_project(self):
"""Test restarting project watcher."""
global_watcher = GlobalFileWatcher()
with patch('app.core.file_watcher.ProjectWatcher') as mock_pw_class:
mock_pw = Mock()
mock_pw_class.return_value = mock_pw
# Start then restart
global_watcher.start_project(123)
global_watcher.restart_project(123)
mock_pw.restart.assert_called_once()
def test_restart_project_not_watched(self):
"""Test restarting project that's not being watched."""
global_watcher = GlobalFileWatcher()
with patch('app.core.file_watcher.ProjectWatcher') as mock_pw_class:
mock_pw = Mock()
mock_pw_class.return_value = mock_pw
global_watcher.restart_project(123)
# Should start the project
mock_pw.start.assert_called_once()
assert 123 in global_watcher.watchers
def test_start_all_projects(self):
"""Test starting all projects."""
global_watcher = GlobalFileWatcher()
with patch('app.core.file_watcher.ProjectWatcher') as mock_pw_class:
mock_pw1 = Mock()
mock_pw2 = Mock()
mock_pw3 = Mock()
mock_pw_class.side_effect = [mock_pw1, mock_pw2, mock_pw3]
project_ids = [123, 456, 789]
global_watcher.start_all(project_ids)
assert len(global_watcher.watchers) == 3
assert 123 in global_watcher.watchers
assert 456 in global_watcher.watchers
assert 789 in global_watcher.watchers
mock_pw1.start.assert_called_once()
mock_pw2.start.assert_called_once()
mock_pw3.start.assert_called_once()
assert global_watcher.enabled is True
def test_start_all_projects_with_errors(self):
"""Test starting all projects with some errors."""
global_watcher = GlobalFileWatcher()
with patch('app.core.file_watcher.ProjectWatcher') as mock_pw_class:
mock_pw1 = Mock()
mock_pw2 = Mock()
mock_pw3 = Mock()
# Make second watcher fail
mock_pw2.start.side_effect = Exception("Start failed")
mock_pw_class.side_effect = [mock_pw1, mock_pw2, mock_pw3]
project_ids = [123, 456, 789]
global_watcher.start_all(project_ids)
# Should still start others
assert len(global_watcher.watchers) == 2 # Only 2 succeeded
assert 123 in global_watcher.watchers
assert 789 in global_watcher.watchers
assert 456 not in global_watcher.watchers
def test_stop_all(self):
"""Test stopping all project watchers."""
global_watcher = GlobalFileWatcher()
with patch('app.core.file_watcher.ProjectWatcher') as mock_pw_class:
mock_pw1 = Mock()
mock_pw2 = Mock()
mock_pw_class.side_effect = [mock_pw1, mock_pw2]
# Start some projects
global_watcher.start_project(123)
global_watcher.start_project(456)
# Stop all
global_watcher.stop_all()
assert len(global_watcher.watchers) == 0
assert global_watcher.enabled is False
mock_pw1.stop.assert_called_once()
mock_pw2.stop.assert_called_once()
def test_get_status(self):
"""Test getting global watcher status."""
global_watcher = GlobalFileWatcher()
with patch('app.core.file_watcher.ProjectWatcher') as mock_pw_class:
mock_pw1 = Mock()
mock_pw2 = Mock()
mock_pw1.watched_paths = {"docs": "/path/docs"}
mock_pw1.event_handlers = {"docs": "handler1"}
mock_pw2.watched_paths = {"profile": "/path/profile"}
mock_pw2.event_handlers = {"profile": "handler2"}
mock_pw_class.side_effect = [mock_pw1, mock_pw2]
# Start projects
global_watcher.start_project(123)
global_watcher.start_project(456)
status = global_watcher.get_status()
assert status["enabled"] is True
assert status["projects_watched"] == 2
assert "123" in status["projects"]
assert "456" in status["projects"]
assert status["projects"]["123"]["watched_paths"] == {"docs": "/path/docs"}
assert status["projects"]["123"]["event_handlers"] == ["docs"]
assert status["projects"]["456"]["watched_paths"] == {"profile": "/path/profile"}
assert status["projects"]["456"]["event_handlers"] == ["profile"]
@pytest.mark.unit
class TestFileWatcherUtility:
"""Test file watcher utility functions."""
def test_get_global_watcher_singleton(self):
"""Test that get_global_watcher returns singleton."""
watcher1 = get_global_watcher()
watcher2 = get_global_watcher()
# Should be the same instance
assert watcher1 is watcher2
def test_get_global_watcher_type(self):
"""Test that get_global_watcher returns correct type."""
watcher = get_global_watcher()
assert isinstance(watcher, GlobalFileWatcher)
@patch('app.core.file_watcher.GlobalFileWatcher')
def test_get_global_watcher_creates_instance(self, mock_gfw_class):
"""Test that get_global_watcher creates instance on first call."""
mock_instance = Mock()
mock_gfw_class.return_value = mock_instance
# Clear any existing instance
import app.core.file_watcher
app.core.file_watcher._global_watcher = None
watcher1 = get_global_watcher()
watcher2 = get_global_watcher()
# Should create instance only once
mock_gfw_class.assert_called_once()
assert watcher1 is mock_instance
assert watcher2 is mock_instance
@pytest.mark.integration
class TestFileWatcherIntegration:
"""Integration tests for file watcher system."""
@patch('app.core.file_watcher.ProjectHook')
def test_file_change_detection(self, mock_hook_class, tmp_path):
"""Test actual file change detection."""
# This test would require real file system watching
# For now, we'll test the integration points
project_id = 123
mock_hook = Mock()
mock_hook.get_hook_status.return_value = {
"hooks": {
"knowledge_docs": {
"enabled": True,
"folder_path": str(tmp_path)
}
}
}
mock_hook_class.return_value = mock_hook
watcher = ProjectWatcher(project_id)
# Test that the handler is created correctly
assert "knowledge_docs" in watcher.event_handlers
handler = watcher.event_handlers["knowledge_docs"]
assert handler.project_id == project_id
assert handler.mcp_type == "knowledge_docs"
assert callable(handler.on_change)
def test_debounce_timing(self):
"""Test debounce timing behavior."""
callback = Mock()
handler = ProjectFileHandler(123, "knowledge_docs", callback)
# Mock timer to track calls
with patch('threading.Timer') as mock_timer_class:
mock_timer = Mock()
mock_timer_class.return_value = mock_timer
# Trigger multiple rapid events
mock_event = Mock()
mock_event.is_directory = False
handler.on_modified(mock_event)
first_call_args = mock_timer_class.call_args
first_callback = first_call_args[0][0]
handler.on_modified(mock_event)
second_call_args = mock_timer_class.call_args
second_callback = second_call_args[0][0]
# Should have created two timers
assert mock_timer_class.call_count == 2
# First timer should be cancelled (implicitly by creating new one)
# In real implementation, the old timer would be cancelled
assert first_callback is not None
assert second_callback is not None