"""Integration tests for SSE endpoint and lifecycle event flow.""" import asyncio import uuid from collections.abc import Generator from typing import Any import pytest from fastapi.testclient import TestClient from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from src.api.system import events as events_module from src.auth.session import decode_session_cookie from src.config import Settings from src.models.project.git_repository import GitRepository from src.models.system.instance_event import InstanceEvent from src.models.project.project import Project from src.models.tool.tool_instance import ToolInstance from src.models.tool.tool_type import ToolType from src.services.instance.event_bus import InstanceEventBus, InstanceEventPayload @pytest.fixture def event_bus() -> Generator[InstanceEventBus, None, None]: bus = InstanceEventBus() bus._reset_for_testing() yield bus bus._reset_for_testing() @pytest.fixture def sample_payload() -> InstanceEventPayload: return { "event": "instance.started", "instance_id": str(uuid.uuid4()), "status": "starting", "message": "Container starting...", "metadata": {}, "timestamp": "2026-05-28T12:00:00Z", "correlation_id": str(uuid.uuid4()), } def _get_user_id_from_client(client: TestClient) -> uuid.UUID | None: settings = Settings() cookie = client.cookies.get("session") if not cookie: return None session = decode_session_cookie(settings=settings, cookie_value=cookie) if session and "user_id" in session: return uuid.UUID(session["user_id"]) return None @pytest.mark.integration def test_sse_requires_auth(test_client: TestClient) -> None: response = test_client.get("/events/stream") assert response.status_code == 401 @pytest.mark.integration def test_sse_enforces_connection_limit(authenticated_client: TestClient) -> None: user_id = _get_user_id_from_client(authenticated_client) assert user_id is not None events_module._connection_counts[user_id] = events_module.MAX_CONNECTIONS_PER_USER try: response = authenticated_client.get("/events/stream") assert response.status_code == 429 finally: events_module._connection_counts.pop(user_id, None) @pytest.mark.integration def test_sse_event_generator_format() -> None: """Test the SSE endpoint is registered.""" from src.api.system.events import router route_paths = [getattr(r, "path", "") for r in router.routes] assert any("/stream" in str(p) for p in route_paths) @pytest.mark.asyncio @pytest.mark.integration async def test_lifecycle_hook_publishes_event_and_persists( authenticated_client: TestClient, db_session: AsyncSession, event_bus: InstanceEventBus, ) -> None: """Test that the lifecycle hook publishes an event and persists an audit row.""" user_id = _get_user_id_from_client(authenticated_client) assert user_id is not None project = Project( id=uuid.uuid4(), name="test-project", description="Test", owner_id=user_id, ) repo = GitRepository( id=uuid.uuid4(), name="test-repo", path="/tmp/test-repo", project_id=project.id, owner_id=user_id, remote_url="https://github.com/test/repo.git", ) tool_type = ToolType( id=uuid.uuid4(), name="test-tool", display_name="Test Tool", category="other", interface_type="web", requires_port=True, default_port=8080, definition_type="legacy", compose_template="version: '3.8'\nservices:\n app:\n image: alpine\n command: sleep 3600\n", ) db_session.add_all([project, repo, tool_type]) await db_session.commit() instance = ToolInstance( id=uuid.uuid4(), name="test-instance", display_name="Test Instance", tool_type_id=tool_type.id, repository_id=repo.id, project_id=project.id, owner_id=user_id, status="pending", compose_path="/tmp/test-compose.yml", port=8080, ) db_session.add(instance) await db_session.commit() received: list[Any] = [] def subscriber(payload: InstanceEventPayload) -> None: received.append(payload) event_bus.subscribe("instance.created", subscriber) from src.services.instance.lifecycle_hooks import publish_lifecycle_event await publish_lifecycle_event( event_bus=event_bus, session=db_session, instance=instance, event_type="instance.created", created_by=user_id, status="pending", message="Instance created", ) assert len(received) == 1 assert received[0]["event"] == "instance.created" result = await db_session.execute( select(InstanceEvent).where(InstanceEvent.instance_id == instance.id) ) rows = result.scalars().all() assert len(rows) == 1 assert rows[0].event_type == "created" assert rows[0].created_by == user_id @pytest.mark.asyncio @pytest.mark.integration async def test_lifecycle_event_persists_audit_row( authenticated_client: TestClient, db_session: AsyncSession, event_bus: InstanceEventBus, ) -> None: """Test that publishing a lifecycle event persists an audit row.""" user_id = _get_user_id_from_client(authenticated_client) assert user_id is not None project = Project( id=uuid.uuid4(), name="test-project", description="Test", owner_id=user_id, ) repo = GitRepository( id=uuid.uuid4(), name="test-repo", path="/tmp/test-repo", project_id=project.id, owner_id=user_id, remote_url="https://github.com/test/repo.git", ) tool_type = ToolType( id=uuid.uuid4(), name="test-tool-2", display_name="Test Tool 2", category="other", interface_type="web", requires_port=True, default_port=8080, definition_type="legacy", compose_template="version: '3.8'\nservices:\n app:\n image: alpine\n command: sleep 3600\n", ) db_session.add_all([project, repo, tool_type]) await db_session.commit() instance = ToolInstance( id=uuid.uuid4(), name="test-instance", display_name="Test Instance", tool_type_id=tool_type.id, repository_id=repo.id, project_id=project.id, owner_id=user_id, status="running", compose_path="/tmp/test-compose.yml", port=8080, ) db_session.add(instance) await db_session.commit() from src.services.instance.lifecycle_hooks import publish_lifecycle_event await publish_lifecycle_event( event_bus=event_bus, session=db_session, instance=instance, event_type="instance.stopped", created_by=user_id, status="stopped", message="Instance stopped", ) result = await db_session.execute( select(InstanceEvent).where(InstanceEvent.instance_id == instance.id) ) rows = result.scalars().all() assert len(rows) == 1 assert rows[0].event_type == "stopped" assert rows[0].status == "stopped" assert rows[0].created_by == user_id @pytest.mark.integration def test_event_bus_pubsub(event_bus: InstanceEventBus) -> None: """Test that the event bus delivers events to subscribers.""" received: list[InstanceEventPayload] = [] def handler(payload: InstanceEventPayload) -> None: received.append(payload) event_bus.subscribe("test.event", handler) payload: InstanceEventPayload = { "event": "test.event", "instance_id": str(uuid.uuid4()), "status": "running", "message": "Test", "metadata": {}, "timestamp": "2026-05-28T12:00:00Z", "correlation_id": str(uuid.uuid4()), } asyncio.run(event_bus.publish("test.event", payload)) assert len(received) == 1 assert received[0]["event"] == "test.event"