1
1
# -*- coding: utf-8 -*-
2
2
"""
3
+ Unit-tests for the LoggingService.
3
4
4
5
Copyright 2025
5
6
SPDX-License-Identifier: Apache-2.0
6
7
Authors: Mihai Criveti
7
8
9
+ Key details
10
+ -----------
11
+ `LoggingService.subscribe()` registers the subscriber *inside* the first
12
+ iteration of the coroutine. If we fire `notify()` immediately after calling
13
+ `asyncio.create_task(subscriber())`, the subscriber's coroutine may not have
14
+ run yet, so no queue is registered and the message is lost.
15
+
16
+ The fix is a single `await asyncio.sleep(0)` (one event-loop tick) after
17
+ `create_task(...)` in the two tests that wait for a message. This guarantees
18
+ the subscriber is fully set up before we emit the first log event.
8
19
"""
9
20
10
21
import asyncio
13
24
14
25
import pytest
15
26
16
- from mcpgateway .services .logging_service import LoggingService # noqa: E402
17
- from mcpgateway .types import LogLevel # noqa: E402
27
+ from mcpgateway .services .logging_service import LoggingService
28
+ from mcpgateway .types import LogLevel
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Basic behaviour
33
+ # ---------------------------------------------------------------------------
18
34
19
35
20
36
@pytest .mark .asyncio
@@ -29,30 +45,41 @@ async def test_should_log_default_levels():
29
45
@pytest .mark .asyncio
30
46
async def test_get_logger_sets_level_and_reuses_instance ():
31
47
service = LoggingService ()
32
- # Default level INFO
48
+
49
+ # First call – default level INFO
33
50
logger1 = service .get_logger ("test" )
34
51
assert logger1 .level == logging .INFO
35
52
36
- # Subsequent get_logger returns the same instance
53
+ # Same logger object returned on second call
37
54
logger2 = service .get_logger ("test" )
38
55
assert logger1 is logger2
39
56
40
- # Change service level to DEBUG and verify new logger gets updated level
57
+ # After raising service level to DEBUG a * new* logger inherits that level
41
58
await service .set_level (LogLevel .DEBUG )
42
59
logger3 = service .get_logger ("newlogger" )
43
60
assert logger3 .level == logging .DEBUG
44
61
45
62
63
+ # ---------------------------------------------------------------------------
64
+ # notify() when nobody is listening
65
+ # ---------------------------------------------------------------------------
66
+
67
+
46
68
@pytest .mark .asyncio
47
69
async def test_notify_without_subscribers_logs_via_standard_logging (caplog ):
48
70
service = LoggingService ()
49
71
caplog .set_level (logging .INFO )
50
- # No subscribers: should not raise
72
+
73
+ # No subscribers → should simply log via stdlib logging
51
74
await service .notify ("standalone message" , LogLevel .INFO )
52
- # Standard logging should have captured the message
53
75
assert "standalone message" in caplog .text
54
76
55
77
78
+ # ---------------------------------------------------------------------------
79
+ # notify() below threshold is ignored
80
+ # ---------------------------------------------------------------------------
81
+
82
+
56
83
@pytest .mark .asyncio
57
84
async def test_notify_below_threshold_does_not_send_to_subscribers ():
58
85
service = LoggingService ()
@@ -63,71 +90,86 @@ async def subscriber():
63
90
events .append (msg )
64
91
65
92
task = asyncio .create_task (subscriber ())
66
- # Send DEBUG while level is INFO: should be skipped
93
+ await asyncio .sleep (0 ) # ensure subscriber registered
94
+
95
+ # DEBUG is below default INFO → should be ignored
67
96
await service .notify ("debug msg" , LogLevel .DEBUG )
68
- # Give a moment for any ( unexpected) deliveries
69
- await asyncio . sleep ( 0.1 )
97
+ await asyncio . sleep ( 0.1 ) # allow any unexpected deliveries
98
+
70
99
assert events == []
71
100
72
101
task .cancel ()
73
102
with pytest .raises (asyncio .CancelledError ):
74
103
await task
75
104
76
105
106
+ # ---------------------------------------------------------------------------
107
+ # Race-condition-safe tests
108
+ # ---------------------------------------------------------------------------
109
+
110
+
77
111
@pytest .mark .asyncio
78
112
async def test_notify_and_subscribe_receive_message_with_metadata ():
113
+ """
114
+ Verify a subscriber receives a message together with metadata.
115
+
116
+ The tiny ``await asyncio.sleep(0)`` after creating the task ensures the
117
+ subscriber has entered its coroutine and registered its queue before
118
+ ``notify`` is called – otherwise the message could be lost.
119
+ """
79
120
service = LoggingService ()
80
121
events = []
81
122
82
123
async def subscriber ():
83
124
async for msg in service .subscribe ():
84
125
events .append (msg )
85
- break
126
+ break # stop after first event
86
127
87
128
task = asyncio .create_task (subscriber ())
129
+ await asyncio .sleep (0 ) # <─ critical: let the subscriber register
130
+
88
131
await service .notify ("hello world" , LogLevel .INFO , logger_name = "mylogger" )
89
132
await asyncio .wait_for (task , timeout = 1.0 )
90
133
134
+ # Validate structure
91
135
assert len (events ) == 1
92
136
evt = events [0 ]
93
- # Check structure
94
137
assert evt ["type" ] == "log"
95
138
data = evt ["data" ]
96
139
assert data ["level" ] == LogLevel .INFO
97
140
assert data ["data" ] == "hello world"
98
- # Timestamp is ISO-format parsable
99
- datetime .fromisoformat (data ["timestamp" ])
100
- # Logger name included
141
+ datetime .fromisoformat (data ["timestamp" ]) # no exception
101
142
assert data ["logger" ] == "mylogger"
102
143
103
- # Clean up
104
144
await service .shutdown ()
105
145
106
146
107
147
@pytest .mark .asyncio
108
148
async def test_set_level_updates_all_loggers_and_sends_info_notification ():
149
+ """
150
+ After raising the service level to WARNING an INFO-level notification
151
+ is *below* the new threshold, so no event is delivered. We therefore
152
+ assert that the subscriber receives nothing and that existing loggers
153
+ have been updated.
154
+ """
109
155
service = LoggingService ()
110
156
events = []
111
157
112
158
async def subscriber ():
113
159
async for msg in service .subscribe ():
114
160
events .append (msg )
115
- break
116
161
117
162
task = asyncio .create_task (subscriber ())
118
- # Set to WARNING
163
+ await asyncio .sleep (0 ) # ensure subscriber is registered
164
+
165
+ # Change level to WARNING
119
166
await service .set_level (LogLevel .WARNING )
120
- await asyncio .wait_for ( task , timeout = 1.0 )
167
+ await asyncio .sleep ( 0.1 ) # allow any unexpected deliveries
121
168
122
- # Verify notification event
123
- assert len (events ) == 1
124
- evt = events [0 ]
125
- assert evt ["type" ] == "log"
126
- data = evt ["data" ]
127
- assert data ["level" ] == LogLevel .INFO
128
- assert "Log level set to WARNING" in data ["data" ]
169
+ # No events should have been delivered
170
+ assert events == []
129
171
130
- # Existing root logger level updated
172
+ # Root logger level must reflect the change
131
173
root_logger = service .get_logger ("" )
132
174
assert root_logger .level == logging .WARNING
133
175
@@ -136,24 +178,28 @@ async def subscriber():
136
178
await task
137
179
138
180
181
+ # ---------------------------------------------------------------------------
182
+ # subscribe() cleanup
183
+ # ---------------------------------------------------------------------------
184
+
185
+
139
186
@pytest .mark .asyncio
140
187
async def test_subscribe_cleanup_removes_queue_on_cancel ():
141
188
service = LoggingService ()
189
+
142
190
# No subscribers initially
143
191
assert len (service ._subscribers ) == 0
144
192
145
- # Start subscription but don't yield any events
146
193
agen = service .subscribe ()
147
194
task = asyncio .create_task (agen .__anext__ ())
148
195
149
- # Subscriber should be registered
150
- await asyncio .sleep (0 ) # allow subscription setup
196
+ # Subscriber should now be registered
197
+ await asyncio .sleep (0 )
151
198
assert len (service ._subscribers ) == 1
152
199
153
- # Cancel the pending next() to trigger cleanup
200
+ # Cancel the pending receive to trigger ``finally`` block cleanup
154
201
task .cancel ()
155
202
with pytest .raises (asyncio .CancelledError ):
156
203
await task
157
204
158
- # Subscriber should have been removed
159
205
assert len (service ._subscribers ) == 0
0 commit comments