1
1
# -*- coding: utf-8 -*-
2
- """
2
+ """Test the configuration module.
3
3
4
4
Copyright 2025
5
5
SPDX-License-Identifier: Apache-2.0
6
- Authors: Mihai Criveti
7
-
6
+ Author: Mihai Criveti
8
7
"""
9
8
10
9
# Standard
10
+ import json
11
11
import os
12
+ from pathlib import Path
13
+ from typing import Any , Dict , List
12
14
from unittest .mock import MagicMock , patch
13
15
14
16
# First-Party
15
- from mcpgateway .config import get_settings , Settings
17
+ from mcpgateway .config import (
18
+ extract_using_jq ,
19
+ get_settings ,
20
+ jsonpath_modifier ,
21
+ Settings ,
22
+ )
23
+
24
+ # Third-Party
25
+ from fastapi import HTTPException
26
+
27
+ # Third-party
28
+ import pytest
29
+
30
+
31
+ # --------------------------------------------------------------------------- #
32
+ # Settings field parsers #
33
+ # --------------------------------------------------------------------------- #
34
+ def test_parse_allowed_origins_json_and_csv ():
35
+ """Validator should accept JSON array *or* comma-separated string."""
36
+ s_json = Settings (allowed_origins = '["https://a.com", "https://b.com"]' )
37
+ assert s_json .allowed_origins == {"https://a.com" , "https://b.com" }
38
+
39
+ s_csv = Settings (allowed_origins = "https://x.com , https://y.com" )
40
+ assert s_csv .allowed_origins == {"https://x.com" , "https://y.com" }
41
+
42
+
43
+ def test_parse_federation_peers_json_and_csv ():
44
+ peers_json = '["https://gw1", "https://gw2"]'
45
+ peers_csv = "https://gw3, https://gw4"
46
+
47
+ s_json = Settings (federation_peers = peers_json )
48
+ s_csv = Settings (federation_peers = peers_csv )
49
+
50
+ assert s_json .federation_peers == ["https://gw1" , "https://gw2" ]
51
+ assert s_csv .federation_peers == ["https://gw3" , "https://gw4" ]
52
+
53
+
54
+ # --------------------------------------------------------------------------- #
55
+ # database / CORS helpers #
56
+ # --------------------------------------------------------------------------- #
57
+ def test_database_settings_sqlite_and_non_sqlite (tmp_path : Path ):
58
+ """connect_args differs for sqlite vs everything else."""
59
+ # sqlite -> check_same_thread flag present
60
+ db_file = tmp_path / "foo" / "bar.db"
61
+ url = f"sqlite:///{ db_file } "
62
+ s_sqlite = Settings (database_url = url )
63
+ assert s_sqlite .database_settings ["connect_args" ] == {"check_same_thread" : False }
64
+
65
+ # non-sqlite -> empty connect_args
66
+ s_pg = Settings (database_url = "postgresql://u:p@db/test" )
67
+ assert s_pg .database_settings ["connect_args" ] == {}
68
+
69
+
70
+ def test_validate_database_creates_missing_parent (tmp_path : Path ):
71
+ db_file = tmp_path / "newdir" / "db.sqlite"
72
+ url = f"sqlite:///{ db_file } "
73
+ s = Settings (database_url = url , _env_file = None )
74
+
75
+ # Parent shouldn't exist yet
76
+ assert not db_file .parent .exists ()
77
+ s .validate_database ()
78
+ # Now it *must* exist
79
+ assert db_file .parent .exists ()
80
+
81
+
82
+ def test_validate_transport_accepts_and_rejects ():
83
+ Settings (transport_type = "http" ).validate_transport () # should not raise
84
+
85
+ with pytest .raises (ValueError ):
86
+ Settings (transport_type = "bogus" ).validate_transport ()
87
+
88
+
89
+ def test_cors_settings_branches ():
90
+ """cors_settings property depends on dynamically present cors_enabled flag."""
91
+ s = Settings (_env_file = None )
92
+
93
+ # With flag missing -> AttributeError when property accessed
94
+ with pytest .raises (AttributeError ):
95
+ _ = s .cors_settings
96
+
97
+ # Manually inject the flag then verify dictionary
98
+ object .__setattr__ (s , "cors_enabled" , True )
99
+ result = s .cors_settings
100
+ assert result ["allow_methods" ] == ["*" ]
101
+ assert s .allowed_origins .issubset (set (result ["allow_origins" ]))
102
+
103
+
104
+ # --------------------------------------------------------------------------- #
105
+ # extract_using_jq #
106
+ # --------------------------------------------------------------------------- #
107
+ def test_extract_using_jq_happy_path ():
108
+ data = {"a" : 123 }
109
+
110
+ with patch ("mcpgateway.config.jq.all" , return_value = [123 ]) as mock_jq :
111
+ out = extract_using_jq (data , ".a" )
112
+ mock_jq .assert_called_once_with (".a" , data )
113
+ assert out == [123 ]
114
+
115
+
116
+ def test_extract_using_jq_short_circuits_and_errors ():
117
+ # Empty filter returns data unmodified
118
+ orig = {"x" : "y" }
119
+ assert extract_using_jq (orig ) is orig
16
120
121
+ # Non-JSON string
122
+ assert extract_using_jq ("this isn't json" , ".foo" ) == ["Invalid JSON string provided." ]
17
123
124
+ # Unsupported input type
125
+ assert extract_using_jq (42 , ".foo" ) == ["Input data must be a JSON string, dictionary, or list." ]
126
+
127
+
128
+ # --------------------------------------------------------------------------- #
129
+ # jsonpath_modifier #
130
+ # --------------------------------------------------------------------------- #
131
+ @pytest .fixture (scope = "module" )
132
+ def sample_people () -> List [Dict [str , Any ]]:
133
+ return [
134
+ {"name" : "Ada" , "id" : 1 },
135
+ {"name" : "Bob" , "id" : 2 },
136
+ ]
137
+
138
+
139
+ def test_jsonpath_modifier_basic_match (sample_people ):
140
+ # Pull out names directly
141
+ names = jsonpath_modifier (sample_people , "$[*].name" )
142
+ assert names == ["Ada" , "Bob" ]
143
+
144
+ # Same query but with a mapping
145
+ mapped = jsonpath_modifier (sample_people , "$[*]" , mappings = {"n" : "$.name" })
146
+ assert mapped == [{"n" : "Ada" }, {"n" : "Bob" }]
147
+
148
+
149
+ def test_jsonpath_modifier_single_dict_collapse ():
150
+ person = {"name" : "Zoe" , "id" : 10 }
151
+ out = jsonpath_modifier (person , "$" )
152
+ assert out == person # single-item dict collapses to dict, not list
153
+
154
+
155
+ def test_jsonpath_modifier_invalid_expressions (sample_people ):
156
+ with pytest .raises (HTTPException ):
157
+ jsonpath_modifier (sample_people , "$[" ) # invalid main expr
158
+
159
+ with pytest .raises (HTTPException ):
160
+ jsonpath_modifier (sample_people , "$[*]" , mappings = {"bad" : "$[" }) # invalid mapping expr
161
+
162
+
163
+ # --------------------------------------------------------------------------- #
164
+ # get_settings LRU cache #
165
+ # --------------------------------------------------------------------------- #
166
+ @patch ("mcpgateway.config.Settings" )
167
+ def test_get_settings_is_lru_cached (mock_settings ):
168
+ """Constructor must run only once regardless of repeated calls."""
169
+ get_settings .cache_clear ()
170
+
171
+ inst1 = MagicMock ()
172
+ inst1 .validate_transport .return_value = None
173
+ inst1 .validate_database .return_value = None
174
+
175
+ inst2 = MagicMock ()
176
+ mock_settings .side_effect = [inst1 , inst2 ]
177
+
178
+ assert get_settings () is inst1
179
+ assert get_settings () is inst1 # cached
180
+ assert mock_settings .call_count == 1
181
+
182
+
183
+ # --------------------------------------------------------------------------- #
184
+ # Keep the user-supplied baseline #
185
+ # --------------------------------------------------------------------------- #
18
186
def test_settings_default_values ():
19
- """
20
- Verify the class defaults only, independent of anything in the
21
- developer's local .env file. Passing ``_env_file=None`` tells
22
- Pydantic not to load any environment file.
23
- """
24
187
with patch .dict (os .environ , {}, clear = True ):
25
188
settings = Settings (_env_file = None )
26
189
@@ -34,63 +197,16 @@ def test_settings_default_values():
34
197
35
198
36
199
def test_api_key_property ():
37
- """Test the api_key property."""
38
- settings = Settings (basic_auth_user = "test_user" , basic_auth_password = "test_pass" )
39
- assert settings .api_key == "test_user:test_pass"
200
+ settings = Settings (basic_auth_user = "u" , basic_auth_password = "p" )
201
+ assert settings .api_key == "u:p"
40
202
41
203
42
204
def test_supports_transport_properties ():
43
- """Test the transport support properties."""
44
- # Test 'all' transport type
45
- settings = Settings (transport_type = "all" )
46
- assert settings .supports_http is True
47
- assert settings .supports_websocket is True
48
- assert settings .supports_sse is True
49
-
50
- # Test 'http' transport type
51
- settings = Settings (transport_type = "http" )
52
- assert settings .supports_http is True
53
- assert settings .supports_websocket is False
54
- assert settings .supports_sse is False
55
-
56
- # Test 'ws' transport type
57
- settings = Settings (transport_type = "ws" )
58
- assert settings .supports_http is False
59
- assert settings .supports_websocket is True
60
- assert settings .supports_sse is False
61
-
62
-
63
- @patch ("mcpgateway.config.Settings" )
64
- def test_get_settings_caching (mock_settings ):
65
- """
66
- Ensure get_settings() calls the Settings constructor exactly once and
67
- then serves the cached object on every additional call.
68
- """
69
- # Clear the cache created at import-time.
70
- get_settings .cache_clear ()
71
-
72
- # Two distinct mock Settings instances, each with the methods that
73
- # get_settings() invokes (validate_transport / validate_database).
74
- settings_instance_1 = MagicMock ()
75
- settings_instance_2 = MagicMock ()
76
-
77
- # Each mock must expose these methods so AttributeError is not raised.
78
- settings_instance_1 .validate_transport .return_value = None
79
- settings_instance_1 .validate_database .return_value = None
80
- settings_instance_2 .validate_transport .return_value = None
81
- settings_instance_2 .validate_database .return_value = None
205
+ s_all = Settings (transport_type = "all" )
206
+ assert (s_all .supports_http , s_all .supports_websocket , s_all .supports_sse ) == (True , True , True )
82
207
83
- # First call should return instance_1; any further constructor calls
84
- # would return instance_2 (but they shouldn't happen).
85
- mock_settings .side_effect = [settings_instance_1 , settings_instance_2 ]
208
+ s_http = Settings (transport_type = "http" )
209
+ assert (s_http .supports_http , s_http .supports_websocket , s_http .supports_sse ) == (True , False , False )
86
210
87
- result1 = get_settings ()
88
- assert result1 is settings_instance_1
89
-
90
- # Even after we change what the constructor would return, the cached
91
- # object must still be served.
92
- result2 = get_settings ()
93
- assert result2 is settings_instance_1
94
-
95
- # The constructor should have been invoked exactly once.
96
- assert mock_settings .call_count == 1
211
+ s_ws = Settings (transport_type = "ws" )
212
+ assert (s_ws .supports_http , s_ws .supports_websocket , s_ws .supports_sse ) == (False , True , False )
0 commit comments