1+ import os
2+ import sys
3+ import pytest
4+ from unittest .mock import AsyncMock , patch , MagicMock
5+ from starlette .testclient import TestClient
6+ from contextlib import asynccontextmanager
7+
8+ # --- Setup Environment ---
9+ os .environ ["AGILEDAY_TENANT_ID" ] = "test-tenant"
10+ os .environ ["AGILEDAY_API_TOKEN" ] = "test-token"
11+
12+ # Add src to path
13+ sys .path .append (os .path .join (os .path .dirname (__file__ ), '../src' ))
14+
15+ from agileday_server import starlette_app
16+
17+ # --- Fixtures ---
18+
19+ @pytest .fixture
20+ def client ():
21+ """Returns a Starlette TestClient for making HTTP requests."""
22+ return TestClient (starlette_app )
23+
24+ @pytest .fixture
25+ def mock_server_run ():
26+ """
27+ Patches the internal MCP server.run method to prevent it from blocking.
28+ """
29+ with patch ("agileday_server.server.run" , new_callable = AsyncMock ) as mock_run :
30+ yield mock_run
31+
32+ @pytest .fixture
33+ def mock_sse_transport (mock_server_run ):
34+ """
35+ Mocks the internal SSE Transport methods to simulate ASGI responses.
36+ """
37+ with patch ("agileday_server.sse_transport" ) as mock :
38+
39+ # 1. Mock handle_post_message
40+ # The real SDK writes a 202 Accepted response to the 'send' channel.
41+ # We must replicate that side effect here.
42+ async def side_effect_post (scope , receive , send ):
43+ await send ({
44+ 'type' : 'http.response.start' ,
45+ 'status' : 202 ,
46+ 'headers' : [[b'content-type' , b'text/plain' ]]
47+ })
48+ await send ({
49+ 'type' : 'http.response.body' ,
50+ 'body' : b'Accepted'
51+ })
52+
53+ mock .handle_post_message = AsyncMock (side_effect = side_effect_post )
54+
55+ # 2. Mock connect_sse
56+ # The real SDK writes 200 OK headers for the Event Stream.
57+ # We use asynccontextmanager to replicate the 'async with' behavior.
58+ @asynccontextmanager
59+ async def side_effect_connect (scope , receive , send ):
60+ # Simulate starting the stream
61+ await send ({
62+ 'type' : 'http.response.start' ,
63+ 'status' : 200 ,
64+ 'headers' : [[b'content-type' , b'text/event-stream' ]]
65+ })
66+
67+ # Yield mock streams (reader, writer) required by the server
68+ yield (AsyncMock (), AsyncMock ())
69+
70+ mock .connect_sse .side_effect = side_effect_connect
71+
72+ yield mock
73+
74+ # --- Tests ---
75+
76+ def test_health_check (client ):
77+ """Ensure the health endpoint returns 200 OK."""
78+ response = client .get ("/health" )
79+ assert response .status_code == 200
80+ assert response .text == "OK"
81+
82+ def test_mcp_post_message_accepted (client , mock_sse_transport ):
83+ """
84+ Verifies that a POST to /mcp returns 202 Accepted.
85+ """
86+ response = client .post ("/mcp" , json = {"jsonrpc" : "2.0" , "method" : "ping" })
87+
88+ # We expect the mock to have been called with the raw ASGI arguments
89+ assert mock_sse_transport .handle_post_message .call_count == 1
90+
91+ # Check the response
92+ assert response .status_code == 202
93+ assert response .text == "Accepted"
94+
95+ def test_mcp_get_connects_sse (client , mock_sse_transport ):
96+ """
97+ Verifies that GET /mcp attempts to establish an SSE stream.
98+ """
99+ with client .stream ("GET" , "/mcp" ) as response :
100+ # Check we got the 200 OK headers simulated by our mock
101+ assert response .status_code == 200
102+
103+ # Verify the context manager was entered
104+ assert mock_sse_transport .connect_sse .called
105+
106+ def test_legacy_sse_endpoint_redirects (client , mock_sse_transport ):
107+ """
108+ Verifies that the deprecated /sse endpoint still works.
109+ """
110+ with client .stream ("GET" , "/sse" ) as response :
111+ assert response .status_code == 200
112+
113+ assert mock_sse_transport .connect_sse .called
114+
115+ def test_method_not_allowed (client ):
116+ """Ensure we enforce allowed methods (PUT should fail)."""
117+ response = client .put ("/mcp" )
118+ assert response .status_code == 405
0 commit comments