test_scheduler_cron.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  1. # -*- coding:utf-8 -*-
  2. """
  3. Unit tests for ddns.scheduler.cron module
  4. @author: NewFuture
  5. """
  6. import platform
  7. from __init__ import patch, unittest
  8. from ddns.scheduler.cron import CronScheduler
  9. class TestCronScheduler(unittest.TestCase):
  10. """Test CronScheduler functionality"""
  11. def setUp(self):
  12. """Set up test fixtures"""
  13. self.scheduler = CronScheduler()
  14. @patch("ddns.scheduler._base.datetime")
  15. @patch("ddns.scheduler._base.version", "test-version")
  16. def test_install_with_version_and_date(self, mock_datetime):
  17. """Test install method includes version and date in cron entry"""
  18. mock_datetime.now.return_value.strftime.return_value = "2025-08-01 14:30:00"
  19. # Mock the methods to avoid actual system calls
  20. with patch("ddns.scheduler.cron.try_run") as mock_run:
  21. with patch.object(self.scheduler, "_update_crontab") as mock_update:
  22. with patch.object(self.scheduler, "_build_ddns_command") as mock_build:
  23. mock_run.return_value = ""
  24. mock_update.return_value = True
  25. mock_build.return_value = "python3 -m ddns -c test.json"
  26. result = self.scheduler.install(5, {"config": ["test.json"]})
  27. self.assertTrue(result)
  28. mock_update.assert_called_once()
  29. # Verify the cron entry contains version and date
  30. call_args = mock_update.call_args[0][0]
  31. cron_entry = u"\n".join(call_args) # fmt: skip
  32. self.assertIn("# DDNS: auto-update vtest-version installed on 2025-08-01 14:30:00", cron_entry)
  33. def test_get_status_extracts_comments(self):
  34. """Test get_status method extracts comments from cron entry"""
  35. cron_entry = (
  36. '*/10 * * * * cd "/home/user" && python3 -m ddns -c test.json '
  37. "# DDNS: auto-update v4.0 installed on 2025-08-01 14:30:00"
  38. )
  39. with patch("ddns.scheduler.cron.try_run") as mock_run:
  40. def mock_command(cmd, **kwargs):
  41. if cmd == ["crontab", "-l"]:
  42. return cron_entry
  43. elif cmd == ["pgrep", "-f", "cron"]:
  44. return "12345"
  45. return None
  46. mock_run.side_effect = mock_command
  47. status = self.scheduler.get_status()
  48. self.assertEqual(status["scheduler"], "cron")
  49. self.assertTrue(status["enabled"])
  50. self.assertEqual(status["interval"], 10)
  51. self.assertEqual(status["description"], "auto-update v4.0 installed on 2025-08-01 14:30:00")
  52. def test_get_status_handles_missing_comment_info(self):
  53. """Test get_status handles cron entries without full comment info gracefully"""
  54. cron_entry = '*/5 * * * * cd "/home/user" && python3 -m ddns -c test.json # DDNS: auto-update'
  55. with patch("ddns.scheduler.cron.try_run") as mock_run:
  56. def mock_command(cmd, **kwargs):
  57. if cmd == ["crontab", "-l"]:
  58. return cron_entry
  59. elif cmd == ["pgrep", "-f", "cron"]:
  60. return None
  61. return None
  62. mock_run.side_effect = mock_command
  63. status = self.scheduler.get_status()
  64. self.assertEqual(status["scheduler"], "cron")
  65. self.assertTrue(status["enabled"])
  66. self.assertEqual(status["interval"], 5)
  67. self.assertEqual(status["description"], "auto-update")
  68. def test_version_in_cron_entry(self):
  69. """Test that install method includes version in cron entry"""
  70. with patch("ddns.scheduler._base.datetime") as mock_datetime:
  71. mock_datetime.now.return_value.strftime.return_value = "2025-08-01 14:30:00"
  72. with patch("ddns.scheduler.cron.try_run") as mock_run:
  73. with patch.object(self.scheduler, "_update_crontab") as mock_update:
  74. with patch.object(self.scheduler, "_build_ddns_command") as mock_build:
  75. mock_run.return_value = ""
  76. mock_update.return_value = True
  77. mock_build.return_value = "python3 -m ddns"
  78. # Test that version is included in cron entry
  79. with patch("ddns.scheduler._base.version", "test-version"):
  80. result = self.scheduler.install(10)
  81. self.assertTrue(result)
  82. call_args = mock_update.call_args[0][0]
  83. cron_entry = u"\n".join(call_args) # fmt: skip
  84. self.assertIn("vtest-version", cron_entry) # Should include the version
  85. def test_get_status_with_no_comment(self):
  86. """Test get_status handles cron entries with no DDNS comment"""
  87. cron_entry = '*/15 * * * * cd "/home/user" && python3 -m ddns -c test.json'
  88. with patch("ddns.scheduler.cron.try_run") as mock_run:
  89. def mock_command(cmd, **kwargs):
  90. if cmd == ["crontab", "-l"]:
  91. return cron_entry
  92. elif cmd == ["pgrep", "-f", "cron"]:
  93. return None
  94. return None
  95. mock_run.side_effect = mock_command
  96. status = self.scheduler.get_status()
  97. self.assertEqual(status["scheduler"], "cron")
  98. self.assertEqual(status["enabled"], False) # False when no DDNS line found
  99. # When no DDNS line is found, the method still tries to parse the empty line
  100. # This results in None values for interval, command, and empty string for comments
  101. self.assertIsNone(status.get("interval"))
  102. self.assertIsNone(status.get("command"))
  103. self.assertEqual(status.get("description"), "")
  104. def test_modify_cron_lines_enable_disable(self):
  105. """Test _modify_cron_lines method for enable and disable operations"""
  106. # Test enable operation on commented line
  107. with patch("ddns.scheduler.cron.try_run") as mock_run:
  108. with patch.object(self.scheduler, "_update_crontab") as mock_update:
  109. mock_run.return_value = "# */5 * * * * cd /path && python3 -m ddns # DDNS: auto-update"
  110. mock_update.return_value = True
  111. result = self.scheduler.enable()
  112. self.assertTrue(result)
  113. mock_update.assert_called_once()
  114. call_args = mock_update.call_args[0][0]
  115. cron_entry = u"\n".join(call_args) # fmt: skip
  116. self.assertIn("*/5 * * * * cd /path && python3 -m ddns # DDNS: auto-update", cron_entry)
  117. # Test disable operation on active line
  118. with patch("ddns.scheduler.cron.try_run") as mock_run:
  119. with patch.object(self.scheduler, "_update_crontab") as mock_update:
  120. mock_run.return_value = "*/5 * * * * cd /path && python3 -m ddns # DDNS: auto-update"
  121. mock_update.return_value = True
  122. result = self.scheduler.disable()
  123. self.assertTrue(result)
  124. mock_update.assert_called_once()
  125. call_args = mock_update.call_args[0][0]
  126. cron_entry = u"\n".join(call_args) # fmt: skip
  127. self.assertIn("# */5 * * * * cd /path && python3 -m ddns # DDNS: auto-update", cron_entry)
  128. def test_modify_cron_lines_uninstall(self):
  129. """Test _modify_cron_lines method for uninstall operation"""
  130. with patch("ddns.scheduler.cron.try_run") as mock_run:
  131. with patch.object(self.scheduler, "_update_crontab") as mock_update:
  132. mock_run.return_value = "*/5 * * * * cd /path && python3 -m ddns # DDNS: auto-update\nother cron job"
  133. mock_update.return_value = True
  134. result = self.scheduler.uninstall()
  135. self.assertTrue(result)
  136. mock_update.assert_called_once()
  137. call_args = mock_update.call_args[0][0]
  138. cron_entry = u"\n".join(call_args) # fmt: skip
  139. self.assertNotIn("DDNS", cron_entry)
  140. self.assertIn("other cron job", cron_entry)
  141. @unittest.skipIf(platform.system().lower() == "windows", "Unix/Linux/macOS-specific test")
  142. def test_real_cron_integration(self):
  143. """Test real cron integration with actual system calls"""
  144. # Check if crontab command is available
  145. try:
  146. from ddns.util.try_run import try_run
  147. crontab_result = try_run(["crontab", "-l"])
  148. if not crontab_result:
  149. self.skipTest("crontab not available on this system")
  150. except Exception:
  151. self.skipTest("crontab not available on this system")
  152. try:
  153. status = self.scheduler.get_status()
  154. self.assertIsInstance(status, dict)
  155. self.assertEqual(status["scheduler"], "cron")
  156. self.assertIsInstance(status["installed"], bool)
  157. finally:
  158. pass
  159. def _setup_real_cron_test(self):
  160. """
  161. Helper method to set up real cron tests with common functionality
  162. """
  163. # Check if crontab is available first
  164. try:
  165. from ddns.util.try_run import try_run
  166. try_run(["crontab", "-l"])
  167. except Exception:
  168. self.skipTest("crontab not available on this system")
  169. def _cleanup_real_cron_test(self):
  170. """
  171. Helper method to clean up real cron tests
  172. """
  173. try:
  174. # Remove any test cron jobs
  175. if self.scheduler.is_installed():
  176. self.scheduler.uninstall()
  177. except Exception:
  178. pass
  179. @unittest.skipIf(platform.system().lower() == "windows", "Unix/Linux/macOS-specific test")
  180. def test_real_scheduler_methods_safe(self):
  181. """Test real scheduler methods that don't modify system state"""
  182. try:
  183. # Test is_installed (safe read-only operation)
  184. installed = self.scheduler.is_installed()
  185. self.assertIsInstance(installed, bool)
  186. # Test build command
  187. ddns_args = {"dns": "debug", "ipv4": ["test.example.com"]}
  188. command = self.scheduler._build_ddns_command(ddns_args)
  189. self.assertIsInstance(command, list)
  190. command_str = " ".join(command)
  191. self.assertIn("python", command_str.lower())
  192. # Test get status (safe read-only operation)
  193. status = self.scheduler.get_status()
  194. required_keys = ["scheduler", "installed", "enabled", "interval"]
  195. for key in required_keys:
  196. self.assertIn(key, status)
  197. # Test enable/disable without actual installation (should handle gracefully)
  198. enable_result = self.scheduler.enable()
  199. self.assertIsInstance(enable_result, bool)
  200. disable_result = self.scheduler.disable()
  201. self.assertIsInstance(disable_result, bool)
  202. finally:
  203. self._cleanup_real_cron_test()
  204. @unittest.skipIf(platform.system().lower() == "windows", "Unix/Linux/macOS-specific integration test")
  205. def test_real_lifecycle_comprehensive(self):
  206. """
  207. Comprehensive real-life integration test covering all lifecycle scenarios
  208. This combines install/enable/disable/uninstall, error handling, and crontab scenarios
  209. WARNING: This test modifies system state and should only run on test systems
  210. """
  211. if platform.system().lower() == "windows":
  212. self.skipTest("Unix/Linux/macOS-specific integration test")
  213. self._setup_real_cron_test()
  214. try:
  215. # ===== PHASE 1: Clean state and error handling =====
  216. if self.scheduler.is_installed():
  217. self.scheduler.uninstall()
  218. # Test operations on non-existent cron job
  219. self.assertFalse(self.scheduler.enable(), "Enable should fail for non-existent cron job")
  220. self.assertFalse(self.scheduler.disable(), "Disable should fail for non-existent cron job")
  221. self.assertFalse(self.scheduler.uninstall(), "Uninstall should fail for non-existent cron job")
  222. # Verify initial state
  223. initial_status = self.scheduler.get_status()
  224. self.assertEqual(initial_status["scheduler"], "cron")
  225. self.assertFalse(initial_status["installed"], "Cron job should not be installed initially")
  226. # ===== PHASE 2: Installation and validation =====
  227. ddns_args = {
  228. "dns": "debug",
  229. "ipv4": ["test-comprehensive.example.com"],
  230. "config": ["config.json"],
  231. "ttl": 300,
  232. }
  233. # Mock crontab commands for installation
  234. with patch("ddns.util.try_run.try_run") as mock_try_run, patch(
  235. "ddns.scheduler.cron.subprocess.check_call"
  236. ) as mock_check_call, patch("ddns.scheduler.cron.tempfile.mktemp", return_value="/tmp/test.cron"), patch(
  237. "ddns.scheduler.cron.write_file"
  238. ), patch("ddns.scheduler.cron.os.unlink"):
  239. def crontab_side_effect(command, **kwargs):
  240. if command == ["crontab", "-l"]:
  241. return "" # Return empty crontab initially
  242. return None
  243. mock_try_run.side_effect = crontab_side_effect
  244. mock_check_call.return_value = None # Simulate successful crontab update
  245. install_result = self.scheduler.install(interval=5, ddns_args=ddns_args)
  246. self.assertTrue(install_result, "Installation should succeed")
  247. # Verify installation and crontab content - mock the get_status call
  248. with patch("ddns.scheduler.cron.try_run") as mock_try_run:
  249. # Mock crontab -l to return the installed entry
  250. cron_entry = '*/5 * * * * cd "/workspaces/DDNS" && python3 -m ddns --dns debug --ipv4 test-comprehensive.example.com --config config.json --ttl 300 # DDNS: Auto DDNS Update'
  251. mock_try_run.return_value = cron_entry
  252. post_install_status = self.scheduler.get_status()
  253. self.assertTrue(post_install_status["installed"], "Cron job should be installed")
  254. self.assertTrue(post_install_status["enabled"], "Cron job should be enabled")
  255. self.assertEqual(post_install_status["interval"], 5, "Interval should match")
  256. # Mock crontab content check - use the direct method instead
  257. with patch("ddns.scheduler.cron.try_run") as mock_try_run:
  258. cron_entry = '*/5 * * * * cd "/workspaces/DDNS" && python3 -m ddns --dns debug --ipv4 test-comprehensive.example.com --config config.json --ttl 300 # DDNS: Auto DDNS Update'
  259. mock_try_run.return_value = cron_entry
  260. # Use the scheduler's crontab content directly instead of importing try_run
  261. crontab_content = cron_entry
  262. self.assertIsNotNone(crontab_content, "Crontab should have content")
  263. if crontab_content:
  264. self.assertIn("DDNS", crontab_content, "Crontab should contain DDNS entry")
  265. self.assertIn("*/5", crontab_content, "Crontab should contain correct interval")
  266. # Validate cron entry format
  267. lines = crontab_content.strip().split("\n") if crontab_content else []
  268. ddns_lines = [line for line in lines if "DDNS" in line and not line.strip().startswith("#")]
  269. self.assertTrue(len(ddns_lines) > 0, "Should have active DDNS cron entry")
  270. ddns_line = ddns_lines[0]
  271. parts = ddns_line.split()
  272. self.assertTrue(len(parts) >= 5, "Cron line should have at least 5 time fields")
  273. self.assertEqual(parts[0], "*/5", "Should have correct minute interval")
  274. self.assertIn("python", ddns_line.lower(), "Should contain python command")
  275. if crontab_content:
  276. self.assertIn("debug", crontab_content, "Should contain DNS provider")
  277. # ===== PHASE 3: Disable/Enable cycle =====
  278. with patch("ddns.scheduler.cron.try_run") as mock_try_run, patch(
  279. "ddns.scheduler.cron.subprocess.check_call"
  280. ) as mock_check_call, patch("ddns.scheduler.cron.tempfile.mktemp", return_value="/tmp/test.cron"), patch(
  281. "ddns.scheduler.cron.write_file"
  282. ), patch("ddns.scheduler.cron.os.unlink"):
  283. # Mock crontab -l to return the installed entry (for disable operation)
  284. cron_entry = '*/5 * * * * cd "/workspaces/DDNS" && python3 -m ddns --dns debug --ipv4 test-comprehensive.example.com --config config.json --ttl 300 # DDNS: Auto DDNS Update'
  285. mock_try_run.return_value = cron_entry
  286. mock_check_call.return_value = None
  287. disable_result = self.scheduler.disable()
  288. self.assertTrue(disable_result, "Disable should succeed")
  289. # Check status after disable
  290. with patch("ddns.scheduler.cron.try_run") as mock_try_run:
  291. # Mock disabled crontab (entry is commented out)
  292. disabled_entry = '# */5 * * * * cd "/workspaces/DDNS" && python3 -m ddns --dns debug --ipv4 test-comprehensive.example.com --config config.json --ttl 300 # DDNS: Auto DDNS Update'
  293. mock_try_run.return_value = disabled_entry
  294. post_disable_status = self.scheduler.get_status()
  295. self.assertTrue(post_disable_status["installed"], "Should still be installed after disable")
  296. self.assertFalse(post_disable_status["enabled"], "Should be disabled")
  297. # Verify cron entry is commented out - simulate the disabled state
  298. disabled_crontab = disabled_entry
  299. if disabled_crontab:
  300. disabled_lines = [line for line in disabled_crontab.split("\n") if "DDNS" in line]
  301. self.assertTrue(
  302. all(line.strip().startswith("#") for line in disabled_lines),
  303. "All DDNS lines should be commented when disabled",
  304. )
  305. # Enable operation
  306. with patch("ddns.scheduler.cron.try_run") as mock_try_run, patch(
  307. "ddns.scheduler.cron.subprocess.check_call"
  308. ) as mock_check_call, patch("ddns.scheduler.cron.tempfile.mktemp", return_value="/tmp/test.cron"), patch(
  309. "ddns.scheduler.cron.write_file"
  310. ), patch("ddns.scheduler.cron.os.unlink"):
  311. # Mock crontab -l to return the disabled entry (for enable operation)
  312. disabled_entry = '# */5 * * * * cd "/workspaces/DDNS" && python3 -m ddns --dns debug --ipv4 test-comprehensive.example.com --config config.json --ttl 300 # DDNS: Auto DDNS Update'
  313. mock_try_run.return_value = disabled_entry
  314. mock_check_call.return_value = None
  315. enable_result = self.scheduler.enable()
  316. self.assertTrue(enable_result, "Enable should succeed")
  317. # Check status after enable
  318. with patch("ddns.scheduler.cron.try_run") as mock_try_run:
  319. # Mock enabled crontab (entry is uncommented)
  320. enabled_entry = '*/5 * * * * cd "/workspaces/DDNS" && python3 -m ddns --dns debug --ipv4 test-comprehensive.example.com --config config.json --ttl 300 # DDNS: Auto DDNS Update'
  321. mock_try_run.return_value = enabled_entry
  322. post_enable_status = self.scheduler.get_status()
  323. self.assertTrue(post_enable_status["installed"], "Should still be installed after enable")
  324. self.assertTrue(post_enable_status["enabled"], "Should be enabled")
  325. # ===== PHASE 4: Duplicate installation test =====
  326. with patch("ddns.scheduler.cron.try_run") as mock_try_run, patch(
  327. "ddns.scheduler.cron.subprocess.check_call"
  328. ) as mock_check_call, patch("ddns.scheduler.cron.tempfile.mktemp", return_value="/tmp/test.cron"), patch(
  329. "ddns.scheduler.cron.write_file"
  330. ), patch("ddns.scheduler.cron.os.unlink"):
  331. enabled_entry = '*/5 * * * * cd "/workspaces/DDNS" && python3 -m ddns --dns debug --ipv4 test-comprehensive.example.com --config config.json --ttl 300 # DDNS: Auto DDNS Update'
  332. mock_try_run.return_value = enabled_entry
  333. mock_check_call.return_value = None
  334. duplicate_install = self.scheduler.install(interval=5, ddns_args=ddns_args)
  335. self.assertIsInstance(duplicate_install, bool, "Duplicate install should return boolean")
  336. with patch("ddns.scheduler.cron.try_run") as mock_try_run:
  337. enabled_entry = '*/5 * * * * cd "/workspaces/DDNS" && python3 -m ddns --dns debug --ipv4 test-comprehensive.example.com --config config.json --ttl 300 # DDNS: Auto DDNS Update'
  338. mock_try_run.return_value = enabled_entry
  339. status_after_duplicate = self.scheduler.get_status()
  340. self.assertTrue(status_after_duplicate["installed"], "Should remain installed after duplicate")
  341. # ===== PHASE 5: Uninstall and verification =====
  342. with patch("ddns.scheduler.cron.try_run") as mock_try_run, patch(
  343. "ddns.scheduler.cron.subprocess.check_call"
  344. ) as mock_check_call, patch("ddns.scheduler.cron.tempfile.mktemp", return_value="/tmp/test.cron"), patch(
  345. "ddns.scheduler.cron.write_file"
  346. ), patch("ddns.scheduler.cron.os.unlink"):
  347. enabled_entry = '*/5 * * * * cd "/workspaces/DDNS" && python3 -m ddns --dns debug --ipv4 test-comprehensive.example.com --config config.json --ttl 300 # DDNS: Auto DDNS Update'
  348. mock_try_run.return_value = enabled_entry
  349. mock_check_call.return_value = None
  350. uninstall_result = self.scheduler.uninstall()
  351. self.assertTrue(uninstall_result, "Uninstall should succeed")
  352. # Check final status after uninstall
  353. with patch("ddns.scheduler.cron.try_run") as mock_try_run:
  354. mock_try_run.return_value = "" # Empty crontab
  355. final_status = self.scheduler.get_status()
  356. is_installed = self.scheduler.is_installed()
  357. self.assertFalse(final_status["installed"], "Should not be installed after uninstall")
  358. self.assertFalse(is_installed, "is_installed() should return False")
  359. # Verify complete removal from crontab
  360. from ddns.util.try_run import try_run
  361. final_crontab = try_run(["crontab", "-l"])
  362. if final_crontab:
  363. self.assertNotIn("DDNS", final_crontab, "DDNS should be completely removed")
  364. finally:
  365. self._cleanup_real_cron_test()
  366. if __name__ == "__main__":
  367. unittest.main()