Skip to content

Commit 6e0f8ca

Browse files
committed
test: add unittests for fastapi/starlette app
Signed-off-by: Shingo OKAWA <[email protected]>
1 parent b313e2a commit 6e0f8ca

File tree

2 files changed

+321
-25
lines changed

2 files changed

+321
-25
lines changed
Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,26 @@
11
from unittest.mock import MagicMock
22

3-
import pytest
4-
5-
from a2a.server.apps.jsonrpc.jsonrpc_app import JSONRPCApplicationBuilder
63
from a2a.server.apps.jsonrpc.fastapi_app import A2AFastAPIApplication
4+
from a2a.server.apps.jsonrpc.jsonrpc_app import JSONRPCApplicationBuilder
75
from a2a.server.request_handlers.request_handler import RequestHandler
86
from a2a.types import AgentCard
97

108

11-
def test_builder_protocol():
9+
class TestA2AFastAPIApplication:
1210
"""
13-
Tests that `A2AFastAPIApplication` matches the `JSONRPCApplicationBuilder` protocol.
14-
15-
This ensures that `A2AFastAPIApplication` can be used interchangeably where
16-
`JSONRPCApplicationBuilder` is expected.
11+
Unit tests for the `A2AFastAPIApplication` implementation.
1712
"""
18-
agent_card = MagicMock(spec=AgentCard)
19-
agent_card.url = 'http://mockurl.com'
20-
agent_card.supportsAuthenticatedExtendedCard = False
21-
http_handler = MagicMock(spec=RequestHandler)
22-
application = A2AFastAPIApplication(agent_card, http_handler)
23-
assert isinstance(application, JSONRPCApplicationBuilder)
13+
14+
def test_builder_protocol(self):
15+
"""
16+
Tests that `A2AFastAPIApplication` matches the `JSONRPCApplicationBuilder` protocol.
17+
18+
This ensures that `A2AFastAPIApplication` can be used interchangeably where
19+
`JSONRPCApplicationBuilder` is expected.
20+
"""
21+
agent_card = MagicMock(spec=AgentCard)
22+
agent_card.url = 'http://mockurl.com'
23+
agent_card.supportsAuthenticatedExtendedCard = False
24+
http_handler = MagicMock(spec=RequestHandler)
25+
application = A2AFastAPIApplication(agent_card, http_handler)
26+
assert isinstance(application, JSONRPCApplicationBuilder)

tests/server/apps/jsonrpc/test_starlette_app.py

Lines changed: 304 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,315 @@
22

33
import pytest
44

5+
from starlette.testclient import TestClient
6+
57
from a2a.server.apps.jsonrpc.jsonrpc_app import JSONRPCApplicationBuilder
6-
from a2a.server.apps.jsonrpc.starlette_app import A2AStarletteApplication
8+
from a2a.server.apps.jsonrpc.starlette_app import (
9+
A2AStarletteApplication,
10+
StarletteBuilder,
11+
StarletteRouteBuilder,
12+
StarletteRouteConfig,
13+
_get_path_from_url,
14+
_join_url,
15+
)
716
from a2a.server.request_handlers.request_handler import RequestHandler
817
from a2a.types import AgentCard
918

1019

11-
def test_builder_protocol():
20+
class TestA2AStarletteApplication:
21+
"""
22+
Unit tests for the `A2AStarletteApplication` implementation.
1223
"""
13-
Tests that `A2AStarletteApplication` matches the `JSONRPCApplicationBuilder` protocol.
1424

