Skip to content

Commit 7d88aa5

Browse files
committed
refactor(tests): improve unit-tests given new architecture
1 parent c060403 commit 7d88aa5

14 files changed

+2037
-936
lines changed

tests/core/test_config.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import os
2+
from pathlib import Path
3+
from unittest.mock import MagicMock, patch
4+
5+
import pytest
6+
7+
from m3.core.config import M3Config
8+
from m3.core.utils.exceptions import M3ConfigError
9+
10+
11+
@pytest.fixture
12+
def temp_env_vars():
13+
"""Fixture to temporarily set environment variables."""
14+
original_env = os.environ.copy()
15+
yield
16+
os.environ.clear()
17+
os.environ.update(original_env)
18+
19+
20+
class TestM3Config:
21+
"""Tests for M3Config class."""
22+
23+
def test_init_default(self):
24+
"""Test default initialization."""
25+
config = M3Config()
26+
assert config.log_level == "INFO"
27+
assert config.env_vars == {}
28+
assert isinstance(config.project_root, Path)
29+
assert isinstance(config.data_dir, Path)
30+
assert isinstance(config.databases_dir, Path)
31+
assert isinstance(config.raw_files_dir, Path)
32+
33+
def test_init_with_params(self):
34+
"""Test initialization with parameters."""
35+
env_vars = {"TEST_KEY": "value"}
36+
config = M3Config(log_level="DEBUG", env_vars=env_vars)
37+
assert config.log_level == "DEBUG"
38+
assert config.env_vars == env_vars
39+
40+
def test_to_dict(self):
41+
"""Test to_dict method."""
42+
config = M3Config(env_vars={"KEY": "value"})
43+
data = config.to_dict()
44+
assert data["log_level"] == "INFO"
45+
assert data["env_vars"] == {"KEY": "value"}
46+
47+
def test_from_dict(self):
48+
"""Test from_dict class method."""
49+
data = {"log_level": "DEBUG", "env_vars": {"KEY": "value"}}
50+
config = M3Config.from_dict(data)
51+
assert config.log_level == "DEBUG"
52+
assert config.env_vars == {"KEY": "value"}
53+
54+
def test_from_dict_missing_key(self):
55+
"""Test from_dict raises error on missing key."""
56+
data = {"log_level": "INFO"}
57+
with pytest.raises(M3ConfigError, match="Missing required config key"):
58+
M3Config.from_dict(data)
59+
60+
def test_get_env_var(self, temp_env_vars):
61+
"""Test get_env_var method."""
62+
os.environ["TEST_ENV"] = "env_value"
63+
config = M3Config(env_vars={"CONFIG_KEY": "config_value"})
64+
assert config.get_env_var("TEST_ENV") == "env_value"
65+
assert config.get_env_var("CONFIG_KEY") == "config_value"
66+
assert config.get_env_var("MISSING", default="default") == "default"
67+
68+
def test_get_env_var_raise_if_missing(self):
69+
"""Test get_env_var raises if missing and required."""
70+
config = M3Config()
71+
with pytest.raises(M3ConfigError, match="Missing required env var"):
72+
config.get_env_var("MISSING", raise_if_missing=True)
73+
74+
def test_validate_for_tools_success(self):
75+
"""Test validate_for_tools method success."""
76+
from m3.core.tool.base import BaseTool
77+
78+
mock_tool = MagicMock(spec=BaseTool)
79+
mock_tool.__class__.__name__ = "TestTool"
80+
mock_tool.required_env_vars = {"REQUIRED": None}
81+
config = M3Config(env_vars={"TESTTOOL_REQUIRED": "value"})
82+
config.validate_for_tools([mock_tool])
83+
84+
def test_validate_for_tools_error(self):
85+
"""Test validate_for_tools raises on error."""
86+
from m3.core.tool.base import BaseTool
87+
88+
mock_tool = MagicMock(spec=BaseTool)
89+
mock_tool.__class__.__name__ = "TestTool"
90+
mock_tool.required_env_vars = {"FAKE_MISSING": None}
91+
config = M3Config(env_vars={})
92+
with pytest.raises(M3ConfigError):
93+
config.validate_for_tools([mock_tool])
94+
95+
def test_merge_env(self):
96+
"""Test merge_env method."""
97+
config = M3Config(env_vars={"EXISTING": "old"})
98+
new_env = {"NEW": "value"}
99+
config.merge_env(new_env)
100+
assert config.env_vars["NEW"] == "value"
101+
assert config.env_vars["EXISTING"] == "old"
102+
103+
def test_merge_env_conflict(self):
104+
"""Test merge_env raises on conflict."""
105+
config = M3Config(env_vars={"KEY": "old"})
106+
with pytest.raises(M3ConfigError, match="Env conflict"):
107+
config.merge_env({"KEY": "new"})
108+
109+
@patch("m3.core.config.Path.home")
110+
def test_project_root_fallback(self, mock_home):
111+
"""Test project root fallback to home."""
112+
mock_home.return_value = Path("/home/user")
113+
with patch("pathlib.Path.exists", return_value=False):
114+
config = M3Config()
115+
assert config.project_root == Path("/home/user")
116+
117+
def test_invalid_log_level(self):
118+
"""Test invalid log level raises error."""
119+
with pytest.raises(M3ConfigError, match="Invalid log level"):
120+
M3Config(log_level="INVALID")
121+
122+
def test_get_env_var_error_success(self):
123+
"""Test _get_env_var_error returns None on success."""
124+
config = M3Config(env_vars={"KEY": "value"})
125+
assert config._get_env_var_error("KEY", None) is None
126+
127+
def test_get_env_var_error_missing(self):
128+
"""Test _get_env_var_error returns error message when missing."""
129+
config = M3Config()
130+
error = config._get_env_var_error("MISSING", None)
131+
assert error is not None
132+
assert "Missing required env var" in error
133+
134+
def test_get_data_dir_with_env(self, temp_env_vars):
135+
"""Test _get_data_dir uses env var when set."""
136+
os.environ["M3_DATA_DIR"] = "/custom/data"
137+
config = M3Config()
138+
assert config._get_data_dir() == Path("/custom/data")
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import os
2+
from pathlib import Path
3+
from unittest.mock import MagicMock, mock_open, patch
4+
5+
import pytest
6+
7+
from m3.core.mcp_config_generator.mcp_config_generators.claude_mcp_config import (
8+
ClaudeConfigGenerator,
9+
)
10+
from m3.core.mcp_config_generator.mcp_config_generators.fast_mcp_config import (
11+
FastMCPConfigGenerator,
12+
)
13+
from m3.core.utils.exceptions import M3ValidationError
14+
from m3.m3 import M3
15+
16+
17+
@pytest.fixture
18+
def mock_m3() -> M3:
19+
"""Fixture for mock M3 instance."""
20+
from m3.core.config import M3Config
21+
22+
config = M3Config(env_vars={"TEST_ENV": "value"})
23+
m3 = M3(config=config)
24+
return m3
25+
26+
27+
class TestClaudeConfigGenerator:
28+
"""Tests for ClaudeConfigGenerator."""
29+
30+
@patch(
31+
"shutil.which",
32+
return_value="/usr/bin/python",
33+
)
34+
@patch("os.path.isdir", return_value=True)
35+
@patch(
36+
"m3.core.mcp_config_generator.mcp_config_generators.claude_mcp_config.ClaudeConfigGenerator._get_claude_config_path"
37+
)
38+
def test_generate_with_defaults(
39+
self,
40+
mock_get_path: MagicMock,
41+
mock_isdir: MagicMock,
42+
mock_which: MagicMock,
43+
mock_m3: M3,
44+
tmp_path: Path,
45+
) -> None:
46+
"""Test generating config with defaults."""
47+
mock_get_path.return_value = None
48+
config = ClaudeConfigGenerator.generate(mock_m3)
49+
assert isinstance(config, dict)
50+
assert "mcpServers" in config
51+
assert "m3" in config["mcpServers"]
52+
server = config["mcpServers"]["m3"]
53+
assert server["command"].endswith("python")
54+
assert server["args"] == ["-m", "m3.core.server"]
55+
assert os.path.isdir(server["cwd"])
56+
assert "TEST_ENV" in server["env"]
57+
58+
@patch("shutil.which", return_value=None)
59+
def test_invalid_command_raises_error(
60+
self, mock_which: MagicMock, mock_m3: M3
61+
) -> None:
62+
"""Test invalid command raises error."""
63+
with pytest.raises(M3ValidationError, match="Invalid command"):
64+
ClaudeConfigGenerator.generate(mock_m3, command="/invalid/python")
65+
66+
@patch("os.path.isdir", return_value=False)
67+
def test_invalid_cwd_raises_error(self, mock_isdir: MagicMock, mock_m3: M3) -> None:
68+
"""Test invalid cwd raises error."""
69+
with pytest.raises(M3ValidationError, match="Invalid cwd"):
70+
ClaudeConfigGenerator.generate(mock_m3, cwd="/invalid/dir")
71+
72+
@patch("builtins.open", new_callable=mock_open)
73+
@patch("json.load")
74+
@patch("json.dump")
75+
@patch(
76+
"m3.core.mcp_config_generator.mcp_config_generators.claude_mcp_config.ClaudeConfigGenerator._get_claude_config_path"
77+
)
78+
@patch("pathlib.Path.exists")
79+
def test_merge_with_existing_config(
80+
self,
81+
mock_exists: MagicMock,
82+
mock_get_path: MagicMock,
83+
mock_dump: MagicMock,
84+
mock_load: MagicMock,
85+
mock_open_file: MagicMock,
86+
mock_m3: M3,
87+
tmp_path: Path,
88+
) -> None:
89+
"""Test merging with existing Claude config."""
90+
mock_path = tmp_path / "claude_config.json"
91+
mock_get_path.return_value = mock_path
92+
mock_exists.return_value = True
93+
mock_load.return_value = {"mcpServers": {"existing": {}}}
94+
_config = ClaudeConfigGenerator.generate(mock_m3)
95+
mock_dump.assert_called_once()
96+
dumped_config = mock_dump.call_args[0][0]
97+
assert "existing" in dumped_config["mcpServers"]
98+
assert "m3" in dumped_config["mcpServers"]
99+
100+
@patch("builtins.open", new_callable=mock_open)
101+
@patch("json.dump")
102+
def test_save_to_custom_path(
103+
self,
104+
mock_dump: MagicMock,
105+
mock_open_file: MagicMock,
106+
mock_m3: M3,
107+
tmp_path: Path,
108+
) -> None:
109+
"""Test saving to custom path."""
110+
save_path = tmp_path / "custom.json"
111+
config = ClaudeConfigGenerator.generate(mock_m3, save_path=str(save_path))
112+
mock_dump.assert_called_once_with(config, mock_open_file(), indent=2)
113+
114+
115+
class TestFastMCPConfigGenerator:
116+
"""Tests for FastMCPConfigGenerator."""
117+
118+
@patch("shutil.which", return_value="/usr/bin/python")
119+
@patch("os.path.isdir", return_value=True)
120+
def test_generate_with_defaults(
121+
self, mock_isdir: MagicMock, mock_which: MagicMock, mock_m3: M3
122+
) -> None:
123+
"""Test generating config with defaults."""
124+
config = FastMCPConfigGenerator.generate(mock_m3)
125+
assert isinstance(config, dict)
126+
assert "mcpServers" in config
127+
assert "m3" in config["mcpServers"]
128+
server = config["mcpServers"]["m3"]
129+
assert server["command"].endswith("python")
130+
assert server["args"] == ["-m", "m3.core.server"]
131+
assert os.path.isdir(server["cwd"])
132+
assert "TEST_ENV" in server["env"]
133+
134+
@patch("shutil.which", return_value=None)
135+
def test_invalid_command_raises_error(
136+
self, mock_which: MagicMock, mock_m3: M3
137+
) -> None:
138+
"""Test invalid command raises error."""
139+
with pytest.raises(M3ValidationError, match="Invalid command"):
140+
FastMCPConfigGenerator.generate(mock_m3, command="/invalid/python")
141+
142+
@patch("os.path.isdir", return_value=False)
143+
def test_invalid_cwd_raises_error(self, mock_isdir: MagicMock, mock_m3: M3) -> None:
144+
"""Test invalid cwd raises error."""
145+
with pytest.raises(M3ValidationError, match="Invalid cwd"):
146+
FastMCPConfigGenerator.generate(mock_m3, cwd="/invalid/dir")
147+
148+
@patch("builtins.open", new_callable=mock_open)
149+
@patch("json.dump")
150+
def test_save_to_custom_path(
151+
self,
152+
mock_dump: MagicMock,
153+
mock_open_file: MagicMock,
154+
mock_m3: M3,
155+
tmp_path: Path,
156+
) -> None:
157+
"""Test saving to custom path."""
158+
save_path = tmp_path / "custom.json"
159+
config = FastMCPConfigGenerator.generate(mock_m3, save_path=str(save_path))
160+
mock_dump.assert_called_once_with(config, mock_open_file(), indent=2)

