test_config_init.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. # coding=utf-8
  2. # type: ignore[index,operator,assignment]
  3. """
  4. Unit tests for ddns.config.__init__ module
  5. @author: GitHub Copilot
  6. """
  7. from __init__ import unittest, patch, MagicMock, call
  8. import os
  9. import tempfile
  10. import shutil
  11. import json
  12. import sys
  13. import ddns.config
  14. from ddns.config import load_configs, Config
  15. from io import StringIO, BytesIO # For capturing stdout in Python2 and Python3
  16. def capture_stdout_output(func, *args, **kwargs):
  17. """Capture stdout output in a Python 2.7/3.x compatible way"""
  18. if sys.version_info[0] < 3:
  19. # Python 2.7: Use BytesIO and decode
  20. buf = BytesIO()
  21. with patch("sys.stdout", new=buf):
  22. func(*args, **kwargs)
  23. output = buf.getvalue()
  24. if isinstance(output, bytes):
  25. output = output.decode("utf-8")
  26. else:
  27. # Python 3.x: Use StringIO
  28. buf = StringIO()
  29. with patch("sys.stdout", new=buf):
  30. func(*args, **kwargs)
  31. output = buf.getvalue()
  32. return output
  33. class TestConfigInit(unittest.TestCase):
  34. """Test cases for ddns.config.__init__ module"""
  35. def setUp(self):
  36. """Set up test fixtures"""
  37. self.test_description = "Test DDNS Application"
  38. self.test_version = "1.0.0"
  39. self.test_date = "2025-07-07"
  40. self.test_dir = tempfile.mkdtemp()
  41. self.original_cwd = os.getcwd()
  42. # Change to the test directory to isolate from any existing config.json
  43. os.chdir(self.test_dir)
  44. # Save and clear any existing DDNS-related environment variables
  45. self.original_env = {}
  46. ddns_prefixes = ["DDNS_", "DNS_", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "PYTHONHTTPSVERIFY"]
  47. # Backup and clear any existing environment variables that might interfere
  48. for key in list(os.environ.keys()):
  49. if any(key.startswith(prefix) for prefix in ddns_prefixes):
  50. self.original_env[key] = os.environ.pop(key)
  51. def tearDown(self):
  52. """Clean up test fixtures"""
  53. # Restore original environment variables
  54. for key, value in self.original_env.items():
  55. os.environ[key] = value
  56. # Change back to original directory and clean up temp directory
  57. os.chdir(self.original_cwd)
  58. shutil.rmtree(self.test_dir, ignore_errors=True)
  59. def test_module_exports(self):
  60. """Test that module exports are correct"""
  61. expected_exports = ["load_configs", "Config"]
  62. self.assertEqual(ddns.config.__all__, expected_exports)
  63. self.assertTrue(hasattr(ddns.config, "load_configs"))
  64. self.assertTrue(hasattr(ddns.config, "Config"))
  65. self.assertEqual(ddns.config.load_configs, load_configs)
  66. self.assertEqual(ddns.config.Config, Config)
  67. def test_load_config_basic_integration(self):
  68. """Test basic load_config functionality with real files"""
  69. # Create test config file
  70. config_content = {"dns": "debug", "token": "test_token", "ttl": 300}
  71. config_path = os.path.join(self.test_dir, "test_config.json")
  72. with open(config_path, "w") as f:
  73. json.dump(config_content, f)
  74. # Test loading with CLI args
  75. with patch("sys.argv", ["ddns", "--config", config_path, "--id", "test_id"]):
  76. results = load_configs(self.test_description, self.test_version, self.test_date)
  77. result = results[0]
  78. self.assertIsInstance(result, Config)
  79. self.assertEqual(result.dns, "debug")
  80. self.assertEqual(result.id, "test_id") # CLI overrides
  81. self.assertEqual(result.token, "test_token") # From JSON
  82. self.assertEqual(result.ttl, 300) # From JSON
  83. def test_load_config_priority_order_integration(self):
  84. """Test configuration priority order using real Config objects"""
  85. json_config_path = os.path.join(self.test_dir, "test_config.json")
  86. with open(json_config_path, "w") as f:
  87. json.dump({"dns": "json_dns", "id": "json_id", "token": "json_token"}, f)
  88. os.environ["DDNS_DNS"] = "env_dns"
  89. os.environ["DDNS_ID"] = "env_id"
  90. os.environ["DDNS_TOKEN"] = "env_token"
  91. os.environ["DDNS_LINE"] = "env_line"
  92. try:
  93. from ddns.config.config import Config
  94. from ddns.config.file import load_config as load_file_config
  95. from ddns.config.env import load_config as load_env_config
  96. cli_config = {"dns": "cli_dns", "id": "cli_id", "config": json_config_path}
  97. json_config = load_file_config(json_config_path)
  98. env_config = load_env_config()
  99. result = Config(cli_config=cli_config, json_config=json_config, env_config=env_config)
  100. # Verify priority order: CLI > JSON > ENV
  101. self.assertEqual(result.dns, "cli_dns")
  102. self.assertEqual(result.id, "cli_id")
  103. self.assertEqual(result.token, "json_token") # JSON overrides ENV when CLI doesn't have it
  104. self.assertEqual(result.line, "env_line") # ENV used when neither CLI nor JSON have it
  105. finally:
  106. # Clean up test environment variables (original env will be restored in tearDown)
  107. for key in ["DDNS_DNS", "DDNS_ID", "DDNS_TOKEN", "DDNS_LINE"]:
  108. os.environ.pop(key, None)
  109. def test_load_config_file_paths_integration(self):
  110. """Test load_config with various config file path sources"""
  111. # Test case 1: Explicit config file path from CLI
  112. config_content = {"dns": "cloudflare", "id": "custom_id", "token": "custom_token"}
  113. config_path = os.path.join(self.test_dir, "custom_config.json")
  114. with open(config_path, "w") as f:
  115. json.dump(config_content, f)
  116. with patch("sys.argv", ["ddns", "--config", config_path]):
  117. results = load_configs(self.test_description, self.test_version, self.test_date)
  118. result = results[0]
  119. self.assertEqual(result.dns, "cloudflare")
  120. self.assertEqual(result.id, "custom_id")
  121. # Test case 2: Config file path from environment
  122. env_config_path = os.path.join(self.test_dir, "env_config.json")
  123. env_config_content = {"dns": "alidns", "id": "env_id", "token": "env_token"}
  124. with open(env_config_path, "w") as f:
  125. json.dump(env_config_content, f)
  126. with patch.dict(os.environ, {"DDNS_CONFIG": env_config_path}):
  127. with patch("sys.argv", ["ddns"]):
  128. results = load_configs(self.test_description, self.test_version, self.test_date)
  129. result = results[0]
  130. self.assertEqual(result.dns, "alidns")
  131. self.assertEqual(result.id, "env_id")
  132. @patch("ddns.config.load_env_config")
  133. @patch("ddns.config.load_file_config")
  134. @patch("ddns.config.load_cli_config")
  135. @patch("os.path.exists")
  136. @patch("os.path.expanduser")
  137. def test_load_config_default_locations(self, mock_expanduser, mock_exists, mock_cli, mock_json, mock_env):
  138. """Test load_config searches default config file locations"""
  139. mock_cli.return_value = {"dns": "debug"}
  140. mock_env.return_value = {}
  141. mock_expanduser.return_value = "/home/user/.ddns/config.json"
  142. # Test multiple default locations
  143. default_locations = [
  144. ("config.json", "local"),
  145. ("/home/user/.ddns/config.json", "home"),
  146. ("/etc/ddns/config.json", "system"),
  147. ]
  148. for config_path, location_type in default_locations:
  149. # Python 2.7 compatible: test each location separately without subTest
  150. mock_json.reset_mock()
  151. mock_exists.side_effect = lambda path: path == config_path
  152. mock_json.return_value = {"id": "{}_id".format(location_type)}
  153. result = load_configs(self.test_description, self.test_version, self.test_date)[0]
  154. mock_json.assert_called_with(config_path)
  155. self.assertEqual(result.id, "{}_id".format(location_type))
  156. def test_load_config_missing_files_integration(self):
  157. """Test load_config when config files don't exist"""
  158. # Test case 1: No config file but provide minimal CLI args
  159. with patch("sys.argv", ["ddns", "--dns", "debug", "--id", "test", "--token", "test"]):
  160. results = load_configs(self.test_description, self.test_version, self.test_date)
  161. result = results[0]
  162. self.assertEqual(result.dns, "debug")
  163. self.assertEqual(result.id, "test")
  164. # Test case 2: Specified config file doesn't exist should exit
  165. with patch("sys.argv", ["ddns", "--config", "/nonexistent/config.json", "--dns", "debug"]):
  166. with self.assertRaises(SystemExit):
  167. load_configs(self.test_description, self.test_version, self.test_date)
  168. def test_load_config_doc_string_format_integration(self):
  169. """Test that doc string is properly formatted with version and date"""
  170. # Create a minimal config to avoid auto-generation
  171. config_content = {"dns": "debug", "id": "test", "token": "test"}
  172. config_path = os.path.join(self.test_dir, "test_config.json")
  173. with open(config_path, "w") as f:
  174. json.dump(config_content, f)
  175. with patch("sys.argv", ["ddns", "--config", config_path]):
  176. results = load_configs(self.test_description, self.test_version, self.test_date)
  177. result = results[0]
  178. self.assertIsInstance(result, Config)
  179. self.assertEqual(result.dns, "debug")
  180. @patch("ddns.config.load_env_config")
  181. @patch("ddns.config.load_file_config")
  182. @patch("ddns.config.load_cli_config")
  183. @patch("os.path.exists")
  184. def test_load_config_config_object_creation(self, mock_exists, mock_cli, mock_json, mock_env):
  185. """Test that Config object is created with correct parameters"""
  186. cli_config = {"dns": "cloudflare", "id": "[email protected]", "config": "test_config.json"}
  187. json_config = {"token": "test_token", "ttl": 300}
  188. env_config = {"proxy": ["http://proxy.com"]}
  189. mock_cli.return_value = cli_config
  190. mock_json.return_value = json_config
  191. mock_env.return_value = env_config
  192. mock_exists.return_value = True
  193. with patch("ddns.config.Config") as mock_config_class:
  194. mock_config_instance = MagicMock()
  195. mock_config_instance.log_format = None # No custom format
  196. mock_config_instance.log_level = 20 # INFO level
  197. mock_config_instance.log_datefmt = "%Y-%m-%dT%H:%M:%S"
  198. mock_config_instance.log_file = None # No log file
  199. mock_config_instance.dns = "cloudflare" # Has DNS provider
  200. mock_config_class.return_value = mock_config_instance
  201. result = load_configs(self.test_description, self.test_version, self.test_date)[0]
  202. # Should create both main config and global config
  203. self.assertEqual(mock_config_class.call_count, 2)
  204. # Both calls should use the same parameters when there's only one config file
  205. expected_calls = [
  206. call(cli_config=cli_config, json_config=json_config, env_config=env_config),
  207. call(cli_config=cli_config, json_config=json_config, env_config=env_config),
  208. ]
  209. mock_config_class.assert_has_calls(expected_calls, any_order=True)
  210. self.assertEqual(result, mock_config_instance)
  211. @patch("ddns.config.load_env_config")
  212. @patch("ddns.config.load_file_config")
  213. @patch("ddns.config.load_cli_config")
  214. @patch("os.path.exists")
  215. def test_load_config_integration(self, mock_exists, mock_cli, mock_json, mock_env):
  216. """Test complete integration of load_config function"""
  217. mock_cli.return_value = {
  218. "dns": "cloudflare",
  219. "id": "[email protected]",
  220. "log_level": "DEBUG",
  221. "cache": "false",
  222. "config": "test_config.json",
  223. }
  224. mock_json.return_value = {
  225. "token": "cf_token_123",
  226. "ipv4": ["home.example.com", "office.example.com"],
  227. "ttl": 300,
  228. "cache": "true", # Should be overridden by CLI
  229. }
  230. mock_env.return_value = {"proxy": ["http://proxy.corp.com:8080"], "line": "default"}
  231. mock_exists.return_value = True
  232. results = load_configs(self.test_description, self.test_version, self.test_date)
  233. result = results[0]
  234. self.assertIsInstance(result, Config)
  235. # CLI overrides
  236. self.assertEqual(result.dns, "cloudflare")
  237. self.assertEqual(result.id, "[email protected]")
  238. self.assertEqual(result.log_level, 10) # DEBUG
  239. self.assertFalse(result.cache) # CLI "false" overrides JSON "true"
  240. # JSON values
  241. self.assertEqual(result.token, "cf_token_123")
  242. self.assertEqual(result.ipv4, ["home.example.com", "office.example.com"])
  243. self.assertEqual(result.ttl, 300)
  244. # ENV values
  245. self.assertEqual(result.proxy, ["http://proxy.corp.com:8080"])
  246. self.assertEqual(result.line, "default")
  247. def test_load_config_parameter_validation_and_edge_cases(self):
  248. """Test load_config parameter validation and edge cases"""
  249. # Test case 1: Valid parameters with DNS
  250. config_content = {"dns": "debug", "id": "test", "token": "test"}
  251. config_path = os.path.join(self.test_dir, "valid_config.json")
  252. with open(config_path, "w") as f:
  253. json.dump(config_content, f)
  254. with patch("sys.argv", ["ddns", "--config", config_path]):
  255. results = load_configs(self.test_description, self.test_version, self.test_date)
  256. result = results[0]
  257. self.assertIsInstance(result, Config)
  258. # Test case 2: Empty string parameters but provide CLI DNS - no config files exist
  259. with patch("sys.argv", ["ddns", "--dns", "debug", "--id", "test", "--token", "test"]):
  260. results = load_configs("", "", "")
  261. result = results[0]
  262. self.assertIsInstance(result, Config)
  263. # Test case 3: Empty configurations should cause exit (edge case)
  264. # Switch to a clean temporary directory to ensure no config.json exists
  265. with patch("ddns.config.load_env_config", return_value={}): # Empty env config
  266. with patch("sys.argv", ["ddns"]): # No arguments at all
  267. with self.assertRaises(SystemExit) as cm:
  268. load_configs(self.test_description, self.test_version, self.test_date)
  269. self.assertEqual(cm.exception.code, 1) # Should exit with error code 1
  270. def test_config_file_discovery_integration(self):
  271. """Test config file discovery in current directory"""
  272. # Test 1: Direct path loading
  273. config_content = {"dns": "cloudflare", "id": "[email protected]", "token": "secret123"}
  274. config_path = os.path.join(self.test_dir, "direct_config.json")
  275. with open(config_path, "w") as f:
  276. json.dump(config_content, f)
  277. from ddns.config.file import load_config as load_file_config
  278. loaded_config = load_file_config(config_path)
  279. self.assertEqual(loaded_config["dns"], "cloudflare")
  280. self.assertEqual(loaded_config["id"], "[email protected]")
  281. self.assertEqual(loaded_config["token"], "secret123")
  282. # Test 2: Auto-discovery in current directory
  283. auto_config_content = {"dns": "debug", "id": "[email protected]", "token": "auto123"}
  284. auto_config_path = os.path.join(self.test_dir, "config.json") # Default name in current dir
  285. with open(auto_config_path, "w") as f:
  286. json.dump(auto_config_content, f)
  287. # Test that it can be auto-discovered when no explicit config is provided
  288. with patch("sys.argv", ["ddns"]):
  289. results = load_configs(self.test_description, self.test_version, self.test_date)
  290. result = results[0]
  291. self.assertEqual(result.dns, "debug")
  292. self.assertEqual(result.id, "[email protected]")
  293. self.assertEqual(result.token, "auto123")
  294. def test_environment_config_integration(self):
  295. """Test environment configuration loading without mocking"""
  296. test_env_vars = {
  297. "DDNS_DNS": "dnspod",
  298. "DDNS_ID": "test_user",
  299. "DDNS_TOKEN": "test_token_123",
  300. "DDNS_TTL": "600",
  301. "DDNS_IPV4": '["ip1.example.com", "ip2.example.com"]',
  302. }
  303. for key, value in test_env_vars.items():
  304. os.environ[key] = value
  305. try:
  306. from ddns.config.env import load_config as load_env_config
  307. env_config = load_env_config()
  308. self.assertEqual(env_config["dns"], "dnspod")
  309. self.assertEqual(env_config["id"], "test_user")
  310. self.assertEqual(env_config["token"], "test_token_123")
  311. self.assertEqual(env_config["ttl"], "600")
  312. self.assertEqual(env_config["ipv4"], ["ip1.example.com", "ip2.example.com"])
  313. finally:
  314. # Clean up test environment variables (original env will be restored in tearDown)
  315. for key in test_env_vars:
  316. os.environ.pop(key, None)
  317. def test_config_merging_without_mocks(self):
  318. """Test configuration merging using real Config objects"""
  319. cli_config = {"dns": "cloudflare", "debug": True}
  320. json_config = {"dns": "dnspod", "id": "json_user", "token": "json_token"}
  321. env_config = {"dns": "alidns", "id": "env_user", "token": "env_token", "ttl": "300"}
  322. from ddns.config.config import Config
  323. config = Config(cli_config=cli_config, json_config=json_config, env_config=env_config)
  324. # Verify CLI takes highest priority
  325. self.assertEqual(config.dns, "cloudflare")
  326. # Verify JSON takes priority over ENV when CLI doesn't have the value
  327. self.assertEqual(config.id, "json_user")
  328. self.assertEqual(config.token, "json_token")
  329. # Verify ENV is used when neither CLI nor JSON have the value
  330. self.assertEqual(config.ttl, 300) # Should be converted to int
  331. def test_array_parameter_processing_integration(self):
  332. """Test array parameter processing without mocking"""
  333. from ddns.config.config import Config
  334. test_configs = {
  335. "cli": {"ipv4": "ip1.com,ip2.com,ip3.com", "proxy": "proxy1;proxy2"},
  336. "json": {"ipv6": "ipv6-1.com,ipv6-2.com", "index4": "regex:192\\.168\\..*,backup"},
  337. "env": {"index6": "default,public"},
  338. }
  339. config = Config(
  340. cli_config=test_configs["cli"], json_config=test_configs["json"], env_config=test_configs["env"]
  341. )
  342. # Verify array splitting works correctly
  343. self.assertEqual(config.ipv4, ["ip1.com", "ip2.com", "ip3.com"])
  344. self.assertEqual(config.proxy, ["proxy1", "proxy2"])
  345. self.assertEqual(config.ipv6, ["ipv6-1.com", "ipv6-2.com"])
  346. # Verify special prefix handling (should not split)
  347. self.assertEqual(config.index4, ["regex:192\\.168\\..*,backup"])
  348. self.assertEqual(config.index6, ["default", "public"])
  349. def test_file_parsing_fallback_integration(self):
  350. """Test JSON to AST parsing fallback without mocking"""
  351. python_dict_content = "{'dns': 'dnspod', 'id': 'python_user', 'token': 'python_token'}"
  352. config_path = os.path.join(self.test_dir, "python_config.py")
  353. with open(config_path, "w") as f:
  354. f.write(python_dict_content)
  355. from ddns.config.file import load_config as load_file_config
  356. loaded_config = load_file_config(config_path)
  357. self.assertEqual(loaded_config["dns"], "dnspod")
  358. self.assertEqual(loaded_config["id"], "python_user")
  359. self.assertEqual(loaded_config["token"], "python_token")
  360. def test_special_value_handling_integration(self):
  361. """Test special value handling without mocking"""
  362. from ddns.config.config import Config
  363. config_data = {"proxy": "DIRECT,http://proxy.com,NONE", "ssl": "auto", "cache": "true", "ttl": "600"}
  364. config = Config(cli_config=config_data)
  365. # Verify proxy special value handling
  366. self.assertEqual(config.proxy, [None, "http://proxy.com", None])
  367. # Verify boolean conversion
  368. self.assertTrue(config.cache)
  369. # Verify TTL conversion to int
  370. self.assertEqual(config.ttl, 600)
  371. self.assertIsInstance(config.ttl, int)
  372. if __name__ == "__main__":
  373. unittest.main()