""" Tests for v4 logs API endpoints including LogsUploadView and get_trace_logs. """ import pytest from io import BytesIO from unittest.mock import patch, MagicMock, AsyncMock from fastapi import HTTPException, status, Request from agentops.api.routes.v4.logs import LogsUploadView, get_trace_logs, convert_trace_id from agentops.api.storage import ObjectUploadResponse from agentops.api.environment import SUPABASE_URL, SUPABASE_S3_LOGS_BUCKET @pytest.fixture def mock_jwt_payload(): """Mock JWT payload for testing""" return { 'project_id': 'test-project-123', 'project_prem_status': 'premium', 'api_key': 'test-api-key', 'aud': 'authenticated', 'exp': 1234567890, } @pytest.fixture def mock_s3_client(): """Mock S3 client for testing""" client = MagicMock() client.upload_fileobj = MagicMock() client.get_object = MagicMock() client.exceptions.NoSuchKey = Exception return client @pytest.fixture def mock_request(): """Mock FastAPI Request for testing""" request = MagicMock(spec=Request) # Create a proper async iterator for the stream method async def async_stream_generator(chunks): for chunk in chunks: yield chunk # Default stream that will be overridden in individual tests request.stream = AsyncMock(return_value=async_stream_generator([])) # Add required headers for public route validation request.headers = { "x-forwarded-for": "192.168.0.1", "x-forwarded-host": "api.agentops.ai", "origin": "https://app.agentops.ai", "referer": "https://app.agentops.ai/signin", "user-agent": "Mozilla/5.0 Chrome/91.0.4472.124", } return request class TestLogsUploadView: """Tests for the LogsUploadView class""" def test_bucket_name_configuration(self, mock_request): """Test that LogsUploadView has correct bucket configuration""" view = LogsUploadView(mock_request) assert view.bucket_name == SUPABASE_S3_LOGS_BUCKET @pytest.mark.asyncio async def test_successful_log_upload(self, mock_jwt_payload, mock_s3_client, mock_request): """Test successful log file upload with trace ID""" view = LogsUploadView(mock_request) # Set trace ID header (merge with existing headers) trace_id = "test-trace-12345" mock_request.headers.update({"Trace-Id": trace_id}) # Mock request stream test_content = b'[INFO] Test log content\n[ERROR] Test error message' async def async_stream_generator(): for chunk in [test_content]: yield chunk mock_request.stream = lambda: async_stream_generator() with ( patch('agentops.api.storage.get_s3_client', return_value=mock_s3_client), patch('agentops.auth.views.API_URL', 'http://localhost:8000'), ): # Bypass validation response = await view(token=mock_jwt_payload) assert isinstance(response, ObjectUploadResponse) assert response.size == len(test_content) expected_url = f"{SUPABASE_URL}/storage/v1/object/public/{SUPABASE_S3_LOGS_BUCKET}/{trace_id}.log" assert response.url == expected_url # Verify S3 upload was called with correct parameters mock_s3_client.upload_fileobj.assert_called_once() args = mock_s3_client.upload_fileobj.call_args[0] assert isinstance(args[0], BytesIO) assert args[1] == SUPABASE_S3_LOGS_BUCKET assert args[2] == f"{trace_id}.log" def test_missing_trace_id(self, mock_jwt_payload, mock_request): """Test that missing trace ID raises appropriate error""" view = LogsUploadView(mock_request) view.token = mock_jwt_payload # Remove Trace-Id header while keeping others mock_request.headers = {k: v for k, v in mock_request.headers.items() if k != "Trace-Id"} with pytest.raises(HTTPException) as exc_info: _ = view.filename assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST assert "No trace ID provided" in exc_info.value.detail def test_invalid_trace_id_characters(self, mock_jwt_payload, mock_request): """Test that invalid characters in trace ID are rejected""" view = LogsUploadView(mock_request) view.token = mock_jwt_payload # Trace ID with invalid characters invalid_trace_ids = ["trace@123", "trace#456", "trace 789", "trace/abc", "trace\\def"] for invalid_id in invalid_trace_ids: mock_request.headers.update({"Trace-Id": invalid_id}) with pytest.raises(HTTPException) as exc_info: _ = view.filename assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST assert "Trace ID contains invalid characters" in exc_info.value.detail def test_valid_trace_id_characters(self, mock_jwt_payload, mock_request): """Test that valid trace IDs are accepted""" view = LogsUploadView(mock_request) view.token = mock_jwt_payload valid_trace_ids = [ "trace123", "trace-456", "trace_789", "trace.abc", "trace-123_456.def", "TRACE-UPPER-123", ] for valid_id in valid_trace_ids: mock_request.headers.update({"Trace-Id": valid_id}) assert view.filename == f"{valid_id}.log" @pytest.mark.asyncio async def test_file_size_limit_enforcement(self, mock_jwt_payload, mock_request): """Test that file size limits are enforced for log uploads""" view = LogsUploadView(mock_request) view.max_size = 100 # Set small limit for testing mock_request.headers.update({"Trace-Id": "test-trace"}) # Mock request stream with oversized content large_content = b'x' * 150 # Exceeds 100 byte limit async def async_stream_generator(): for chunk in [large_content]: yield chunk mock_request.stream = lambda: async_stream_generator() with patch('agentops.auth.views.API_URL', 'http://localhost:8000'): # Bypass validation with pytest.raises(HTTPException) as exc_info: await view(token=mock_jwt_payload) assert exc_info.value.status_code == status.HTTP_413_REQUEST_ENTITY_TOO_LARGE assert "File size exceeds the maximum limit" in exc_info.value.detail class TestGetTraceLogs: """Tests for the get_trace_logs endpoint""" def test_convert_trace_id_hex_to_int(self): """Test conversion of hex trace IDs to integers""" # Hex strings should be converted assert convert_trace_id("1a2b3c") == str(int("1a2b3c", 16)) assert convert_trace_id("ABCDEF") == str(int("ABCDEF", 16)) assert convert_trace_id("123abc") == str(int("123abc", 16)) # Pure numeric strings should remain unchanged assert convert_trace_id("123456") == "123456" # Invalid hex should remain unchanged assert convert_trace_id("invalid") == "invalid" assert convert_trace_id("123xyz") == "123xyz" def test_convert_trace_id_edge_cases(self): """Test edge cases for trace ID conversion""" # Empty string assert convert_trace_id("") == "" # Mixed case hex assert convert_trace_id("1A2b3C") == str(int("1A2b3C", 16)) # Long hex string long_hex = "abcdef1234567890" assert convert_trace_id(long_hex) == str(int(long_hex, 16)) @pytest.mark.asyncio async def test_get_trace_logs_successful_retrieval(self, mock_s3_client): """Test successful log retrieval for valid trace""" trace_id = "test-trace-123" log_content = "INFO: Test log entry\nERROR: Test error" # Mock S3 response mock_response = {'Body': MagicMock()} mock_response['Body'].read.return_value = log_content.encode('utf-8') mock_s3_client.get_object.return_value = mock_response # Mock request with session mock_request = MagicMock() mock_request.state.session.user_id = "user-123" # Mock ORM session mock_orm = MagicMock() # Mock trace model with ( patch('agentops.api.routes.v4.logs.TraceModel') as mock_trace_model, patch('agentops.api.routes.v4.logs.ProjectModel') as mock_project_model, patch('agentops.api.routes.v4.logs.get_s3_client', return_value=mock_s3_client), ): # Setup trace mock with AsyncMock mock_trace = MagicMock() mock_trace.spans = ["span1", "span2"] # Non-empty spans mock_trace.project_id = "project-123" mock_trace_model.select = AsyncMock(return_value=mock_trace) # Setup project mock mock_project = MagicMock() mock_project.is_freeplan = False mock_project.org.is_user_member.return_value = True mock_project_model.get_by_id.return_value = mock_project response = await get_trace_logs(request=mock_request, orm=mock_orm, trace_id=trace_id) # Function is decorated with @add_cors_headers which wraps it in JSONResponse assert hasattr(response, 'body') # Extract the JSON data from the response import json response_data = json.loads(response.body.decode()) assert response_data['content'] == log_content assert response_data['trace_id'] == trace_id assert not response_data['freeplan_truncated'] # Verify S3 was called with converted trace ID mock_s3_client.get_object.assert_called_once_with( Bucket=SUPABASE_S3_LOGS_BUCKET, Key=f"{trace_id}.log" ) @pytest.mark.asyncio async def test_get_trace_logs_nonexistent_trace(self): """Test error when trace doesn't exist""" trace_id = "nonexistent-trace" mock_request = MagicMock() mock_orm = MagicMock() with patch('agentops.api.routes.v4.logs.TraceModel') as mock_trace_model: # Mock empty trace (no spans) mock_trace = MagicMock() mock_trace.spans = [] # Empty spans indicate no trace mock_trace_model.select = AsyncMock(return_value=mock_trace) with pytest.raises(HTTPException) as exc_info: await get_trace_logs(request=mock_request, orm=mock_orm, trace_id=trace_id) assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN assert "You do not have access to this trace" in exc_info.value.detail @pytest.mark.asyncio async def test_get_trace_logs_no_permission(self): """Test error when user doesn't have access to trace""" trace_id = "restricted-trace" mock_request = MagicMock() mock_request.state.session.user_id = "user-123" mock_orm = MagicMock() with ( patch('agentops.api.routes.v4.logs.TraceModel') as mock_trace_model, patch('agentops.api.routes.v4.logs.ProjectModel') as mock_project_model, ): # Setup trace mock mock_trace = MagicMock() mock_trace.spans = ["span1"] # Non-empty spans mock_trace.project_id = "project-123" mock_trace_model.select = AsyncMock(return_value=mock_trace) # Setup project mock - user is not a member mock_project = MagicMock() mock_project.org.is_user_member.return_value = False mock_project_model.get_by_id.return_value = mock_project with pytest.raises(HTTPException) as exc_info: await get_trace_logs(request=mock_request, orm=mock_orm, trace_id=trace_id) assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN assert "You do not have access to this trace" in exc_info.value.detail @pytest.mark.asyncio async def test_get_trace_logs_s3_file_not_found(self, mock_s3_client): """Test error when log file doesn't exist in S3""" trace_id = "trace-without-logs" # Mock S3 to raise NoSuchKey exception mock_s3_client.get_object.side_effect = mock_s3_client.exceptions.NoSuchKey() mock_request = MagicMock() mock_request.state.session.user_id = "user-123" mock_orm = MagicMock() with ( patch('agentops.api.routes.v4.logs.TraceModel') as mock_trace_model, patch('agentops.api.routes.v4.logs.ProjectModel') as mock_project_model, patch('agentops.api.routes.v4.logs.get_s3_client', return_value=mock_s3_client), ): # Setup valid trace and project mock_trace = MagicMock() mock_trace.spans = ["span1"] mock_trace.project_id = "project-123" mock_trace_model.select = AsyncMock(return_value=mock_trace) mock_project = MagicMock() mock_project.org.is_user_member.return_value = True mock_project_model.get_by_id.return_value = mock_project with pytest.raises(HTTPException) as exc_info: await get_trace_logs(request=mock_request, orm=mock_orm, trace_id=trace_id) assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND assert f"No logs found for trace ID: {trace_id}" in exc_info.value.detail