Add sandbox_id__eq filter to AppConversationService search and count methods (#13387)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Tim O'Farrell
2026-03-13 11:24:58 -06:00
committed by GitHub
parent b4f00379b8
commit 0527c46bba
5 changed files with 299 additions and 2 deletions

View File

@@ -12,21 +12,28 @@ from fastapi import HTTPException, status
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversation,
AppConversationPage,
)
from openhands.app_server.app_conversation.app_conversation_router import (
batch_get_app_conversations,
count_app_conversations,
search_app_conversations,
)
from openhands.app_server.sandbox.sandbox_models import SandboxStatus
def _make_mock_app_conversation(conversation_id=None, user_id='test-user'):
def _make_mock_app_conversation(
conversation_id=None, user_id='test-user', sandbox_id=None
):
"""Create a mock AppConversation for testing."""
if conversation_id is None:
conversation_id = uuid4()
if sandbox_id is None:
sandbox_id = str(uuid4())
return AppConversation(
id=conversation_id,
created_by_user_id=user_id,
sandbox_id=str(uuid4()),
sandbox_id=sandbox_id,
sandbox_status=SandboxStatus.RUNNING,
)
@@ -34,11 +41,17 @@ def _make_mock_app_conversation(conversation_id=None, user_id='test-user'):
def _make_mock_service(
get_conversation_return=None,
batch_get_return=None,
search_return=None,
count_return=0,
):
"""Create a mock AppConversationService for testing."""
service = MagicMock()
service.get_app_conversation = AsyncMock(return_value=get_conversation_return)
service.batch_get_app_conversations = AsyncMock(return_value=batch_get_return or [])
service.search_app_conversations = AsyncMock(
return_value=search_return or AppConversationPage(items=[])
)
service.count_app_conversations = AsyncMock(return_value=count_return)
return service
@@ -207,3 +220,157 @@ class TestBatchGetAppConversations:
assert result[0] is not None
assert result[0].id == uuid1
assert result[1] is None
@pytest.mark.asyncio
class TestSearchAppConversations:
"""Test suite for search_app_conversations endpoint."""
async def test_search_with_sandbox_id_filter(self):
"""Test that sandbox_id__eq filter is passed to the service.
Arrange: Create mock service and specific sandbox_id
Act: Call search_app_conversations with sandbox_id__eq
Assert: Service is called with the sandbox_id__eq parameter
"""
# Arrange
sandbox_id = 'test-sandbox-123'
mock_conversation = _make_mock_app_conversation(sandbox_id=sandbox_id)
mock_service = _make_mock_service(
search_return=AppConversationPage(items=[mock_conversation])
)
# Act
result = await search_app_conversations(
sandbox_id__eq=sandbox_id,
app_conversation_service=mock_service,
)
# Assert
mock_service.search_app_conversations.assert_called_once()
call_kwargs = mock_service.search_app_conversations.call_args[1]
assert call_kwargs.get('sandbox_id__eq') == sandbox_id
assert len(result.items) == 1
assert result.items[0].sandbox_id == sandbox_id
async def test_search_without_sandbox_id_filter(self):
"""Test that sandbox_id__eq defaults to None when not provided.
Arrange: Create mock service
Act: Call search_app_conversations without sandbox_id__eq
Assert: Service is called with sandbox_id__eq=None
"""
# Arrange
mock_service = _make_mock_service()
# Act
await search_app_conversations(
app_conversation_service=mock_service,
)
# Assert
mock_service.search_app_conversations.assert_called_once()
call_kwargs = mock_service.search_app_conversations.call_args[1]
assert call_kwargs.get('sandbox_id__eq') is None
async def test_search_with_sandbox_id_and_other_filters(self):
"""Test that sandbox_id__eq works correctly with other filters.
Arrange: Create mock service
Act: Call search_app_conversations with sandbox_id__eq and other filters
Assert: Service is called with all parameters correctly
"""
# Arrange
sandbox_id = 'test-sandbox-456'
mock_service = _make_mock_service()
# Act
await search_app_conversations(
title__contains='test',
sandbox_id__eq=sandbox_id,
limit=50,
include_sub_conversations=True,
app_conversation_service=mock_service,
)
# Assert
mock_service.search_app_conversations.assert_called_once()
call_kwargs = mock_service.search_app_conversations.call_args[1]
assert call_kwargs.get('sandbox_id__eq') == sandbox_id
assert call_kwargs.get('title__contains') == 'test'
assert call_kwargs.get('limit') == 50
assert call_kwargs.get('include_sub_conversations') is True
@pytest.mark.asyncio
class TestCountAppConversations:
"""Test suite for count_app_conversations endpoint."""
async def test_count_with_sandbox_id_filter(self):
"""Test that sandbox_id__eq filter is passed to the service.
Arrange: Create mock service with count return value
Act: Call count_app_conversations with sandbox_id__eq
Assert: Service is called with the sandbox_id__eq parameter
"""
# Arrange
sandbox_id = 'test-sandbox-789'
mock_service = _make_mock_service(count_return=5)
# Act
result = await count_app_conversations(
sandbox_id__eq=sandbox_id,
app_conversation_service=mock_service,
)
# Assert
mock_service.count_app_conversations.assert_called_once()
call_kwargs = mock_service.count_app_conversations.call_args[1]
assert call_kwargs.get('sandbox_id__eq') == sandbox_id
assert result == 5
async def test_count_without_sandbox_id_filter(self):
"""Test that sandbox_id__eq defaults to None when not provided.
Arrange: Create mock service
Act: Call count_app_conversations without sandbox_id__eq
Assert: Service is called with sandbox_id__eq=None
"""
# Arrange
mock_service = _make_mock_service(count_return=10)
# Act
result = await count_app_conversations(
app_conversation_service=mock_service,
)
# Assert
mock_service.count_app_conversations.assert_called_once()
call_kwargs = mock_service.count_app_conversations.call_args[1]
assert call_kwargs.get('sandbox_id__eq') is None
assert result == 10
async def test_count_with_sandbox_id_and_other_filters(self):
"""Test that sandbox_id__eq works correctly with other filters.
Arrange: Create mock service
Act: Call count_app_conversations with sandbox_id__eq and other filters
Assert: Service is called with all parameters correctly
"""
# Arrange
sandbox_id = 'test-sandbox-abc'
mock_service = _make_mock_service(count_return=3)
# Act
result = await count_app_conversations(
title__contains='test',
sandbox_id__eq=sandbox_id,
app_conversation_service=mock_service,
)
# Assert
mock_service.count_app_conversations.assert_called_once()
call_kwargs = mock_service.count_app_conversations.call_args[1]
assert call_kwargs.get('sandbox_id__eq') == sandbox_id
assert call_kwargs.get('title__contains') == 'test'
assert result == 3

