14
14
15
15
No external MCP server is started; we test the isolated utility pieces that
16
16
have no heavy dependencies.
17
+
17
18
"""
18
19
19
20
# Future
@@ -61,6 +62,70 @@ async def collector(msg):
61
62
assert len (sent ) == 1 and sent [0 ].message ["id" ] == 2
62
63
assert sent [0 ].event_id == eid2
63
64
65
+ @pytest .mark .asyncio
66
+ async def test_event_store_no_new_events ():
67
+ store = InMemoryEventStore (max_events_per_stream = 10 )
68
+ stream_id = "stream1"
69
+ eid = await store .store_event (stream_id , {"val" : 42 })
70
+ sent = []
71
+ async def collector (msg ):
72
+ sent .append (msg )
73
+
74
+ returned = await store .replay_events_after (eid , collector )
75
+ assert returned == stream_id
76
+ # No new events were stored, so nothing should be sent
77
+ assert sent == []
78
+
79
+ @pytest .mark .asyncio
80
+ async def test_event_store_multiple_replay ():
81
+ store = InMemoryEventStore (max_events_per_stream = 10 )
82
+ stream_id = "stream1"
83
+ # Store three events
84
+ eids = []
85
+ for i in range (3 ):
86
+ eids .append (await store .store_event (stream_id , {"n" : i }))
87
+ sent = []
88
+ async def collector (msg ):
89
+ sent .append (msg )
90
+
91
+ # Replay after the first event should get the 2nd and 3rd
92
+ returned = await store .replay_events_after (eids [0 ], collector )
93
+ assert returned == stream_id
94
+ assert [msg .message ["n" ] for msg in sent ] == [1 , 2 ]
95
+
96
+ @pytest .mark .asyncio
97
+ async def test_event_store_cross_streams ():
98
+ store = InMemoryEventStore (max_events_per_stream = 10 )
99
+ s1 , s2 = "s1" , "s2"
100
+ # Store events in two different streams
101
+ eid1_s1 = await store .store_event (s1 , {"val" : 1 })
102
+ eid1_s2 = await store .store_event (s2 , {"val" : 2 })
103
+ eid2_s1 = await store .store_event (s1 , {"val" : 3 })
104
+ sent = []
105
+ async def collector (msg ):
106
+ sent .append (msg )
107
+
108
+ # Replay on stream s1 after its first ID
109
+ returned = await store .replay_events_after (eid1_s1 , collector )
110
+ assert returned == s1
111
+ # Should only get the event from s1 (val=3), not the s2 event
112
+ assert [msg .message ["val" ] for msg in sent ] == [3 ]
113
+
114
+ @pytest .mark .asyncio
115
+ async def test_event_store_eviction_of_oldest ():
116
+ store = InMemoryEventStore (max_events_per_stream = 1 )
117
+ stream_id = "s"
118
+ eid_old = await store .store_event (stream_id , {"x" : "old" })
119
+ # Storing a second event evicts the first (due to maxlen=1):contentReference[oaicite:8]{index=8}:contentReference[oaicite:9]{index=9}
120
+ await store .store_event (stream_id , {"x" : "new" })
121
+ sent = []
122
+ async def collector (msg ):
123
+ sent .append (msg )
124
+
125
+ result = await store .replay_events_after (eid_old , collector )
126
+ # The first event ID has been evicted, so it should not be found
127
+ assert result is None
128
+ assert sent == []
64
129
65
130
@pytest .mark .asyncio
66
131
async def test_event_store_eviction ():
@@ -147,6 +212,42 @@ async def send(msg):
147
212
assert sent and sent [0 ]["type" ] == "http.response.start"
148
213
assert sent [0 ]["status" ] == tr .HTTP_401_UNAUTHORIZED
149
214
215
+ @pytest .mark .asyncio
216
+ async def test_auth_valid_token (monkeypatch ):
217
+ # Simulate verify_credentials always succeeding
218
+ async def fake_verify (token ):
219
+ assert token == "good-token"
220
+ return {"ok" : True }
221
+ monkeypatch .setattr (tr , "verify_credentials" , fake_verify )
222
+
223
+ messages = []
224
+ async def send (msg ):
225
+ messages .append (msg )
226
+
227
+ scope = _make_scope ("/servers/1/mcp" ,
228
+ headers = [(b"authorization" , b"Bearer good-token" )])
229
+ assert await streamable_http_auth (scope , None , send ) is True
230
+ assert messages == [] # No response sent on success
231
+
232
+ @pytest .mark .asyncio
233
+ async def test_auth_invalid_token_raises (monkeypatch ):
234
+ # Simulate verify_credentials raising (invalid token scenario)
235
+ async def fake_verify (token ):
236
+ raise ValueError ("bad token" )
237
+ monkeypatch .setattr (tr , "verify_credentials" , fake_verify )
238
+
239
+ sent = []
240
+ async def send (msg ):
241
+ sent .append (msg )
242
+
243
+ scope = _make_scope ("/servers/1/mcp" ,
244
+ headers = [(b"authorization" , b"Bearer bad-token" )])
245
+ result = await streamable_http_auth (scope , None , send )
246
+ assert result is False
247
+ # Expect an HTTP 401 response to be sent
248
+ assert sent and sent [0 ]["type" ] == "http.response.start"
249
+ assert sent [0 ]["status" ] == tr .HTTP_401_UNAUTHORIZED
250
+
150
251
151
252
# ---------------------------------------------------------------------------
152
253
# SamplingHandler tests
@@ -185,6 +286,13 @@ async def test_select_model_by_hint(handler):
185
286
186
287
assert handler ._select_model (prefs ) == "claude-3-sonnet" # pylint: disable=protected-access
187
288
289
+ @pytest .mark .asyncio
290
+ async def test_select_model_no_suitable_model (handler ):
291
+ # Remove all supported models to force error
292
+ handler ._supported_models = {}
293
+ prefs = _t .SimpleNamespace (hints = [], cost_priority = 0 , speed_priority = 0 , intelligence_priority = 0 )
294
+ with pytest .raises (SamplingError ):
295
+ handler ._select_model (prefs )
188
296
189
297
# ---------------------------------------------------------------------------
190
298
# _validate_message
@@ -203,6 +311,54 @@ def test_validate_message(handler):
203
311
assert handler ._validate_message (valid_image ) # pylint: disable=protected-access
204
312
assert not handler ._validate_message (invalid ) # pylint: disable=protected-access
205
313
314
+ def test_validate_message_missing_image_fields (handler ):
315
+ # Missing 'data' field in image content
316
+ invalid_img1 = {"role" : "assistant" , "content" : {"type" : "image" , "mime_type" : "image/png" }}
317
+ # Missing 'mime_type' field
318
+ invalid_img2 = {"role" : "assistant" , "content" : {"type" : "image" , "data" : "AAA" }}
319
+ # Unknown content type
320
+ invalid_img3 = {"role" : "user" , "content" : {"type" : "audio" , "data" : "xxx" }}
321
+
322
+ assert not handler ._validate_message (invalid_img1 )
323
+ assert not handler ._validate_message (invalid_img2 )
324
+ assert not handler ._validate_message (invalid_img3 )
325
+
326
+ @pytest .mark .asyncio
327
+ async def test_add_context_returns_messages (handler ):
328
+ # Should just return the messages as-is (stub)
329
+ msgs = [{"role" : "user" , "content" : {"type" : "text" , "text" : "hi" }}]
330
+ result = await handler ._add_context (None , msgs , "irrelevant" )
331
+ assert result == msgs
332
+
333
+ def test_mock_sample_no_user_message (handler ):
334
+ # No user message in the list
335
+ msgs = [{"role" : "assistant" , "content" : {"type" : "text" , "text" : "hi" }}]
336
+ result = handler ._mock_sample (msgs )
337
+ assert "I'm not sure" in result
338
+
339
+ def test_mock_sample_image_message (handler ):
340
+ # Last user message is image
341
+ msgs = [
342
+ {"role" : "user" , "content" : {"type" : "image" , "data" : "xxx" , "mime_type" : "image/png" }}
343
+ ]
344
+ result = handler ._mock_sample (msgs )
345
+ assert "I see the image" in result
346
+
347
+ def test_validate_message_invalid_role (handler ):
348
+ msg = {"role" : "system" , "content" : {"type" : "text" , "text" : "hi" }}
349
+ assert not handler ._validate_message (msg )
350
+
351
+ def test_validate_message_missing_content (handler ):
352
+ msg = {"role" : "user" }
353
+ assert not handler ._validate_message (msg )
354
+
355
+ def test_validate_message_exception_path (handler ):
356
+ # Simulate exception in validation
357
+ class BadDict (dict ):
358
+ def get (self , k , d = None ):
359
+ raise Exception ("fail" )
360
+ msg = {"role" : "user" , "content" : BadDict ()}
361
+ assert not handler ._validate_message (msg )
206
362
207
363
# ---------------------------------------------------------------------------
208
364
# create_message success + error paths
@@ -228,6 +384,29 @@ async def test_create_message_success(monkeypatch, handler):
228
384
assert result .role == sp .Role .ASSISTANT
229
385
assert result .content .text .startswith ("You said: Hello" )
230
386
387
+ @pytest .mark .asyncio
388
+ async def test_create_message_multiple_user_messages (monkeypatch , handler ):
389
+ # Return neutral preferences with no hints
390
+ neutral_prefs = _t .SimpleNamespace (
391
+ hints = [], cost_priority = 0.5 , speed_priority = 0.3 , intelligence_priority = 0.2
392
+ )
393
+ monkeypatch .setattr (sp .ModelPreferences , "model_validate" , lambda x : neutral_prefs )
394
+
395
+ # Conversation with an assistant message and then a user message
396
+ request = {
397
+ "messages" : [
398
+ {"role" : "assistant" , "content" : {"type" : "text" , "text" : "Hi" }},
399
+ {"role" : "user" , "content" : {"type" : "text" , "text" : "Hello" }}
400
+ ],
401
+ "maxTokens" : 10 ,
402
+ "modelPreferences" : {}
403
+ }
404
+
405
+ result = await handler .create_message (db = None , request = request )
406
+ assert result .role == sp .Role .ASSISTANT
407
+ # The response should reference the last user message "Hello"
408
+ assert "You said: Hello" in result .content .text
409
+
231
410
232
411
@pytest .mark .asyncio
233
412
async def test_create_message_no_messages (monkeypatch , handler ):
@@ -237,3 +416,45 @@ async def test_create_message_no_messages(monkeypatch, handler):
237
416
238
417
with pytest .raises (SamplingError ):
239
418
await handler .create_message (db = None , request = request )
419
+
420
+ @pytest .mark .asyncio
421
+ async def test_create_message_raises_on_no_user_message (monkeypatch , handler ):
422
+ # Even if there are assistant messages, at least one user message is required
423
+ monkeypatch .setattr (sp .ModelPreferences , "model_validate" ,
424
+ lambda x : _t .SimpleNamespace (hints = [], cost_priority = 0 , speed_priority = 0 , intelligence_priority = 0 ))
425
+ request = {"messages" : [], "maxTokens" : 5 , "modelPreferences" : {}}
426
+ with pytest .raises (SamplingError ):
427
+ await handler .create_message (db = None , request = request )
428
+
429
+ @pytest .mark .asyncio
430
+ async def test_create_message_missing_max_tokens (monkeypatch , handler ):
431
+ monkeypatch .setattr (sp .ModelPreferences , "model_validate" , lambda _x : _t .SimpleNamespace (hints = [], cost_priority = 0 , speed_priority = 0 , intelligence_priority = 0 ))
432
+ request = {"messages" : [{"role" : "user" , "content" : {"type" : "text" , "text" : "hi" }}]}
433
+ with pytest .raises (SamplingError ):
434
+ await handler .create_message (db = None , request = request )
435
+
436
+ @pytest .mark .asyncio
437
+ async def test_create_message_invalid_message (monkeypatch , handler ):
438
+ monkeypatch .setattr (sp .ModelPreferences , "model_validate" , lambda _x : _t .SimpleNamespace (hints = [], cost_priority = 0 , speed_priority = 0 , intelligence_priority = 0 ))
439
+ # Invalid message: missing text
440
+ request = {
441
+ "messages" : [{"role" : "user" , "content" : {"type" : "text" }}],
442
+ "maxTokens" : 5 ,
443
+ "modelPreferences" : {},
444
+ }
445
+ with pytest .raises (SamplingError ):
446
+ await handler .create_message (db = None , request = request )
447
+
448
+ @pytest .mark .asyncio
449
+ async def test_create_message_exception_propagation (monkeypatch , handler ):
450
+ # Patch _select_model to raise
451
+ monkeypatch .setattr (handler , "_select_model" , lambda prefs : (_ for _ in ()).throw (Exception ("fail" )))
452
+ monkeypatch .setattr (sp .ModelPreferences , "model_validate" , lambda _x : _t .SimpleNamespace (hints = [], cost_priority = 0 , speed_priority = 0 , intelligence_priority = 0 ))
453
+ request = {
454
+ "messages" : [{"role" : "user" , "content" : {"type" : "text" , "text" : "hi" }}],
455
+ "maxTokens" : 5 ,
456
+ "modelPreferences" : {},
457
+ }
458
+ with pytest .raises (SamplingError ) as exc :
459
+ await handler .create_message (db = None , request = request )
460
+ assert "fail" in str (exc .value )
0 commit comments