claude-os/tests/test_file_watcher.py

700 lines
23 KiB
Python
Raw Normal View History

2025-10-27 21:57:06 -05:00
"""
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