View File

@@ -2097,6 +2097,120 @@ class TestLiveStatusAppConversationService:
assert captured['workspace_working_dir'] == '/workspace/project'
@pytest.mark.asyncio
async def test_search_app_conversations_with_sandbox_id_filter(self):
"""Test that search_app_conversations passes sandbox_id__eq to the info service.
This verifies that the sandbox_id filter is correctly propagated through
the service layer to the underlying info service.
"""
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationInfoPage,
)
# Create test data with different sandbox IDs
sandbox_id_alpha = 'sandbox-alpha-123'
sandbox_id_beta = 'sandbox-beta-456'
conv_alpha = AppConversationInfo(
id=uuid4(),
created_by_user_id=None,
sandbox_id=sandbox_id_alpha,
title='Alpha Conversation',
)
conv_beta = AppConversationInfo(
id=uuid4(),
created_by_user_id=None,
sandbox_id=sandbox_id_beta,
title='Beta Conversation',
)
# Mock the info service to return filtered results based on sandbox_id__eq
async def mock_search(sandbox_id__eq=None, **kwargs):
if sandbox_id__eq == sandbox_id_alpha:
return AppConversationInfoPage(items=[conv_alpha])
elif sandbox_id__eq == sandbox_id_beta:
return AppConversationInfoPage(items=[conv_beta])
else:
return AppConversationInfoPage(items=[conv_alpha, conv_beta])
self.mock_app_conversation_info_service.search_app_conversation_info = (
AsyncMock(side_effect=mock_search)
)
# Mock sandbox service to return running status for sandbox lookups
self.mock_sandbox_service.batch_get_sandboxes = AsyncMock(return_value=[])
# Test filtering by sandbox_id_alpha
result = await self.service.search_app_conversations(
sandbox_id__eq=sandbox_id_alpha
)
# Verify the info service was called with the correct sandbox_id__eq
self.mock_app_conversation_info_service.search_app_conversation_info.assert_called()
call_kwargs = self.mock_app_conversation_info_service.search_app_conversation_info.call_args[
1
]
assert call_kwargs.get('sandbox_id__eq') == sandbox_id_alpha
# Verify only alpha conversation is returned
assert len(result.items) == 1
assert result.items[0].sandbox_id == sandbox_id_alpha
@pytest.mark.asyncio
async def test_count_app_conversations_with_sandbox_id_filter(self):
"""Test that count_app_conversations passes sandbox_id__eq to the info service.
This verifies that the sandbox_id filter is correctly propagated through
the service layer to the underlying info service for count operations.
"""
sandbox_id = 'sandbox-count-test-789'
# Mock the info service to return count based on sandbox_id__eq
async def mock_count(sandbox_id__eq=None, **kwargs):
if sandbox_id__eq == sandbox_id:
return 3 # 3 conversations match this sandbox
else:
return 10 # 10 total conversations
self.mock_app_conversation_info_service.count_app_conversation_info = AsyncMock(
side_effect=mock_count
)
# Test counting with sandbox_id filter
result = await self.service.count_app_conversations(sandbox_id__eq=sandbox_id)
# Verify the info service was called with the correct sandbox_id__eq
self.mock_app_conversation_info_service.count_app_conversation_info.assert_called_once()
call_kwargs = self.mock_app_conversation_info_service.count_app_conversation_info.call_args[
1
]
assert call_kwargs.get('sandbox_id__eq') == sandbox_id
# Verify filtered count is returned
assert result == 3
@pytest.mark.asyncio
async def test_search_app_conversations_sandbox_id_filter_returns_empty(self):
"""Test that search with non-matching sandbox_id returns empty results."""
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationInfoPage,
)
# Mock the info service to return empty for non-matching sandbox
self.mock_app_conversation_info_service.search_app_conversation_info = (
AsyncMock(return_value=AppConversationInfoPage(items=[]))
)
self.mock_sandbox_service.batch_get_sandboxes = AsyncMock(return_value=[])
# Test filtering by non-existent sandbox_id
result = await self.service.search_app_conversations(
sandbox_id__eq='non-existent-sandbox'
)
# Verify empty results
assert len(result.items) == 0
class TestPluginHandling:
"""Test cases for plugin-related functionality in LiveStatusAppConversationService."""