Files
syndarix/backend/tests/api/routes/test_events.py
Felipe Cardoso 742ce4c9c8 fix: Comprehensive validation and bug fixes
Infrastructure:
- Add Redis and Celery workers to all docker-compose files
- Fix celery migration race condition in entrypoint.sh
- Add healthchecks and resource limits to dev compose
- Update .env.template with Redis/Celery variables

Backend Models & Schemas:
- Rename Sprint.completed_points to velocity (per requirements)
- Add AgentInstance.name as required field
- Rename Issue external tracker fields for consistency
- Add IssueSource and TrackerType enums
- Add Project.default_tracker_type field

Backend Fixes:
- Add Celery retry configuration with exponential backoff
- Remove unused sequence counter from EventBus
- Add mypy overrides for test dependencies
- Fix test file using wrong schema (UserUpdate -> dict)

Frontend Fixes:
- Fix memory leak in useProjectEvents (proper cleanup)
- Fix race condition with stale closure in reconnection
- Sync TokenWithUser type with regenerated API client
- Fix expires_in null handling in useAuth
- Clean up unused imports in prototype pages
- Add ESLint relaxed rules for prototype files

CI/CD:
- Add E2E testing stage with Testcontainers
- Add security scanning with Trivy and pip-audit
- Add dependency caching for faster builds

Tests:
- Update all tests to use renamed fields (velocity, name, etc.)
- Fix 14 schema test failures
- All 1500 tests pass with 91% coverage

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 10:35:30 +01:00

499 lines
16 KiB
Python