15-
This ensures that `A2AStarletteApplication` can be used interchangeably where
16-
`JSONRPCApplicationBuilder` is expected.
25+
def test_builder_protocol(self):
26+
"""
27+
Tests that `A2AStarletteApplication` matches the `JSONRPCApplicationBuilder` protocol.
28+
29+
This ensures that `A2AStarletteApplication` can be used interchangeably where
30+
`JSONRPCApplicationBuilder` is expected.
31+
"""
32+
agent_card = MagicMock(spec=AgentCard)
33+
agent_card.url = 'http://mockurl.com'
34+
agent_card.supportsAuthenticatedExtendedCard = False
35+
http_handler = MagicMock(spec=RequestHandler)
36+
application = A2AStarletteApplication(agent_card, http_handler)
37+
assert isinstance(application, JSONRPCApplicationBuilder)
38+
39+
40+
class TestStarletteRouteConfig:
41+
"""
42+
Unit tests for the `StarletteRouteConfig` dataclass.
43+
"""
44+
45+
def test_starlette_route_config_defaults(self):
46+
"""
47+
Verifies that `StarletteRouteConfig` initializes with the expected default values
48+
for all route paths.
49+
"""
50+
config = StarletteRouteConfig()
51+
assert config.agent_card_path == '/agent.json'
52+
assert (
53+
config.extended_agent_card_path
54+
== '/agent/authenticatedExtendedCard'
55+
)
56+
assert config.rpc_path == '/'
57+
58+
def test_starlette_route_config_custom_values(self):
59+
"""
60+
Verifies that custom values passed to `StarletteRouteConfig` are correctly
61+
assigned and retained.
62+
"""
63+
config = StarletteRouteConfig(
64+
agent_card_path='/custom/agent.json',
65+
extended_agent_card_path='/custom/agent/authenticatedExtendedCard',
66+
rpc_path='/custom',
67+
)
68+
assert config.agent_card_path == '/custom/agent.json'
69+
assert (
70+
config.extended_agent_card_path
71+
== '/custom/agent/authenticatedExtendedCard'
72+
)
73+
assert config.rpc_path == '/custom'
74+
75+
76+
class TestStarletteRouteBuilder:
77+
"""
78+
Unit tests for the `StarletteRouteBuilder` class.
1779
"""
18-
agent_card = MagicMock(spec=AgentCard)
19-
agent_card.url = 'http://mockurl.com'
20-
agent_card.supportsAuthenticatedExtendedCard = False
21-
http_handler = MagicMock(spec=RequestHandler)
22-
application = A2AStarletteApplication(agent_card, http_handler)
23-
assert isinstance(application, JSONRPCApplicationBuilder)
80+
81+
def _mock_agent_card(self, supports_extended=False):
82+
"""
83+
Creates a mocked `AgentCard` with the given `supportsAuthenticatedExtendedCard` setting.
84+
"""
85+
card = MagicMock(spec=AgentCard)
86+
card.supportsAuthenticatedExtendedCard = supports_extended
87+
return card
88+
89+
def test_build_routes_with_defaults(self):
90+
"""
91+
Tests that `StarletteRouteBuilder` creates the correct default routes
92+
when no custom configuration is provided and the extended card is not supported.
93+
"""
94+
agent_card = self._mock_agent_card(supports_extended=False)
95+
handler = MagicMock(spec=RequestHandler)
96+
builder = StarletteRouteBuilder(agent_card, handler)
97+
routes = builder.build()
98+
assert isinstance(routes, list)
99+
assert len(routes) == 2
100+
paths = {route.path for route in routes}
101+
assert '/' in paths
102+
assert '/agent.json' in paths
103+
104+
def test_build_routes_with_authenticated_extended_card(self):
105+
"""
106+
Tests that the authenticated extended card route is included
107+
when `supportsAuthenticatedExtendedCard` is set to True.
108+
"""
109+
agent_card = self._mock_agent_card(supports_extended=True)
110+
handler = MagicMock(spec=RequestHandler)
111+
builder = StarletteRouteBuilder(agent_card, handler)
112+
routes = builder.build()
113+
assert len(routes) == 3
114+
paths = {route.path for route in routes}
115+
assert '/agent/authenticatedExtendedCard' in paths
116+
117+
def test_build_routes_with_custom_config(self):
118+
"""
119+
Tests that custom route paths specified via `StarletteRouteConfig`
120+
are respected and correctly used in the constructed routes.
121+
"""
122+
agent_card = self._mock_agent_card(supports_extended=True)
123+
handler = MagicMock(spec=RequestHandler)
124+
config = StarletteRouteConfig(
125+
agent_card_path='/custom/agent.json',
126+
extended_agent_card_path='/custom/agent/authenticatedExtendedCard',
127+
rpc_path='/custom',
128+
)
129+
builder = StarletteRouteBuilder(agent_card, handler, config=config)
130+
routes = builder.build()
131+
assert len(routes) == 3
132+
paths = {route.path for route in routes}
133+
assert '/custom/agent.json' in paths
134+
assert '/custom/agent/authenticatedExtendedCard' in paths
135+
assert '/custom' in paths
136+
137+
def test_route_handlers_are_bound(self):
138+
"""
139+
Tests that all constructed routes are associated with the expected handler methods,
140+
and that the route names are correctly set.
141+
"""
142+
agent_card = self._mock_agent_card(supports_extended=True)
143+
handler = MagicMock(spec=RequestHandler)
144+
builder = StarletteRouteBuilder(agent_card, handler)
145+
routes = builder.build()
146+
assert len(routes) == 3
147+
route_names = {route.name for route in routes}
148+
assert 'a2a_handler' in route_names
149+
assert 'agent_card' in route_names
150+
assert 'authenticated_extended_agent_card' in route_names
151+
152+
153+
class TestURLUtils:
154+
"""
155+
Unit tests for URL utility functions `_join_url` and `_get_path_from_url`.
156+
"""
157+
158+
@pytest.mark.parametrize(
159+
'base, paths, expected',
160+
[
161+
('http://example.com', ['a', 'b'], 'http://example.com/a/b'),
162+
('http://example.com/', ['a', 'b'], 'http://example.com/a/b'),
163+
(
164+
'http://example.com/base/',
165+
['a', 'b'],
166+
'http://example.com/base/a/b',
167+
),
168+
(
169+
'http://example.com/base',
170+
['a', 'b/'],
171+
'http://example.com/base/a/b',
172+
),
173+
('http://example.com/', [], 'http://example.com/'),
174+
('http://example.com', [], 'http://example.com/'),
175+
(
176+
'http://example.com//',
177+
['//a//', '//b//'],
178+
'http://example.com/a/b',
179+
),
180+
],
181+
)
182+
def test_join_url(self, base, paths, expected):
183+
"""
184+
Tests that `_join_url` correctly joins a base URL with multiple path fragments,
185+
normalizing slashes and producing a well-formed absolute URL.
186+
"""
187+
assert _join_url(base, *paths) == expected
188+
189+
def test_join_url_preserves_query_and_fragment(self):
190+
"""
191+
Tests that `_join_url` preserves query parameters and fragment identifiers
192+
from the base URL when joining with additional paths.
193+
"""
194+
base = 'http://example.com/base?foo=1#section'
195+
result = _join_url(base, 'x', 'y')
196+
assert result.startswith('http://example.com/base/x/y')
197+
assert '?foo=1' in result or '#section' in result
198+
199+
@pytest.mark.parametrize(
200+
'url, expected_path',
201+
[
202+
('http://example.com', '/'),
203+
('http://example.com/', '/'),
204+
('http://example.com/api/v1/resource', '/api/v1/resource'),
205+
('http://example.com/api/', '/api/'),
206+
('http://example.com?x=1', '/'),
207+
('http://example.com#section', '/'),
208+
],
209+
)
210+
def test_get_path_from_url(self, url, expected_path):
211+
"""
212+
Tests that `_get_path_from_url` correctly extracts the path component from a full URL.
213+
Ensures that an empty or missing path defaults to '/'.
214+
"""
215+
assert _get_path_from_url(url) == expected_path
216+
217+
218+
class TestStarletteBuilder:
219+
"""
220+
Unit tests for the `StarletteBuilder` class.
221+
"""
222+
223+
def _mock_route_builder(
224+
self,
225+
extended=False,
226+
same_url=True,
227+
):
228+
"""
229+
Helper to create a mock `StarletteRouteBuilder` with configurable
230+
agent card, extended card, and route config for testing.
231+
232+
Args:
233+
extended: Whether to include an authenticated extended agent card.
234+
same_url: Whether agent_card.url and extended_agent_card.url are the same.
235+
236+
Returns:
237+
A mocked `StarletteRouteBuilder` instance.
238+
"""
239+
agent_card = MagicMock(spec=AgentCard)
240+
agent_card.url = 'http://example.com/agent'
241+
agent_card.supportsAuthenticatedExtendedCard = extended
242+
if extended:
243+
extended_card = MagicMock(spec=AgentCard)
244+
extended_card.url = (
245+
agent_card.url if same_url else 'http://other.com'
246+
)
247+
else:
248+
extended_card = None
249+
config = StarletteRouteConfig(
250+
agent_card_path='/agent.json',
251+
rpc_path='/',
252+
extended_agent_card_path='/agent/authenticatedExtendedCard',
253+
)
254+
handler = MagicMock(spec=RequestHandler)
255+
builder = StarletteRouteBuilder(
256+
agent_card=agent_card,
257+
http_handler=handler,
258+
extended_agent_card=extended_card,
259+
config=config,
260+
)
261+
return builder
262+
263+
def test_mount_and_build_app(self):
264+
"""
265+
Tests that a route builder can be mounted and that the resulting
266+
Starlette application responds with a valid Agent Catalog document
267+
at `/.well-known/api-catalog`.
268+
"""
269+
route_builder = self._mock_route_builder()
270+
app_builder = StarletteBuilder()
271+
app = app_builder.mount(route_builder).build()
272+
client = TestClient(app)
273+
response = client.get('/.well-known/api-catalog')
274+
assert response.status_code == 200
275+
data = response.json()
276+
assert 'linkset' in data
277+
assert isinstance(data['linkset'], list)
278+
assert data['linkset'][0]['anchor'].endswith('/')
279+
280+
def test_duplicate_mount_raises(self):
281+
"""
282+
Tests that attempting to mount two route builders at the same path
283+
raises a `ValueError`, ensuring path uniqueness enforcement.
284+
"""
285+
builder = StarletteBuilder()
286+
rb1 = self._mock_route_builder()
287+
rb2 = self._mock_route_builder()
288+
builder.mount(rb1)
289+
with pytest.raises(ValueError, match='already exists'):
290+
builder.mount(rb2)
291+
292+
def test_inconsistent_card_urls_raises(self):
293+
"""
294+
Tests that mounting a route builder with mismatched `agent_card.url`
295+
and `extended_agent_card.url` raises a `ValueError`.
296+
"""
297+
builder = StarletteBuilder()
298+
bad_rb = self._mock_route_builder(extended=True, same_url=False)
299+
with pytest.raises(ValueError, match='must be the same'):
300+
builder.mount(bad_rb)
301+
302+
def test_agent_catalog_includes_extended_card(self):
303+
"""
304+
Tests that when an extended agent card is provided,
305+
its URL is correctly included in the `describedby` section
306+
of the generated Agent Catalog.
307+
"""
308+
rb = self._mock_route_builder(extended=True)
309+
builder = StarletteBuilder()
310+
app = builder.mount(rb).build()
311+
client = TestClient(app)
312+
response = client.get('/.well-known/api-catalog')
313+
assert response.status_code == 200
314+
data = response.json()
315+
links = data['linkset'][0]['describedby']
316+
assert any('/agent/auth' in entry['href'] for entry in links)

0 commit comments

Comments
 (0)