"""Tests for ping middleware.""" from unittest.mock import AsyncMock, MagicMock import anyio import pytest from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.server.middleware.ping import PingMiddleware class TestPingMiddlewareInit: """Test PingMiddleware initialization.""" def test_init_default(self): """Test default initialization.""" middleware = PingMiddleware() assert middleware.interval_ms == 30000 assert middleware._active_sessions == set() def test_init_custom(self): """Test custom interval initialization.""" middleware = PingMiddleware(interval_ms=5000) assert middleware.interval_ms == 5000 def test_init_invalid_interval_zero(self): """Test that zero interval raises ValueError.""" with pytest.raises(ValueError, match="interval_ms must be positive"): PingMiddleware(interval_ms=0) def test_init_invalid_interval_negative(self): """Test that negative interval raises ValueError.""" with pytest.raises(ValueError, match="interval_ms must be positive"): PingMiddleware(interval_ms=-1000) class TestPingMiddlewareOnMessage: """Test on_message hook behavior.""" async def test_starts_ping_task_on_first_message(self): """Test that ping task is started on first message from a session.""" middleware = PingMiddleware(interval_ms=1000) mock_session = MagicMock() mock_session._subscription_task_group = MagicMock() mock_session._subscription_task_group.start_soon = MagicMock() mock_context = MagicMock() mock_context.fastmcp_context.session = mock_session mock_call_next = AsyncMock(return_value="result") result = await middleware.on_message(mock_context, mock_call_next) assert result == "result" assert id(mock_session) in middleware._active_sessions mock_session._subscription_task_group.start_soon.assert_called_once() async def test_does_not_start_duplicate_task(self): """Test that duplicate messages from same session don't spawn duplicate tasks.""" middleware = PingMiddleware(interval_ms=1000) mock_session = MagicMock() mock_session._subscription_task_group = MagicMock() mock_session._subscription_task_group.start_soon = MagicMock() mock_context = MagicMock() mock_context.fastmcp_context.session = mock_session mock_call_next = AsyncMock(return_value="result") # First message await middleware.on_message(mock_context, mock_call_next) # Second message from same session await middleware.on_message(mock_context, mock_call_next) # Third message from same session await middleware.on_message(mock_context, mock_call_next) # Should only start task once assert mock_session._subscription_task_group.start_soon.call_count == 1 async def test_starts_separate_task_per_session(self): """Test that different sessions get separate ping tasks.""" middleware = PingMiddleware(interval_ms=1000) mock_session1 = MagicMock() mock_session1._subscription_task_group = MagicMock() mock_session1._subscription_task_group.start_soon = MagicMock() mock_session2 = MagicMock() mock_session2._subscription_task_group = MagicMock() mock_session2._subscription_task_group.start_soon = MagicMock() mock_context1 = MagicMock() mock_context1.fastmcp_context.session = mock_session1 mock_context2 = MagicMock() mock_context2.fastmcp_context.session = mock_session2 mock_call_next = AsyncMock(return_value="result") await middleware.on_message(mock_context1, mock_call_next) await middleware.on_message(mock_context2, mock_call_next) mock_session1._subscription_task_group.start_soon.assert_called_once() mock_session2._subscription_task_group.start_soon.assert_called_once() assert len(middleware._active_sessions) == 2 async def test_skips_task_when_no_task_group(self): """Test graceful handling when session has no task group.""" middleware = PingMiddleware(interval_ms=1000) mock_session = MagicMock() mock_session._subscription_task_group = None mock_context = MagicMock() mock_context.fastmcp_context.session = mock_session mock_call_next = AsyncMock(return_value="result") result = await middleware.on_message(mock_context, mock_call_next) assert result == "result" # Session should NOT be added if task group is None assert id(mock_session) not in middleware._active_sessions async def test_skips_when_fastmcp_context_is_none(self): """Test that middleware passes through when fastmcp_context is None.""" middleware = PingMiddleware(interval_ms=1000) mock_context = MagicMock() mock_context.fastmcp_context = None mock_call_next = AsyncMock(return_value="result") result = await middleware.on_message(mock_context, mock_call_next) assert result == "result" assert len(middleware._active_sessions) == 0 async def test_skips_when_request_context_is_none(self): """Test that middleware passes through when request_context is None.""" middleware = PingMiddleware(interval_ms=1000) mock_context = MagicMock() mock_context.fastmcp_context = MagicMock() mock_context.fastmcp_context.request_context = None mock_call_next = AsyncMock(return_value="result") result = await middleware.on_message(mock_context, mock_call_next) assert result == "result" assert len(middleware._active_sessions) == 0 class TestPingLoop: """Test the ping loop behavior.""" async def test_ping_loop_sends_pings_at_interval(self): """Test that ping loop sends pings at configured interval.""" middleware = PingMiddleware(interval_ms=50) mock_session = MagicMock() mock_session.send_ping = AsyncMock() session_id = id(mock_session) middleware._active_sessions.add(session_id) # Run ping loop for a short time then cancel with anyio.move_on_after(0.35): await middleware._ping_loop(mock_session, session_id) # Should have sent at least 2 pings in 350ms with 50ms interval assert mock_session.send_ping.call_count >= 2 async def test_ping_loop_cleans_up_on_cancellation(self): """Test that session is removed from active sessions on cancellation.""" middleware = PingMiddleware(interval_ms=50) mock_session = MagicMock() mock_session.send_ping = AsyncMock() session_id = 12345 middleware._active_sessions.add(session_id) # Run and cancel the ping loop with anyio.move_on_after(0.1): await middleware._ping_loop(mock_session, session_id) # Session should be cleaned up after cancellation assert session_id not in middleware._active_sessions class TestPingMiddlewareIntegration: """Integration tests for PingMiddleware with real FastMCP server.""" async def test_ping_middleware_registers_session(self): """Test that PingMiddleware registers sessions on first request.""" mcp = FastMCP("PingTestServer") middleware = PingMiddleware(interval_ms=50) mcp.add_middleware(middleware) @mcp.tool def hello() -> str: return "Hello!" assert len(middleware._active_sessions) == 0 async with Client(mcp) as client: result = await client.call_tool("hello") assert result.content[0].text == "Hello!" # Should have registered the session assert len(middleware._active_sessions) == 1 # Make another request - should not add duplicate await client.call_tool("hello") assert len(middleware._active_sessions) == 1 async def test_ping_task_cancelled_on_disconnect(self): """Test that ping task is properly cancelled when client disconnects.""" mcp = FastMCP("PingTestServer") middleware = PingMiddleware(interval_ms=50) mcp.add_middleware(middleware) @mcp.tool def hello() -> str: return "Hello!" async with Client(mcp) as client: await client.call_tool("hello") # Should have one active session assert len(middleware._active_sessions) == 1 # After disconnect, give a moment for cleanup await anyio.sleep(0.01) # Session should be cleaned up assert len(middleware._active_sessions) == 0