"""
Tests for the SSE events endpoint.
This module tests the Server-Sent Events endpoint for project event streaming,
including:
- Authentication and authorization
- SSE stream connection and format
- Keepalive mechanism
- Reconnection support (Last-Event-ID)
- Connection cleanup
"""
import json
import uuid
from collections.abc import AsyncGenerator
from datetime import UTC, datetime
from unittest.mock import AsyncMock, patch
import pytest
import pytest_asyncio
from fastapi import status
from httpx import ASGITransport, AsyncClient
from app.api.dependencies.event_bus import get_event_bus
from app.core.database import get_db
from app.main import app
from app.schemas.events import Event, EventType
from app.services.event_bus import EventBus
class MockEventBus:
"""Mock EventBus for testing without Redis."""
def __init__(self):
self.published_events: list[Event] = []
self._should_yield_events = True
self._events_to_yield: list[str] = []
self._connected = True
@property
def is_connected(self) -> bool:
return self._connected
async def connect(self) -> None:
self._connected = True
async def disconnect(self) -> None:
self._connected = False
def get_project_channel(self, project_id: uuid.UUID | str) -> str:
"""Get the channel name for a project."""
return f"project:{project_id}"
@staticmethod
def create_event(
event_type: EventType,
project_id: uuid.UUID,
actor_type: str,
payload: dict | None = None,
actor_id: uuid.UUID | None = None,
event_id: str | None = None,
timestamp: datetime | None = None,
) -> Event:
"""Create a new Event."""
return Event(
id=event_id or str(uuid.uuid4()),
type=event_type,
timestamp=timestamp or datetime.now(UTC),
project_id=project_id,
actor_id=actor_id,
actor_type=actor_type,
payload=payload or {},
)
async def publish(self, channel: str, event: Event) -> int:
"""Publish an event to a channel."""
self.published_events.append(event)
return 1
def add_event_to_yield(self, event_json: str) -> None:
"""Add an event JSON string to be yielded by subscribe_sse."""
self._events_to_yield.append(event_json)
async def subscribe_sse(
self,
project_id: str | uuid.UUID,
last_event_id: str | None = None,
keepalive_interval: int = 30,
) -> AsyncGenerator[str, None]:
"""Mock subscribe_sse that yields pre-configured events then keepalive."""
# First yield any pre-configured events
for event_data in self._events_to_yield:
yield event_data
# Then yield keepalive
yield ""
# Then stop to allow test to complete
self._should_yield_events = False
@pytest_asyncio.fixture
async def mock_event_bus():
"""Create a mock event bus for testing."""
return MockEventBus()
@pytest_asyncio.fixture
async def client_with_mock_bus(async_test_db, mock_event_bus):
"""
Create a FastAPI test client with mocked database and event bus.
"""
_test_engine, AsyncTestingSessionLocal = async_test_db
async def override_get_db():
async with AsyncTestingSessionLocal() as session:
try:
yield session
finally:
pass
async def override_get_event_bus():
return mock_event_bus
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[get_event_bus] = override_get_event_bus
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as test_client:
yield test_client
app.dependency_overrides.clear()
@pytest_asyncio.fixture
async def user_token_with_mock_bus(client_with_mock_bus, async_test_user):
"""Create an access token for the test user."""
response = await client_with_mock_bus.post(
"/api/v1/auth/login",
json={
"email": async_test_user.email,
"password": "TestPassword123!",
},
)
assert response.status_code == 200, f"Login failed: {response.text}"
tokens = response.json()
return tokens["access_token"]
class TestSSEEndpointAuthentication:
"""Tests for SSE endpoint authentication."""
@pytest.mark.asyncio
async def test_stream_events_requires_authentication(self, client_with_mock_bus):
"""Test that SSE endpoint requires authentication."""
project_id = uuid.uuid4()
response = await client_with_mock_bus.get(
f"/api/v1/projects/{project_id}/events/stream",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.asyncio
async def test_stream_events_with_invalid_token(self, client_with_mock_bus):
"""Test that SSE endpoint rejects invalid tokens."""
project_id = uuid.uuid4()
response = await client_with_mock_bus.get(
f"/api/v1/projects/{project_id}/events/stream",
headers={"Authorization": "Bearer invalid_token"},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
class TestSSEEndpointStream:
"""Tests for SSE stream functionality."""
@pytest.mark.asyncio
async def test_stream_events_returns_sse_response(
self, client_with_mock_bus, user_token_with_mock_bus
):
"""Test that SSE endpoint returns proper SSE response."""
project_id = uuid.uuid4()
# Make request with a timeout to avoid hanging
response = await client_with_mock_bus.get(
f"/api/v1/projects/{project_id}/events/stream",
headers={"Authorization": f"Bearer {user_token_with_mock_bus}"},
timeout=5.0,
)
# The response should start streaming
assert response.status_code == status.HTTP_200_OK
assert "text/event-stream" in response.headers.get("content-type", "")
@pytest.mark.asyncio
async def test_stream_events_with_events(
self, client_with_mock_bus, user_token_with_mock_bus, mock_event_bus
):
"""Test that SSE endpoint yields events."""
project_id = uuid.uuid4()
# Create a test event and add it to the mock bus
test_event = Event(
id=str(uuid.uuid4()),
type=EventType.AGENT_MESSAGE,
timestamp=datetime.now(UTC),
project_id=project_id,
actor_type="agent",
payload={"message": "test"},
)
mock_event_bus.add_event_to_yield(test_event.model_dump_json())
# Request the stream
response = await client_with_mock_bus.get(
f"/api/v1/projects/{project_id}/events/stream",
headers={"Authorization": f"Bearer {user_token_with_mock_bus}"},
timeout=5.0,
)
assert response.status_code == status.HTTP_200_OK
# Check response contains event data
content = response.text
assert "agent.message" in content or "data:" in content
@pytest.mark.asyncio
async def test_stream_events_with_last_event_id(
self, client_with_mock_bus, user_token_with_mock_bus
):
"""Test that Last-Event-ID header is accepted."""
project_id = uuid.uuid4()
last_event_id = str(uuid.uuid4())
response = await client_with_mock_bus.get(
f"/api/v1/projects/{project_id}/events/stream",
headers={
"Authorization": f"Bearer {user_token_with_mock_bus}",
"Last-Event-ID": last_event_id,
},
timeout=5.0,
)
# Should accept the header and return OK
assert response.status_code == status.HTTP_200_OK
class TestSSEEndpointHeaders:
"""Tests for SSE response headers."""
@pytest.mark.asyncio
async def test_stream_events_cache_control_header(
self, client_with_mock_bus, user_token_with_mock_bus
):
"""Test that SSE response has no-cache header."""
project_id = uuid.uuid4()
response = await client_with_mock_bus.get(
f"/api/v1/projects/{project_id}/events/stream",
headers={"Authorization": f"Bearer {user_token_with_mock_bus}"},
timeout=5.0,
)
assert response.status_code == status.HTTP_200_OK
cache_control = response.headers.get("cache-control", "")
assert "no-cache" in cache_control.lower()
class TestTestEventEndpoint:
"""Tests for the test event endpoint."""
@pytest.mark.asyncio
async def test_send_test_event_requires_auth(self, client_with_mock_bus):
"""Test that test event endpoint requires authentication."""
project_id = uuid.uuid4()
response = await client_with_mock_bus.post(
f"/api/v1/projects/{project_id}/events/test",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.asyncio
async def test_send_test_event_success(
self, client_with_mock_bus, user_token_with_mock_bus, mock_event_bus
):
"""Test sending a test event."""
project_id = uuid.uuid4()
response = await client_with_mock_bus.post(
f"/api/v1/projects/{project_id}/events/test",
headers={"Authorization": f"Bearer {user_token_with_mock_bus}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["success"] is True
assert "event_id" in data
assert data["event_type"] == "agent.message"
# Verify event was published
assert len(mock_event_bus.published_events) == 1
published = mock_event_bus.published_events[0]
assert published.type == EventType.AGENT_MESSAGE
assert published.project_id == project_id
class TestEventSchema:
"""Tests for the Event schema."""
def test_event_creation(self):
"""Test Event creation with required fields."""
project_id = uuid.uuid4()
event = Event(
id=str(uuid.uuid4()),
type=EventType.AGENT_MESSAGE,
timestamp=datetime.now(UTC),
project_id=project_id,
actor_type="agent",
payload={"message": "test"},
)
assert event.id is not None
assert event.type == EventType.AGENT_MESSAGE
assert event.project_id == project_id
assert event.actor_type == "agent"
assert event.payload == {"message": "test"}
def test_event_json_serialization(self):
"""Test Event JSON serialization."""
project_id = uuid.uuid4()
event = Event(
id="test-id",
type=EventType.AGENT_STATUS_CHANGED,
timestamp=datetime.now(UTC),
project_id=project_id,
actor_type="system",
payload={"status": "running"},
)
json_str = event.model_dump_json()
parsed = json.loads(json_str)
assert parsed["id"] == "test-id"
assert parsed["type"] == "agent.status_changed"
assert str(parsed["project_id"]) == str(project_id)
assert parsed["payload"]["status"] == "running"
class TestEventBusUnit:
"""Unit tests for EventBus class."""
@pytest.mark.asyncio
async def test_event_bus_not_connected_raises(self):
"""Test that accessing redis_client before connect raises."""
from app.services.event_bus import EventBusConnectionError
bus = EventBus()
with pytest.raises(EventBusConnectionError, match="not connected"):
_ = bus.redis_client
@pytest.mark.asyncio
async def test_event_bus_channel_names(self):
"""Test channel name generation."""
bus = EventBus()
project_id = uuid.uuid4()
agent_id = uuid.uuid4()
user_id = uuid.uuid4()
assert bus.get_project_channel(project_id) == f"project:{project_id}"
assert bus.get_agent_channel(agent_id) == f"agent:{agent_id}"
assert bus.get_user_channel(user_id) == f"user:{user_id}"
def test_event_bus_create_event(self):
"""Test EventBus.create_event factory method."""
project_id = uuid.uuid4()
actor_id = uuid.uuid4()
event = EventBus.create_event(
event_type=EventType.ISSUE_CREATED,
project_id=project_id,
actor_type="user",
actor_id=actor_id,
payload={"title": "Test Issue"},
)
assert event.type == EventType.ISSUE_CREATED
assert event.project_id == project_id
assert event.actor_id == actor_id
assert event.actor_type == "user"
assert event.payload == {"title": "Test Issue"}
class TestEventBusIntegration:
"""Integration tests for EventBus with mocked Redis."""
@pytest.mark.asyncio
async def test_event_bus_connect_disconnect(self):
"""Test EventBus connect and disconnect."""
with patch("app.services.event_bus.redis.from_url") as mock_redis:
mock_client = AsyncMock()
mock_redis.return_value = mock_client
mock_client.ping = AsyncMock()
mock_client.pubsub = lambda: AsyncMock()
bus = EventBus(redis_url="redis://localhost:6379/0")
# Connect
await bus.connect()
mock_client.ping.assert_called_once()
assert bus._redis_client is not None
assert bus.is_connected
# Disconnect
await bus.disconnect()
mock_client.aclose.assert_called_once()
assert bus._redis_client is None
assert not bus.is_connected
@pytest.mark.asyncio
async def test_event_bus_publish(self):
"""Test EventBus event publishing."""
with patch("app.services.event_bus.redis.from_url") as mock_redis:
mock_client = AsyncMock()
mock_redis.return_value = mock_client
mock_client.ping = AsyncMock()
mock_client.publish = AsyncMock(return_value=1)
mock_client.pubsub = lambda: AsyncMock()
bus = EventBus()
await bus.connect()
project_id = uuid.uuid4()
event = EventBus.create_event(
event_type=EventType.AGENT_SPAWNED,
project_id=project_id,
actor_type="system",
payload={"agent_name": "test-agent"},
)
channel = bus.get_project_channel(project_id)
result = await bus.publish(channel, event)
# Verify publish was called
mock_client.publish.assert_called_once()
call_args = mock_client.publish.call_args
# Check channel name
assert call_args[0][0] == f"project:{project_id}"
# Check result
assert result == 1
await bus.disconnect()
@pytest.mark.asyncio
async def test_event_bus_connect_failure(self):
"""Test EventBus handles connection failure."""
from app.services.event_bus import EventBusConnectionError
with patch("app.services.event_bus.redis.from_url") as mock_redis:
mock_client = AsyncMock()
mock_redis.return_value = mock_client
import redis.asyncio as redis_async
mock_client.ping = AsyncMock(
side_effect=redis_async.ConnectionError("Connection refused")
)
bus = EventBus()
with pytest.raises(EventBusConnectionError, match="Failed to connect"):
await bus.connect()
@pytest.mark.asyncio
async def test_event_bus_already_connected(self):
"""Test EventBus connect when already connected is a no-op."""
with patch("app.services.event_bus.redis.from_url") as mock_redis:
mock_client = AsyncMock()
mock_redis.return_value = mock_client
mock_client.ping = AsyncMock()
mock_client.pubsub = lambda: AsyncMock()
bus = EventBus()
# First connect
await bus.connect()
assert mock_client.ping.call_count == 1
# Second connect should be a no-op
await bus.connect()
assert mock_client.ping.call_count == 1
await bus.disconnect()