claude-os/tests/test_learning_jobs.py

735 lines
27 KiB
Python
Raw Normal View History

2025-10-27 21:57:06 -05:00
"""
Tests for learning jobs functionality.
"""
import pytest
import tempfile
import json
import time
from pathlib import Path
from unittest.mock import patch, MagicMock, mock_open
from app.core.learning_jobs import (
process_learning_detection,
prompt_user_for_confirmation,
ingest_to_mcp,
handle_conversation_message,
wait_for_confirmation,
subscribe_to_conversations
)
from app.core.redis_config import RedisConfig
@pytest.mark.unit
class TestLearningJobs:
"""Test learning jobs functions."""
@patch('app.core.learning_jobs.RedisConfig')
@patch('app.core.learning_jobs.ConversationWatcher')
def test_process_learning_detection_confirmed(self, mock_watcher_class, mock_redis_class, tmp_path):
"""Test processing learning detection with user confirmation."""
# Mock Redis
mock_redis = MagicMock()
mock_redis_class.set_confirmation.return_value = None
mock_redis_class.queue_prompt_job.return_value = "prompt_job_123"
mock_redis_class.get_confirmation.return_value = True
mock_redis_class.return_value = mock_redis
# Mock watcher
mock_watcher = MagicMock()
mock_watcher_class.return_value = mock_watcher
detection = {
"id": "detection_123",
"trigger": "switching",
"text": "switching from A to B",
"confidence": 0.95
}
with patch('app.core.learning_jobs.wait_for_confirmation') as mock_wait:
mock_wait.return_value = True
with patch('app.core.learning_jobs.ingest_to_mcp') as mock_ingest:
mock_ingest.return_value = {"status": "ingested"}
result = process_learning_detection(123, detection)
assert result["status"] == "confirmed"
assert result["ingest_job_id"] is not None
assert result["detection_id"] == "detection_123"
# Verify Redis calls
mock_redis.set_confirmation.assert_called_once_with(123, "detection_123", False)
mock_redis.queue_prompt_job.assert_called_once_with(123, detection)
mock_wait.assert_called_once_with(123, "detection_123", timeout=600)
mock_ingest.assert_called_once_with(123, str(Path.cwd()))
@patch('app.core.learning_jobs.RedisConfig')
@patch('app.core.learning_jobs.ConversationWatcher')
def test_process_learning_detection_skipped(self, mock_watcher_class, mock_redis_class, tmp_path):
"""Test processing learning detection with user skipping."""
# Mock Redis
mock_redis = MagicMock()
mock_redis_class.set_confirmation.return_value = None
mock_redis_class.queue_prompt_job.return_value = "prompt_job_123"
mock_redis_class.get_confirmation.return_value = False
mock_redis_class.return_value = mock_redis
# Mock watcher
mock_watcher = MagicMock()
mock_watcher_class.return_value = mock_watcher
detection = {
"id": "detection_123",
"trigger": "switching",
"text": "switching from A to B",
"confidence": 0.95
}
with patch('app.core.learning_jobs.wait_for_confirmation') as mock_wait:
mock_wait.return_value = False
result = process_learning_detection(123, detection)
assert result["status"] == "skipped"
assert result["detection_id"] == "detection_123"
# Verify Redis calls
mock_redis.set_confirmation.assert_called_once_with(123, "detection_123", False)
mock_redis.queue_prompt_job.assert_called_once_with(123, detection)
mock_wait.assert_called_once_with(123, "detection_123", timeout=600)
# Should not call ingest when skipped
mock_redis.queue_ingest_job.assert_not_called()
@patch('app.core.learning_jobs.RedisConfig')
@patch('app.core.learning_jobs.ConversationWatcher')
def test_process_learning_detection_timeout(self, mock_watcher_class, mock_redis_class, tmp_path):
"""Test processing learning detection with timeout."""
# Mock Redis
mock_redis = MagicMock()
mock_redis_class.set_confirmation.return_value = None
mock_redis_class.queue_prompt_job.return_value = "prompt_job_123"
mock_redis_class.get_confirmation.return_value = None # Timeout
mock_redis_class.return_value = mock_redis
# Mock watcher
mock_watcher = MagicMock()
mock_watcher_class.return_value = mock_watcher
detection = {
"id": "detection_123",
"trigger": "switching",
"text": "switching from A to B",
"confidence": 0.95
}
with patch('app.core.learning_jobs.wait_for_confirmation') as mock_wait:
mock_wait.return_value = None # Timeout
result = process_learning_detection(123, detection)
assert result["status"] == "skipped"
assert result["detection_id"] == "detection_123"
# Should not call ingest when timeout
mock_redis.queue_ingest_job.assert_not_called()
@patch('app.core.learning_jobs.ConversationWatcher')
def test_prompt_user_for_confirmation(self, mock_watcher_class):
"""Test prompting user for confirmation."""
# Mock watcher
mock_watcher = MagicMock()
mock_watcher_class.return_value = mock_watcher
detection = {
"id": "detection_123",
"trigger": "switching",
"text": "switching from A to B",
"confidence": 0.95,
"context": "Some context around the switch"
}
result = prompt_user_for_confirmation(123, detection)
assert result["status"] == "prompted"
assert result["detection_id"] == "detection_123"
assert result["trigger"] == "switching"
# Should print prompt information
# In real implementation, this would show to user
# For test, we just verify the function returns correctly
@patch('app.core.learning_jobs.ConversationWatcher')
@patch('app.core.learning_jobs.urllib.request')
def test_ingest_to_mcp_success(self, mock_watcher_class, mock_request, tmp_path):
"""Test successful ingestion to MCP."""
# Mock watcher
mock_watcher = MagicMock()
mock_watcher_class.return_value = mock_watcher
# Create insights file
insights_dir = tmp_path / ".claude-os" / "project-profile"
insights_dir.mkdir(parents=True)
insights_file = insights_dir / "LEARNED_INSIGHTS.md"
insights_file.write_text("# Test Insights\n\nThis is a test insight.")
# Mock successful API response
mock_response = MagicMock()
mock_response.status = 200
mock_request.return_value.__enter__.return_value = mock_response
result = ingest_to_mcp(123, str(tmp_path))
assert result["status"] == "ingested"
assert result["project_id"] == 123
assert str(insights_file) in result["file"]
# Verify API call
mock_request.assert_called_once()
call_args = mock_request.call_args
assert "http://localhost:8051" in call_args[0][0] # URL
assert "/api/projects/123/ingest-document" in call_args[0][0] # Endpoint
# Verify request data
request_data = json.loads(call_args[0][1]["data"])
assert request_data["filename"] == "LEARNED_INSIGHTS.md"
assert request_data["mcp_type"] == "project_profile"
assert request_data["metadata"]["type"] == "learned_insights"
assert request_data["metadata"]["auto_generated"] is True
@patch('app.core.learning_jobs.ConversationWatcher')
def test_ingest_to_mcp_no_file(self, mock_watcher_class, tmp_path):
"""Test ingestion to MCP with no insights file."""
# Mock watcher
mock_watcher = MagicMock()
mock_watcher_class.return_value = mock_watcher
# Don't create insights file
insights_dir = tmp_path / ".claude-os" / "project-profile"
insights_dir.mkdir(parents=True)
result = ingest_to_mcp(123, str(tmp_path))
assert result["status"] == "error"
assert result["message"] == "insights_file_not_found"
@patch('app.core.learning_jobs.ConversationWatcher')
@patch('app.core.learning_jobs.urllib.request')
def test_ingest_to_mcp_api_error(self, mock_watcher_class, mock_request, tmp_path):
"""Test ingestion to MCP with API error."""
# Mock watcher
mock_watcher = MagicMock()
mock_watcher_class.return_value = mock_watcher
# Create insights file
insights_dir = tmp_path / ".claude-os" / "project-profile"
insights_dir.mkdir(parents=True)
insights_file = insights_dir / "LEARNED_INSIGHTS.md"
insights_file.write_text("# Test Insights")
# Mock API error
import urllib.error
mock_request.side_effect = urllib.error.HTTPError("500 Server Error")
result = ingest_to_mcp(123, str(tmp_path))
assert result["status"] == "error"
assert "API returned" in result["message"]
@patch('app.core.learning_jobs.ConversationWatcher')
@patch('app.core.learning_jobs.urllib.request')
def test_ingest_to_mcp_request_error(self, mock_watcher_class, mock_request, tmp_path):
"""Test ingestion to MCP with request error."""
# Mock watcher
mock_watcher = MagicMock()
mock_watcher_class.return_value = mock_watcher
# Create insights file
insights_dir = tmp_path / ".claude-os" / "project-profile"
insights_dir.mkdir(parents=True)
insights_file = insights_dir / "LEARNED_INSIGHTS.md"
insights_file.write_text("# Test Insights")
# Mock request error
mock_request.side_effect = Exception("Request failed")
result = ingest_to_mcp(123, str(tmp_path))
assert result["status"] == "error"
assert "Request failed" in result["message"]
@patch('app.core.learning_jobs.RedisConfig')
@patch('app.core.learning_jobs.ConversationWatcher')
def test_handle_conversation_message_user(self, mock_watcher_class, mock_redis_class):
"""Test handling user conversation message."""
# Mock Redis
mock_redis = MagicMock()
mock_redis_class.return_value = mock_redis
# Mock watcher
mock_watcher = MagicMock()
mock_watcher.detect_triggers.return_value = [
{
"id": "detection_1",
"trigger": "switching",
"text": "switching from A to B",
"confidence": 0.95
}
]
mock_watcher.should_prompt_user.return_value = True
mock_watcher_class.return_value = mock_watcher
message = {
"role": "user",
"text": "We're switching from A to B"
}
with patch('app.core.learning_jobs.RedisConfig.queue_learning_job') as mock_queue:
mock_queue.return_value = "job_123"
handle_conversation_message(123, message)
# Should process user message
mock_watcher.detect_triggers.assert_called_once_with("We're switching from A to B")
mock_watcher.should_prompt_user.assert_called_once()
# Should queue learning job
mock_queue.assert_called_once_with(123, {
"id": "detection_1",
"trigger": "switching",
"text": "switching from A to B",
"confidence": 0.95
})
@patch('app.core.learning_jobs.RedisConfig')
@patch('app.core.learning_jobs.ConversationWatcher')
def test_handle_conversation_message_assistant(self, mock_watcher_class, mock_redis_class):
"""Test handling assistant conversation message."""
# Mock Redis
mock_redis = MagicMock()
mock_redis_class.return_value = mock_redis
# Mock watcher
mock_watcher = MagicMock()
mock_watcher_class.return_value = mock_watcher
message = {
"role": "assistant",
"text": "I can help you with that switch"
}
with patch('app.core.learning_jobs.RedisConfig.queue_learning_job') as mock_queue:
handle_conversation_message(123, message)
# Should not process assistant message
mock_watcher.detect_triggers.assert_not_called()
mock_queue.assert_not_called()
@patch('app.core.learning_jobs.RedisConfig')
@patch('app.core.learning_jobs.ConversationWatcher')
def test_handle_conversation_message_no_detections(self, mock_watcher_class, mock_redis_class):
"""Test handling message with no detections."""
# Mock Redis
mock_redis = MagicMock()
mock_redis_class.return_value = mock_redis
# Mock watcher
mock_watcher = MagicMock()
mock_watcher.detect_triggers.return_value = []
mock_watcher_class.return_value = mock_watcher
message = {
"role": "user",
"text": "This is a normal message"
}
with patch('app.core.learning_jobs.RedisConfig.queue_learning_job') as mock_queue:
handle_conversation_message(123, message)
# Should not queue job for no detections
mock_watcher.detect_triggers.assert_called_once()
mock_queue.assert_not_called()
@patch('app.core.learning_jobs.RedisConfig')
@patch('app.core.learning_jobs.ConversationWatcher')
def test_handle_conversation_message_low_confidence(self, mock_watcher_class, mock_redis_class):
"""Test handling message with low confidence detections."""
# Mock Redis
mock_redis = MagicMock()
mock_redis_class.return_value = mock_redis
# Mock watcher
mock_watcher = MagicMock()
mock_watcher.detect_triggers.return_value = [
{
"id": "detection_1",
"trigger": "switching",
"text": "switching from A to B",
"confidence": 0.70 # Low confidence
}
]
mock_watcher.should_prompt_user.return_value = False
mock_watcher_class.return_value = mock_watcher
message = {
"role": "user",
"text": "We're switching from A to B"
}
with patch('app.core.learning_jobs.RedisConfig.queue_learning_job') as mock_queue:
handle_conversation_message(123, message)
# Should not queue job for low confidence
mock_watcher.detect_triggers.assert_called_once()
mock_watcher.should_prompt_user.assert_called_once()
mock_queue.assert_not_called()
@patch('app.core.learning_jobs.RedisConfig')
def test_wait_for_confirmation_confirmed(self, mock_redis_class):
"""Test waiting for confirmation when confirmed."""
# Mock Redis
mock_redis = MagicMock()
mock_redis_class.get_confirmation.side_effect = [None, True] # First None, then True
mock_redis_class.return_value = mock_redis
with patch('app.core.learning_jobs.time.sleep') as mock_sleep:
result = wait_for_confirmation(123, "detection_123", timeout=1)
assert result is True
# Should sleep once
mock_sleep.assert_called_once_with(1)
@patch('app.core.learning_jobs.RedisConfig')
def test_wait_for_confirmation_timeout(self, mock_redis_class):
"""Test waiting for confirmation with timeout."""
# Mock Redis
mock_redis = MagicMock()
mock_redis_class.get_confirmation.return_value = None # Always None
mock_redis_class.return_value = mock_redis
with patch('app.core.learning_jobs.time.sleep') as mock_sleep:
result = wait_for_confirmation(123, "detection_123", timeout=0.1)
assert result is False
# Should sleep once
mock_sleep.assert_called_once_with(0.1)
@patch('app.core.learning_jobs.RedisConfig')
def test_wait_for_confirmation_immediate(self, mock_redis_class):
"""Test waiting for confirmation when immediately confirmed."""
# Mock Redis
mock_redis = MagicMock()
mock_redis_class.get_confirmation.return_value = True # Immediately True
mock_redis_class.return_value = mock_redis
with patch('app.core.learning_jobs.time.sleep') as mock_sleep:
result = wait_for_confirmation(123, "detection_123", timeout=1)
assert result is True
# Should not sleep
mock_sleep.assert_not_called()
@patch('app.core.learning_jobs.RedisConfig')
@patch('app.core.learning_jobs.ConversationWatcher')
def test_subscribe_to_conversations(self, mock_watcher_class, mock_redis_class):
"""Test subscribing to conversations."""
# Mock Redis
mock_redis = MagicMock()
mock_pubsub = MagicMock()
mock_redis.subscribe_to_conversation.return_value = mock_pubsub
mock_redis_class.return_value = mock_redis
# Mock watcher
mock_watcher = MagicMock()
mock_watcher_class.return_value = mock_watcher
with patch('app.core.learning_jobs.time.sleep') as mock_sleep:
# Mock pubsub messages
message1 = json.dumps({"role": "user", "text": "Test message 1"})
message2 = json.dumps({"role": "assistant", "text": "Test response"})
invalid_message = "invalid json"
# Mock pubsub listen
def mock_listen():
# First call returns message dict
yield {"type": "message", "data": message1}
# Second call returns message dict
yield {"type": "message", "data": message2}
# Third call returns invalid JSON
yield {"type": "message", "data": invalid_message}
# Fourth call is not message type
yield {"type": "other", "data": "something"}
# Fifth call stops listening
raise KeyboardInterrupt()
mock_pubsub.listen.side_effect = mock_listen()
with patch('builtins.print'): # Suppress print output
try:
subscribe_to_conversations(123)
except KeyboardInterrupt:
pass # Expected
# Should handle valid messages
mock_watcher.handle_conversation_message.assert_any_call(123, {"role": "user", "text": "Test message 1"})
mock_watcher.handle_conversation_message.assert_any_call(123, {"role": "assistant", "text": "Test response"})
# Should ignore invalid JSON
assert mock_watcher.handle_conversation_message.call_count == 2
# Should close pubsub
mock_pubsub.close.assert_called_once()
@pytest.mark.integration
class TestLearningJobsIntegration:
"""Integration tests for learning jobs."""
@patch('app.core.learning_jobs.RedisConfig')
@patch('app.core.learning_jobs.ConversationWatcher')
def test_full_learning_workflow(self, mock_watcher_class, mock_redis_class, tmp_path):
"""Test full learning workflow from message to ingestion."""
# Mock Redis
mock_redis = MagicMock()
mock_redis_class.set_confirmation.return_value = None
mock_redis_class.queue_prompt_job.return_value = "prompt_job_123"
mock_redis_class.get_confirmation.return_value = True
mock_redis_class.queue_ingest_job.return_value = "ingest_job_123"
mock_redis_class.return_value = mock_redis
# Mock watcher
mock_watcher = MagicMock()
mock_watcher.detect_triggers.return_value = [
{
"id": "detection_123",
"trigger": "switching",
"text": "switching from A to B",
"confidence": 0.95
}
]
mock_watcher.should_prompt_user.return_value = True
mock_watcher_class.return_value = mock_watcher
# Create insights file
insights_dir = tmp_path / ".claude-os" / "project-profile"
insights_dir.mkdir(parents=True)
insights_file = insights_dir / "LEARNED_INSIGHTS.md"
insights_file.write_text("# Test Insights\n\nThis is a test insight.")
# Mock API response
with patch('app.core.learning_jobs.urllib.request') as mock_request:
mock_response = MagicMock()
mock_response.status = 200
mock_request.return_value.__enter__.return_value = mock_response
# Start with message
message = {"role": "user", "text": "We're switching from A to B"}
with patch('app.core.learning_jobs.wait_for_confirmation') as mock_wait:
mock_wait.return_value = True
# Handle message
handle_conversation_message(123, message)
# Process detection
result = process_learning_detection(123, {
"id": "detection_123",
"trigger": "switching",
"text": "switching from A to B",
"confidence": 0.95
})
assert result["status"] == "confirmed"
assert result["ingest_job_id"] == "ingest_job_123"
# Verify full workflow
mock_watcher.detect_triggers.assert_called_once()
mock_redis.queue_learning_job.assert_called_once()
mock_redis.queue_prompt_job.assert_called_once()
mock_wait.assert_called_once()
mock_redis.queue_ingest_job.assert_called_once()
mock_request.assert_called_once()
@pytest.mark.unit
class TestRedisConfigLearning:
"""Test Redis configuration for learning jobs."""
@patch('app.core.learning_jobs.redis.Redis')
def test_redis_config_connection(self, mock_redis_class):
"""Test Redis configuration for learning jobs."""
mock_redis = MagicMock()
mock_redis.ping.return_value = True
mock_redis_class.return_value = mock_redis
# Get Redis instance
redis = RedisConfig.get_redis()
# Should create connection with correct parameters
mock_redis_class.assert_called_once_with(
host="localhost",
port=6379,
db=0,
password=None,
decode_responses=True
)
# Should be singleton
redis2 = RedisConfig.get_redis()
assert redis is redis2
@patch('app.core.learning_jobs.redis.Redis')
def test_redis_config_connection_failure(self, mock_redis_class):
"""Test Redis configuration connection failure."""
mock_redis = MagicMock()
mock_redis.ping.side_effect = Exception("Connection failed")
mock_redis_class.return_value = mock_redis
with pytest.raises(Exception, match="Redis connection failed"):
RedisConfig.get_redis()
@patch('app.core.learning_jobs.redis.Redis')
@patch('app.core.learning_jobs.rq.Queue')
def test_redis_config_queues(self, mock_redis_class, mock_queue_class):
"""Test Redis queue configuration."""
mock_redis = MagicMock()
mock_redis.ping.return_value = True
mock_redis_class.return_value = mock_redis
mock_queue = MagicMock()
mock_queue_class.return_value = mock_queue
# Get learning queue
learning_queue = RedisConfig.get_learning_queue()
# Should create queue with correct parameters
mock_queue_class.assert_called_once_with(
"claude-os:learning",
connection=mock_redis
)
# Get prompt queue
prompt_queue = RedisConfig.get_prompt_queue()
# Should create queue with correct parameters
mock_queue_class.assert_any_call(
"claude-os:prompts",
connection=mock_redis
)
# Get ingest queue
ingest_queue = RedisConfig.get_ingest_queue()
# Should create queue with correct parameters
mock_queue_class.assert_any_call(
"claude-os:ingest",
connection=mock_redis
)
@patch('app.core.learning_jobs.redis.Redis')
def test_redis_config_publish_conversation(self, mock_redis_class):
"""Test publishing conversation messages."""
mock_redis = MagicMock()
mock_redis.ping.return_value = True
mock_redis_class.return_value = mock_redis
redis = RedisConfig.get_redis()
# Publish message
RedisConfig.publish_conversation(123, "user", "Test message")
# Should publish to correct channel
redis.publish.assert_called_once_with(
"claude-os:conversation:123",
json.dumps({
"role": "user",
"text": "Test message",
"timestamp": pytest.ANY # Any timestamp
})
)
@patch('app.core.learning_jobs.redis.Redis')
def test_redis_config_subscribe_to_conversation(self, mock_redis_class):
"""Test subscribing to conversation messages."""
mock_redis = MagicMock()
mock_redis.ping.return_value = True
mock_redis_class.return_value = mock_redis
mock_pubsub = MagicMock()
mock_redis.pubsub.return_value = mock_pubsub
redis = RedisConfig.get_redis()
# Subscribe to conversation
pubsub = RedisConfig.subscribe_to_conversation(123)
# Should subscribe to correct channel
redis.pubsub.assert_called_once()
mock_pubsub.subscribe.assert_called_once_with("claude-os:conversation:123")
# Should return pubsub
assert pubsub is mock_pubsub
@patch('app.core.learning_jobs.redis.Redis')
def test_redis_config_confirmation_operations(self, mock_redis_class):
"""Test Redis confirmation operations."""
mock_redis = MagicMock()
mock_redis.ping.return_value = True
mock_redis_class.return_value = mock_redis
redis = RedisConfig.get_redis()
# Set confirmation
RedisConfig.set_confirmation(123, "detection_123", True)
# Should set with TTL
redis.setex.assert_called_once_with(
"claude_os:prompt:123:detection_123:confirmed",
600, # 10 minutes
"True"
)
# Get confirmation (exists)
redis.get.return_value = "True"
result = RedisConfig.get_confirmation(123, "detection_123")
assert result is True
redis.get.assert_called_once_with(
"claude_os:prompt:123:detection_123:confirmed"
)
# Get confirmation (doesn't exist)
redis.get.return_value = None
result = RedisConfig.get_confirmation(123, "nonexistent_detection")
assert result is None
redis.get.assert_called_once_with(
"claude_os:prompt:123:nonexistent_detection:confirmed"
)
@patch('app.core.learning_jobs.redis.Redis')
@patch('app.core.learning_jobs.rq.Queue')
def test_redis_config_job_queueing(self, mock_redis_class, mock_queue_class):
"""Test Redis job queueing operations."""
mock_redis = MagicMock()
mock_redis.ping.return_value = True
mock_redis_class.return_value = mock_redis
mock_queue = MagicMock()
mock_queue_class.return_value = mock_queue
mock_queue.enqueue.return_value = MagicMock(id="job_123")
redis = RedisConfig.get_redis()
# Queue learning job
job_id = RedisConfig.queue_learning_job(123, {"test": "data"})
# Should enqueue with correct parameters
mock_queue.enqueue.assert_called_once_with(
"app.core.learning_jobs.process_learning_detection",
123,
{"test": "data"},
job_timeout="5m"
)
assert job_id == "job_123"