Skip to content

Commit 11c77e2

Browse files
committed
Update config testing
Signed-off-by: Mihai Criveti <[email protected]>
1 parent ba81a93 commit 11c77e2

File tree

1 file changed

+180
-64
lines changed

1 file changed

+180
-64
lines changed

tests/unit/mcpgateway/test_config.py

Lines changed: 180 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,189 @@
11
# -*- coding: utf-8 -*-
2-
"""
2+
"""Test the configuration module.
33
44
Copyright 2025
55
SPDX-License-Identifier: Apache-2.0
6-
Authors: Mihai Criveti
7-
6+
Author: Mihai Criveti
87
"""
98

109
# Standard
10+
import json
1111
import os
12+
from pathlib import Path
13+
from typing import Any, Dict, List
1214
from unittest.mock import MagicMock, patch
1315

1416
# 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
16120

121+
# Non-JSON string
122+
assert extract_using_jq("this isn't json", ".foo") == ["Invalid JSON string provided."]
17123

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+
# --------------------------------------------------------------------------- #
18186
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-
"""
24187
with patch.dict(os.environ, {}, clear=True):
25188
settings = Settings(_env_file=None)
26189

@@ -34,63 +197,16 @@ def test_settings_default_values():
34197

35198

36199
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"
40202

41203

42204
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)
82207

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)
86210

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

Comments
 (0)