tests/core/test_server.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import os
2+
from unittest.mock import Mock, patch
3+
4+
import pytest
5+
6+
from m3.core.utils.exceptions import M3ValidationError
7+
from m3.m3 import M3
8+
9+
10+
class TestMCPServer:
11+
"""Tests for MCP server."""
12+
13+
def test_server_can_be_imported_as_module(self) -> None:
14+
"""Test that the server can be imported as a module."""
15+
import m3.core.server
16+
17+
assert hasattr(m3.core.server, "main")
18+
assert callable(m3.core.server.main)
19+
20+
@patch.dict(os.environ, {"M3_CONFIG_PATH": "test_config.json"})
21+
@patch("m3.core.server.M3.load")
22+
def test_main_success(self, mock_load: Mock) -> None:
23+
"""Test main function with valid config."""
24+
mock_m3 = Mock(spec=M3)
25+
mock_load.return_value = mock_m3
26+
from m3.core.server import main
27+
28+
main()
29+
mock_load.assert_called_once_with("test_config.json")
30+
mock_m3.build.assert_called_once()
31+
mock_m3.run.assert_called_once()
32+
33+
@patch.dict(os.environ, clear=True)
34+
def test_main_no_config_path(self) -> None:
35+
"""Test main raises error when M3_CONFIG_PATH is not set."""
36+
from m3.core.server import main
37+
38+
with pytest.raises(M3ValidationError, match="M3_CONFIG_PATH env var not set"):
39+
main()
40+
41+
@patch.dict(os.environ, {"M3_CONFIG_PATH": "invalid.json"})
42+
@patch("m3.core.server.M3.load")
43+
def test_main_load_failure(self, mock_load: Mock) -> None:
44+
"""Test main handles load failure."""
45+
mock_load.side_effect = FileNotFoundError("Config not found")
46+
from m3.core.server import main
47+
48+
with pytest.raises(FileNotFoundError):
49+
main()
50+
51+
@patch.dict(os.environ, {"M3_CONFIG_PATH": "test.json"})
52+
@patch("m3.core.server.M3.load")
53+
def test_main_build_failure(self, mock_load: Mock) -> None:
54+
"""Test main handles build failure."""
55+
mock_m3 = Mock(spec=M3)
56+
mock_load.return_value = mock_m3
57+
mock_m3.build.side_effect = M3ValidationError("Build failed")
58+
from m3.core.server import main
59+
60+
with pytest.raises(M3ValidationError):
61+
main()

0 commit comments

Comments
 (0)