diff --git a/test/test_execute.py b/test/test_execute.py
new file mode 100644
index 0000000..2119a6a
--- /dev/null
+++ b/test/test_execute.py
@@ -0,0 +1,169 @@
+#!/usr/bin/env python3
+# © 2025 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+import os
+import unittest
+from unittest.mock import patch
+
+from script.execute.execute import Execute
+
+
+class TestExecuteInit(unittest.TestCase):
+ """Test Execute class initialization."""
+
+ @patch("shutil.which")
+ def test_init_with_gnome_terminal(self, mock_which):
+ mock_which.side_effect = lambda x: (
+ "/usr/bin/gnome-terminal" if x == "gnome-terminal" else None
+ )
+ exe = Execute()
+ self.assertIn("gnome-terminal", exe.cmd_source_erplibre)
+ self.assertIn(".venv.erplibre", exe.cmd_source_erplibre)
+ self.assertIn("gnome-terminal", exe.cmd_source_default)
+
+ @patch("shutil.which")
+ def test_init_with_osascript(self, mock_which):
+ mock_which.side_effect = lambda x: (
+ "/usr/bin/osascript" if x == "osascript" else None
+ )
+ exe = Execute()
+ self.assertIn("osascript", exe.cmd_source_erplibre)
+
+ @patch("shutil.which", return_value=None)
+ def test_init_fallback_source(self, mock_which):
+ exe = Execute()
+ self.assertIn(".venv.erplibre/bin/activate", exe.cmd_source_erplibre)
+ self.assertEqual(exe.cmd_source_default, "")
+
+
+class TestExecCommandLive(unittest.TestCase):
+ """Test exec_command_live method."""
+
+ def setUp(self):
+ with patch("shutil.which", return_value=None):
+ self.exe = Execute()
+
+ def test_simple_command_returns_zero(self):
+ result = self.exe.exec_command_live(
+ "echo hello",
+ source_erplibre=False,
+ quiet=True,
+ )
+ self.assertEqual(result, 0)
+
+ def test_failing_command_returns_nonzero(self):
+ result = self.exe.exec_command_live(
+ "exit 42",
+ source_erplibre=False,
+ quiet=True,
+ )
+ self.assertEqual(result, 42)
+
+ def test_return_status_and_output(self):
+ status, output = self.exe.exec_command_live(
+ "echo hello",
+ source_erplibre=False,
+ quiet=True,
+ return_status_and_output=True,
+ )
+ self.assertEqual(status, 0)
+ self.assertEqual(output, ["hello"])
+
+ def test_return_status_and_output_multiline(self):
+ status, output = self.exe.exec_command_live(
+ "echo -e 'line1\nline2\nline3'",
+ source_erplibre=False,
+ quiet=True,
+ return_status_and_output=True,
+ )
+ self.assertEqual(status, 0)
+ self.assertEqual(output, ["line1", "line2", "line3"])
+
+ def test_return_status_and_command(self):
+ status, cmd = self.exe.exec_command_live(
+ "echo test",
+ source_erplibre=False,
+ quiet=True,
+ return_status_and_command=True,
+ )
+ self.assertEqual(status, 0)
+ self.assertEqual(cmd, "echo test")
+
+ def test_return_status_and_output_and_command(self):
+ status, cmd, output = self.exe.exec_command_live(
+ "echo result",
+ source_erplibre=False,
+ quiet=True,
+ return_status_and_output_and_command=True,
+ )
+ self.assertEqual(status, 0)
+ self.assertEqual(cmd, "echo result")
+ self.assertEqual(output, ["result"])
+
+ def test_source_erplibre_prepends_activate(self):
+ status, cmd = self.exe.exec_command_live(
+ "echo test",
+ source_erplibre=True,
+ quiet=True,
+ return_status_and_command=True,
+ )
+ self.assertIn(".venv.erplibre/bin/activate", cmd)
+
+ def test_single_source_erplibre(self):
+ status, cmd = self.exe.exec_command_live(
+ "echo test",
+ source_erplibre=False,
+ single_source_erplibre=True,
+ quiet=True,
+ return_status_and_command=True,
+ )
+ self.assertIn(".venv.erplibre/bin/activate", cmd)
+ self.assertIn("echo test", cmd)
+
+ def test_single_source_odoo_no_version_returns_error(self):
+ with patch("os.path.exists", return_value=False):
+ result = self.exe.exec_command_live(
+ "echo test",
+ source_erplibre=False,
+ single_source_odoo=True,
+ source_odoo="",
+ quiet=True,
+ )
+ self.assertEqual(result, -1)
+
+ def test_single_source_odoo_with_version(self):
+ status, cmd = self.exe.exec_command_live(
+ "echo test",
+ source_erplibre=False,
+ single_source_odoo=True,
+ source_odoo="odoo18",
+ quiet=True,
+ return_status_and_command=True,
+ )
+ self.assertIn(".venv.odoo18/bin/activate", cmd)
+
+ def test_new_env_passed_to_subprocess(self):
+ status, output = self.exe.exec_command_live(
+ "echo $MY_TEST_VAR",
+ source_erplibre=False,
+ quiet=True,
+ new_env={"MY_TEST_VAR": "test_value_123"},
+ return_status_and_output=True,
+ )
+ self.assertEqual(status, 0)
+ self.assertEqual(output, ["test_value_123"])
+
+ def test_empty_output_command(self):
+ status, output = self.exe.exec_command_live(
+ "true",
+ source_erplibre=False,
+ quiet=True,
+ return_status_and_output=True,
+ )
+ self.assertEqual(status, 0)
+ self.assertEqual(output, [])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_git_tool.py b/test/test_git_tool.py
new file mode 100644
index 0000000..113514b
--- /dev/null
+++ b/test/test_git_tool.py
@@ -0,0 +1,327 @@
+#!/usr/bin/env python3
+# © 2025 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+import os
+import tempfile
+import unittest
+from collections import OrderedDict
+from unittest.mock import MagicMock, mock_open, patch
+
+from script.git.git_tool import (
+ CST_EL_GITHUB_TOKEN,
+ CST_FILE_SOURCE_REPO_ADDONS,
+ DEFAULT_PROJECT_NAME,
+ DEFAULT_REMOTE_URL,
+ DEFAULT_WEBSITE,
+ GitTool,
+ Struct,
+)
+
+
+class TestStruct(unittest.TestCase):
+ def test_basic_attributes(self):
+ s = Struct(a=1, b="hello")
+ self.assertEqual(s.a, 1)
+ self.assertEqual(s.b, "hello")
+
+ def test_empty_struct(self):
+ s = Struct()
+ self.assertEqual(s.__dict__, {})
+
+ def test_override_existing(self):
+ s = Struct(x=10)
+ self.assertEqual(s.x, 10)
+
+
+class TestGetUrl(unittest.TestCase):
+ def test_https_to_git(self):
+ url, url_https, url_git = GitTool.get_url(
+ "https://github.com/OCA/server-tools.git"
+ )
+ self.assertEqual(url, "https://github.com/OCA/server-tools.git")
+ self.assertEqual(url_https, "https://github.com/OCA/server-tools.git")
+ self.assertEqual(url_git, "git@github.com:OCA/server-tools.git")
+
+ def test_git_to_https(self):
+ url, url_https, url_git = GitTool.get_url(
+ "git@github.com:OCA/server-tools.git"
+ )
+ self.assertEqual(url_https, "https://github.com/OCA/server-tools.git")
+ self.assertEqual(url_git, "git@github.com:OCA/server-tools.git")
+
+ def test_https_without_git_suffix(self):
+ url, url_https, url_git = GitTool.get_url(
+ "https://github.com/ERPLibre/ERPLibre"
+ )
+ self.assertEqual(url_https, "https://github.com/ERPLibre/ERPLibre")
+ self.assertEqual(url_git, "git@github.com:ERPLibre/ERPLibre")
+
+
+class TestGetTransformedRepoInfo(unittest.TestCase):
+ def setUp(self):
+ self.gt = GitTool()
+
+ def test_https_url_as_submodule(self):
+ result = self.gt.get_transformed_repo_info_from_url(
+ "https://github.com/OCA/server-tools.git",
+ repo_path=".",
+ get_obj=False,
+ )
+ self.assertEqual(result["organization"], "OCA")
+ self.assertEqual(result["repo_name"], "server-tools")
+ self.assertEqual(result["project_name"], "server-tools.git")
+ self.assertEqual(result["path"], "addons/OCA_server-tools")
+ self.assertTrue(result["is_submodule"])
+
+ def test_git_url_as_submodule(self):
+ result = self.gt.get_transformed_repo_info_from_url(
+ "git@github.com:ERPLibre/erplibre_addons.git",
+ repo_path=".",
+ get_obj=False,
+ )
+ self.assertEqual(result["organization"], "ERPLibre")
+ self.assertEqual(result["repo_name"], "erplibre_addons")
+
+ def test_not_submodule(self):
+ result = self.gt.get_transformed_repo_info_from_url(
+ "https://github.com/OCA/server-tools.git",
+ repo_path="/tmp/test",
+ get_obj=False,
+ is_submodule=False,
+ )
+ self.assertEqual(result["path"], "/tmp/test")
+ self.assertFalse(result["is_submodule"])
+
+ def test_get_obj_true_returns_struct(self):
+ result = self.gt.get_transformed_repo_info_from_url(
+ "https://github.com/OCA/web.git",
+ get_obj=True,
+ )
+ self.assertIsInstance(result, Struct)
+ self.assertEqual(result.organization, "OCA")
+
+ def test_organization_force(self):
+ result = self.gt.get_transformed_repo_info_from_url(
+ "https://github.com/OCA/server-tools.git",
+ get_obj=False,
+ organization_force="MyOrg",
+ )
+ self.assertEqual(result["organization"], "MyOrg")
+ self.assertEqual(result["original_organization"], "OCA")
+ self.assertIn("MyOrg", result["url_https"])
+
+ def test_custom_sub_path(self):
+ result = self.gt.get_transformed_repo_info_from_url(
+ "https://github.com/OCA/server-tools.git",
+ get_obj=False,
+ sub_path="custom",
+ )
+ self.assertEqual(result["path"], "custom/OCA_server-tools")
+
+ def test_empty_sub_path(self):
+ result = self.gt.get_transformed_repo_info_from_url(
+ "https://github.com/OCA/server-tools.git",
+ get_obj=False,
+ sub_path="",
+ )
+ self.assertEqual(result["path"], "server-tools")
+
+ def test_dot_sub_path(self):
+ result = self.gt.get_transformed_repo_info_from_url(
+ "https://github.com/OCA/server-tools.git",
+ get_obj=False,
+ sub_path=".",
+ )
+ self.assertEqual(result["path"], "server-tools")
+
+ def test_revision_and_clone_depth(self):
+ result = self.gt.get_transformed_repo_info_from_url(
+ "https://github.com/OCA/web.git",
+ get_obj=False,
+ revision="16.0",
+ clone_depth="1",
+ )
+ self.assertEqual(result["revision"], "16.0")
+ self.assertEqual(result["clone_depth"], "1")
+
+ def test_url_without_git_suffix(self):
+ result = self.gt.get_transformed_repo_info_from_url(
+ "https://github.com/OCA/server-tools",
+ get_obj=False,
+ )
+ self.assertEqual(result["repo_name"], "server-tools")
+
+
+class TestDefaultProperties(unittest.TestCase):
+ def test_default_project_name(self):
+ gt = GitTool()
+ self.assertEqual(gt.default_project_name, DEFAULT_PROJECT_NAME)
+
+ def test_default_website(self):
+ gt = GitTool()
+ self.assertEqual(gt.default_website, DEFAULT_WEBSITE)
+
+ def test_default_remote_url(self):
+ gt = GitTool()
+ self.assertEqual(gt.default_remote_url, DEFAULT_REMOTE_URL)
+
+ @patch(
+ "builtins.open",
+ mock_open(read_data="18.0"),
+ )
+ def test_odoo_version(self):
+ gt = GitTool()
+ self.assertEqual(gt.odoo_version, "18.0")
+
+ @patch(
+ "builtins.open",
+ mock_open(read_data="16.0"),
+ )
+ def test_odoo_version_long(self):
+ gt = GitTool()
+ self.assertEqual(gt.odoo_version_long, "odoo16.0")
+
+
+class TestStrInsert(unittest.TestCase):
+ def test_insert_middle(self):
+ result = GitTool.str_insert("abcdef", "XY", 3)
+ self.assertEqual(result, "abcXYdef")
+
+ def test_insert_beginning(self):
+ result = GitTool.str_insert("hello", "X", 0)
+ self.assertEqual(result, "Xhello")
+
+ def test_insert_end(self):
+ result = GitTool.str_insert("hello", "X", 5)
+ self.assertEqual(result, "helloX")
+
+
+class TestGetProjectConfig(unittest.TestCase):
+ def test_reads_github_token(self):
+ content = (
+ "#!/bin/bash\n"
+ 'EL_GITHUB_TOKEN="my_token_123"\n'
+ 'OTHER_VAR="value"\n'
+ )
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".sh", delete=False, dir="/tmp"
+ ) as f:
+ f.write(content)
+ f.flush()
+ tmpdir = os.path.dirname(f.name)
+ tmpname = os.path.basename(f.name)
+ try:
+ # We need env_var.sh in a directory
+ env_var_path = os.path.join(tmpdir, "env_var.sh")
+ os.rename(f.name, env_var_path)
+ result = GitTool.get_project_config(repo_path=tmpdir)
+ self.assertEqual(result[CST_EL_GITHUB_TOKEN], "my_token_123")
+ finally:
+ if os.path.exists(env_var_path):
+ os.unlink(env_var_path)
+
+
+class TestGetRepoInfoSubmodule(unittest.TestCase):
+ def test_parses_gitmodules(self):
+ gitmodules_content = (
+ '[submodule "addons/OCA_server-tools"]\n'
+ "\turl = https://github.com/OCA/server-tools.git\n"
+ "\tpath = addons/OCA_server-tools\n"
+ "\n"
+ '[submodule "addons/OCA_web"]\n'
+ "\turl = https://github.com/OCA/web.git\n"
+ "\tpath = addons/OCA_web\n"
+ )
+ gt = GitTool()
+ with tempfile.TemporaryDirectory() as tmpdir:
+ gitmodules_path = os.path.join(tmpdir, ".gitmodules")
+ with open(gitmodules_path, "w") as f:
+ f.write(gitmodules_content)
+
+ result = gt.get_repo_info_submodule(
+ repo_path=tmpdir, add_root=False
+ )
+ self.assertEqual(len(result), 2)
+ names = [r["name"] for r in result]
+ self.assertIn("addons/OCA_server-tools", names)
+ self.assertIn("addons/OCA_web", names)
+
+ def test_single_submodule(self):
+ gitmodules_content = (
+ '[submodule "addons/test"]\n'
+ "\turl = https://github.com/Test/repo.git\n"
+ "\tpath = addons/test\n"
+ )
+ gt = GitTool()
+ with tempfile.TemporaryDirectory() as tmpdir:
+ with open(os.path.join(tmpdir, ".gitmodules"), "w") as f:
+ f.write(gitmodules_content)
+
+ result = gt.get_repo_info_submodule(repo_path=tmpdir)
+ self.assertEqual(len(result), 1)
+ self.assertEqual(result[0]["name"], "addons/test")
+ self.assertIn("https://", result[0]["url_https"])
+ self.assertIn("git@", result[0]["url_git"])
+
+
+class TestGetManifestXmlInfo(unittest.TestCase):
+ def test_parses_manifest(self):
+ xml_content = """
+
+
+
+
+
+"""
+ gt = GitTool()
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".xml", delete=False
+ ) as f:
+ f.write(xml_content)
+ f.flush()
+ try:
+ dct_remote, dct_project, default_remote = (
+ gt.get_manifest_xml_info(filename=f.name)
+ )
+ self.assertIn("OCA", dct_remote)
+ self.assertIn("server-tools.git", dct_project)
+ self.assertEqual(default_remote["@remote"], "OCA")
+ finally:
+ os.unlink(f.name)
+
+ def test_empty_manifest(self):
+ xml_content = """
+
+"""
+ gt = GitTool()
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".xml", delete=False
+ ) as f:
+ f.write(xml_content)
+ f.flush()
+ try:
+ dct_remote, dct_project, default_remote = (
+ gt.get_manifest_xml_info(filename=f.name)
+ )
+ self.assertEqual(dct_remote, {})
+ self.assertEqual(dct_project, {})
+ self.assertIsNone(default_remote)
+ finally:
+ os.unlink(f.name)
+
+
+class TestConstants(unittest.TestCase):
+ def test_file_source_repo_addons(self):
+ self.assertEqual(CST_FILE_SOURCE_REPO_ADDONS, "source_repo_addons.csv")
+
+ def test_default_project_name(self):
+ self.assertEqual(DEFAULT_PROJECT_NAME, "ERPLibre")
+
+ def test_default_website(self):
+ self.assertEqual(DEFAULT_WEBSITE, "erplibre.ca")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_kill_process_by_port.py b/test/test_kill_process_by_port.py
new file mode 100644
index 0000000..d6e991f
--- /dev/null
+++ b/test/test_kill_process_by_port.py
@@ -0,0 +1,251 @@
+#!/usr/bin/env python3
+# © 2025 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+import unittest
+from unittest.mock import MagicMock, patch
+
+import psutil
+
+from script.process.kill_process_by_port import (
+ PROTECTED_NAMES,
+ STOP_PARENT_KILL,
+ choose_target,
+ find_listeners,
+ get_ancestry,
+ kill_process,
+ kill_tree,
+ proc_desc,
+)
+
+
+def _make_proc(pid, name="python", cmdline=None, username="user"):
+ """Create a mock psutil.Process."""
+ p = MagicMock(spec=psutil.Process)
+ p.pid = pid
+ p.name.return_value = name
+ p.cmdline.return_value = cmdline or [name]
+ p.username.return_value = username
+ return p
+
+
+class TestProcDesc(unittest.TestCase):
+ def test_normal_process(self):
+ p = _make_proc(123, "python", ["python", "app.py"])
+ result = proc_desc(p)
+ self.assertIn("pid=123", result)
+ self.assertIn("python app.py", result)
+ self.assertIn("user=user", result)
+
+ def test_empty_cmdline_uses_name(self):
+ p = _make_proc(42, "bash", [])
+ result = proc_desc(p)
+ self.assertIn("pid=42", result)
+ self.assertIn("name=bash", result)
+
+ def test_psutil_error_fallback(self):
+ p = MagicMock(spec=psutil.Process)
+ p.pid = 99
+ p.cmdline.side_effect = psutil.Error("gone")
+ result = proc_desc(p)
+ self.assertEqual(result, "pid=99")
+
+
+class TestGetAncestry(unittest.TestCase):
+ @patch("script.process.kill_process_by_port.psutil.Process")
+ def test_nonexistent_pid(self, mock_proc_cls):
+ mock_proc_cls.side_effect = psutil.NoSuchProcess(99999)
+ chain = get_ancestry(99999)
+ self.assertEqual(chain, [])
+
+ @patch("script.process.kill_process_by_port.psutil.Process")
+ def test_chain_stops_at_pid_1(self, mock_proc_cls):
+ p_child = MagicMock()
+ p_child.pid = 100
+ p_child.ppid.return_value = 1
+
+ p_init = MagicMock()
+ p_init.pid = 1
+
+ mock_proc_cls.side_effect = [p_child, p_init]
+ chain = get_ancestry(100)
+ self.assertEqual(len(chain), 2)
+ self.assertEqual(chain[0].pid, 100)
+ self.assertEqual(chain[1].pid, 1)
+
+ @patch("script.process.kill_process_by_port.psutil.Process")
+ def test_chain_stops_at_ppid_zero(self, mock_proc_cls):
+ p = MagicMock()
+ p.pid = 50
+ p.ppid.return_value = 0
+
+ mock_proc_cls.return_value = p
+ chain = get_ancestry(50)
+ self.assertEqual(len(chain), 1)
+ self.assertEqual(chain[0].pid, 50)
+
+
+class TestChooseTarget(unittest.TestCase):
+ def test_empty_chain_returns_none(self):
+ result = choose_target([], 1)
+ self.assertIsNone(result)
+
+ def test_stops_at_run_sh(self):
+ p1 = _make_proc(100, "python", ["python", "odoo-bin"])
+ p2 = _make_proc(99, "bash", ["./run.sh"])
+ p3 = _make_proc(1, "systemd", ["systemd"])
+ chain = [p1, p2, p3]
+ target, lst = choose_target(chain, 1)
+ self.assertEqual(target.pid, 99)
+
+ def test_stops_at_odoo_bin_sh(self):
+ p1 = _make_proc(200, "python", ["python", "odoo-bin"])
+ p2 = _make_proc(199, "bash", ["./odoo_bin.sh"])
+ chain = [p1, p2]
+ target, lst = choose_target(chain, 1)
+ self.assertEqual(target.pid, 199)
+
+ def test_no_stop_marker_uses_nb_parent(self):
+ p1 = _make_proc(300, "python", ["python"])
+ p2 = _make_proc(299, "bash", ["bash"])
+ p3 = _make_proc(1, "systemd", ["systemd"])
+ chain = [p1, p2, p3]
+ target, lst = choose_target(chain, 2)
+ self.assertEqual(target.pid, 299)
+
+
+class TestKillProcess(unittest.TestCase):
+ def test_terminate_called(self):
+ p = _make_proc(10)
+ kill_process(p, force=False)
+ p.terminate.assert_called_once()
+ p.kill.assert_not_called()
+
+ def test_kill_called_with_force(self):
+ p = _make_proc(10)
+ kill_process(p, force=True)
+ p.kill.assert_called_once()
+ p.terminate.assert_not_called()
+
+
+class TestKillTree(unittest.TestCase):
+ @patch("script.process.kill_process_by_port.psutil.wait_procs")
+ def test_kills_children_then_root(self, mock_wait):
+ root = _make_proc(10, "root_proc")
+ child1 = _make_proc(11, "child1")
+ child2 = _make_proc(12, "child2")
+ root.children.return_value = [child1, child2]
+ mock_wait.return_value = (
+ [root, child1, child2],
+ [],
+ )
+
+ alive = kill_tree(root, force=False)
+ child1.terminate.assert_called_once()
+ child2.terminate.assert_called_once()
+ root.terminate.assert_called_once()
+ self.assertEqual(alive, [])
+
+ @patch("script.process.kill_process_by_port.psutil.wait_procs")
+ def test_returns_alive_processes(self, mock_wait):
+ root = _make_proc(10)
+ root.children.return_value = []
+ mock_wait.return_value = ([], [root])
+
+ alive = kill_tree(root, force=True)
+ self.assertEqual(len(alive), 1)
+
+ @patch("script.process.kill_process_by_port.psutil.wait_procs")
+ def test_handles_psutil_error_on_child(self, mock_wait):
+ root = _make_proc(10)
+ child = _make_proc(11)
+ child.terminate.side_effect = psutil.NoSuchProcess(11)
+ root.children.return_value = [child]
+ mock_wait.return_value = ([root], [])
+
+ alive = kill_tree(root, force=False)
+ self.assertEqual(alive, [])
+
+
+class TestFindListeners(unittest.TestCase):
+ @patch("script.process.kill_process_by_port.psutil.net_connections")
+ def test_finds_listening_pid(self, mock_conns):
+ conn = MagicMock()
+ conn.laddr = MagicMock()
+ conn.laddr.port = 8069
+ conn.status = psutil.CONN_LISTEN
+ conn.pid = 1234
+ mock_conns.return_value = [conn]
+
+ result = find_listeners(8069)
+ self.assertEqual(result, [1234])
+
+ @patch("script.process.kill_process_by_port.psutil.net_connections")
+ def test_ignores_different_port(self, mock_conns):
+ conn = MagicMock()
+ conn.laddr = MagicMock()
+ conn.laddr.port = 9999
+ conn.status = psutil.CONN_LISTEN
+ conn.pid = 1234
+ mock_conns.return_value = [conn]
+
+ result = find_listeners(8069)
+ self.assertEqual(result, [])
+
+ @patch("script.process.kill_process_by_port.psutil.net_connections")
+ def test_ignores_non_listen_status(self, mock_conns):
+ conn = MagicMock()
+ conn.laddr = MagicMock()
+ conn.laddr.port = 8069
+ conn.status = psutil.CONN_ESTABLISHED
+ conn.pid = 1234
+ mock_conns.return_value = [conn]
+
+ result = find_listeners(8069)
+ self.assertEqual(result, [])
+
+ @patch("script.process.kill_process_by_port.psutil.net_connections")
+ def test_no_connections(self, mock_conns):
+ mock_conns.return_value = []
+ result = find_listeners(8069)
+ self.assertEqual(result, [])
+
+ @patch("script.process.kill_process_by_port.psutil.net_connections")
+ def test_deduplicates_pids(self, mock_conns):
+ conn1 = MagicMock()
+ conn1.laddr = MagicMock()
+ conn1.laddr.port = 8069
+ conn1.status = psutil.CONN_LISTEN
+ conn1.pid = 100
+ conn2 = MagicMock()
+ conn2.laddr = MagicMock()
+ conn2.laddr.port = 8069
+ conn2.status = psutil.CONN_LISTEN
+ conn2.pid = 100
+ mock_conns.return_value = [conn1, conn2]
+
+ result = find_listeners(8069)
+ self.assertEqual(result, [100])
+
+
+class TestProtectedNames(unittest.TestCase):
+ def test_systemd_is_protected(self):
+ self.assertIn("systemd", PROTECTED_NAMES)
+
+ def test_gnome_shell_is_protected(self):
+ self.assertIn("gnome-shell", PROTECTED_NAMES)
+
+ def test_sshd_is_protected(self):
+ self.assertIn("sshd", PROTECTED_NAMES)
+
+
+class TestStopParentKill(unittest.TestCase):
+ def test_contains_run_sh(self):
+ self.assertIn("./run.sh", STOP_PARENT_KILL)
+
+ def test_contains_odoo_bin_sh(self):
+ self.assertIn("./odoo_bin.sh", STOP_PARENT_KILL)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_todo.py b/test/test_todo.py
new file mode 100644
index 0000000..b2ef961
--- /dev/null
+++ b/test/test_todo.py
@@ -0,0 +1,332 @@
+#!/usr/bin/env python3
+# © 2025 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+import json
+import os
+import tempfile
+import unittest
+from unittest.mock import MagicMock, patch
+
+from script.todo.todo import (
+ ANDROID_DIR,
+ CONFIG_FILE,
+ CONFIG_OVERRIDE_FILE,
+ ENABLE_CRASH,
+ GRADLE_FILE,
+ INSTALLED_ODOO_VERSION_FILE,
+ LOGO_ASCII_FILE,
+ MOBILE_HOME_PATH,
+ ODOO_VERSION_FILE,
+ STRINGS_FILE,
+ TODO,
+ VERSION_DATA_FILE,
+ cst_venv_erplibre,
+ file_error_path,
+)
+
+
+class TestTODOInit(unittest.TestCase):
+ def test_initial_attributes(self):
+ todo = TODO()
+ self.assertIsNone(todo.dir_path)
+ self.assertIsNone(todo.kdbx)
+ self.assertIsNone(todo.file_path)
+ self.assertIsNotNone(todo.config_file)
+ self.assertIsNotNone(todo.execute)
+
+
+class TestFillHelpInfo(unittest.TestCase):
+ def setUp(self):
+ self.todo = TODO()
+
+ @patch("script.todo.todo.t")
+ def test_basic_help_info(self, mock_t):
+ mock_t.side_effect = lambda k: {
+ "command": "Command:",
+ "back": "Back",
+ }.get(k, k)
+ lst_choice = [
+ {"prompt_description": "Option A"},
+ {"prompt_description": "Option B"},
+ ]
+ result = self.todo.fill_help_info(lst_choice)
+ self.assertIn("[1] Option A", result)
+ self.assertIn("[2] Option B", result)
+ self.assertIn("[0] Back", result)
+
+ @patch("script.todo.todo.t")
+ def test_with_prompt_description_key(self, mock_t):
+ mock_t.side_effect = lambda k: {
+ "command": "Command:",
+ "back": "Back",
+ "my_key": "Translated Description",
+ }.get(k, k)
+ lst_choice = [
+ {
+ "prompt_description": "fallback",
+ "prompt_description_key": "my_key",
+ },
+ ]
+ result = self.todo.fill_help_info(lst_choice)
+ self.assertIn("[1] Translated Description", result)
+
+ @patch("script.todo.todo.t")
+ def test_empty_list(self, mock_t):
+ mock_t.side_effect = lambda k: {
+ "command": "Command:",
+ "back": "Back",
+ }.get(k, k)
+ result = self.todo.fill_help_info([])
+ self.assertIn("Command:", result)
+ self.assertIn("[0] Back", result)
+ self.assertNotIn("[1]", result)
+
+
+class TestGetOdooVersion(unittest.TestCase):
+ def test_reads_version_data(self):
+ version_data = {
+ "odoo18.0_python3.12.10": {
+ "odoo_version": "18.0",
+ "python_version": "3.12.10",
+ "default": True,
+ "is_deprecated": False,
+ },
+ "odoo16.0_python3.10.18": {
+ "odoo_version": "16.0",
+ "python_version": "3.10.18",
+ "default": False,
+ "is_deprecated": False,
+ },
+ }
+ todo = TODO()
+ with tempfile.TemporaryDirectory() as tmpdir:
+ version_file = os.path.join(tmpdir, "version.json")
+ with open(version_file, "w") as f:
+ json.dump(version_data, f)
+
+ odoo_version_file = os.path.join(tmpdir, ".odoo-version")
+ with open(odoo_version_file, "w") as f:
+ f.write("18.0")
+
+ with patch(
+ "script.todo.todo.VERSION_DATA_FILE", version_file
+ ), patch(
+ "script.todo.todo.INSTALLED_ODOO_VERSION_FILE",
+ os.path.join(tmpdir, "nonexistent.txt"),
+ ), patch(
+ "script.todo.todo.ODOO_VERSION_FILE",
+ odoo_version_file,
+ ):
+ lst_version, lst_installed, odoo_current = (
+ todo.get_odoo_version()
+ )
+
+ self.assertEqual(len(lst_version), 2)
+ self.assertEqual(odoo_current, "odoo18.0")
+ # Check erplibre_version was added
+ names = [v["erplibre_version"] for v in lst_version]
+ self.assertIn("odoo18.0_python3.12.10", names)
+ self.assertIn("odoo16.0_python3.10.18", names)
+
+ def test_installed_versions_read(self):
+ version_data = {
+ "odoo18.0_python3.12.10": {
+ "odoo_version": "18.0",
+ "python_version": "3.12.10",
+ "default": True,
+ "is_deprecated": False,
+ },
+ }
+ todo = TODO()
+ with tempfile.TemporaryDirectory() as tmpdir:
+ version_file = os.path.join(tmpdir, "version.json")
+ with open(version_file, "w") as f:
+ json.dump(version_data, f)
+
+ installed_file = os.path.join(tmpdir, "installed.txt")
+ with open(installed_file, "w") as f:
+ f.write("odoo18.0\nodoo16.0\n")
+
+ with patch(
+ "script.todo.todo.VERSION_DATA_FILE", version_file
+ ), patch(
+ "script.todo.todo.INSTALLED_ODOO_VERSION_FILE",
+ installed_file,
+ ), patch(
+ "script.todo.todo.ODOO_VERSION_FILE",
+ os.path.join(tmpdir, "nonexistent"),
+ ):
+ lst_version, lst_installed, odoo_current = (
+ todo.get_odoo_version()
+ )
+
+ self.assertEqual(lst_installed, ["odoo16.0", "odoo18.0"])
+ self.assertIsNone(odoo_current)
+
+ def test_no_version_data_raises(self):
+ todo = TODO()
+ with tempfile.TemporaryDirectory() as tmpdir:
+ version_file = os.path.join(tmpdir, "empty.json")
+ with open(version_file, "w") as f:
+ json.dump({}, f)
+
+ with patch("script.todo.todo.VERSION_DATA_FILE", version_file):
+ with self.assertRaises(Exception):
+ todo.get_odoo_version()
+
+
+class TestOnDirSelected(unittest.TestCase):
+ @patch("script.todo.todo.todo_file_browser", create=True)
+ def test_sets_dir_path(self, mock_browser):
+ todo = TODO()
+ todo.on_dir_selected("/some/path")
+ self.assertEqual(todo.dir_path, "/some/path")
+
+
+class TestExecuteFromConfiguration(unittest.TestCase):
+ def test_with_command(self):
+ todo = TODO()
+ todo.execute = MagicMock()
+ dct = {"command": "./run.sh"}
+ todo.execute_from_configuration(dct)
+ todo.execute.exec_command_live.assert_called()
+
+ def test_with_makefile_cmd(self):
+ todo = TODO()
+ todo.execute = MagicMock()
+ todo.execute.exec_command_live.return_value = 0
+ dct = {"makefile_cmd": "run_test"}
+ todo.execute_from_configuration(dct)
+ call_args = todo.execute.exec_command_live.call_args
+ self.assertIn("make run_test", call_args[0][0])
+
+ def test_makefile_cmd_ignored_when_flag(self):
+ todo = TODO()
+ todo.execute = MagicMock()
+ dct = {"makefile_cmd": "run_test"}
+ todo.execute_from_configuration(dct, ignore_makefile=True)
+ todo.execute.exec_command_live.assert_not_called()
+
+ def test_with_callback(self):
+ todo = TODO()
+ todo.execute = MagicMock()
+ callback = MagicMock()
+ dct = {"callback": callback}
+ todo.execute_from_configuration(dct)
+ callback.assert_called_once_with(dct)
+
+ def test_makefile_error_stops_execution(self):
+ todo = TODO()
+ todo.execute = MagicMock()
+ todo.execute.exec_command_live.return_value = 1
+ callback = MagicMock()
+ dct = {"makefile_cmd": "broken", "callback": callback}
+ todo.execute_from_configuration(dct)
+ callback.assert_not_called()
+
+
+class TestConstants(unittest.TestCase):
+ def test_config_file_path(self):
+ self.assertEqual(CONFIG_FILE, "./script/todo/todo.json")
+
+ def test_config_override_path(self):
+ self.assertEqual(CONFIG_OVERRIDE_FILE, "./private/todo/todo.json")
+
+ def test_logo_path(self):
+ self.assertEqual(LOGO_ASCII_FILE, "./script/todo/logo_ascii.txt")
+
+ def test_venv_erplibre(self):
+ self.assertEqual(cst_venv_erplibre, ".venv.erplibre")
+
+ def test_file_error_path(self):
+ self.assertEqual(file_error_path, ".erplibre.error.txt")
+
+ def test_version_data_file(self):
+ self.assertEqual(
+ VERSION_DATA_FILE,
+ os.path.join("conf", "supported_version_erplibre.json"),
+ )
+
+ def test_mobile_paths(self):
+ self.assertEqual(ANDROID_DIR, "android")
+ self.assertIn("mobile", MOBILE_HOME_PATH)
+
+
+class TestDeployGitServer(unittest.TestCase):
+ def test_local_mode(self):
+ todo = TODO()
+ todo.execute = MagicMock()
+ todo._deploy_git_server(production_ready=False, action="init")
+ cmd = todo.execute.exec_command_live.call_args[0][0]
+ self.assertIn("--action init", cmd)
+ self.assertNotIn("--production-ready", cmd)
+
+ def test_production_mode(self):
+ todo = TODO()
+ todo.execute = MagicMock()
+ todo._deploy_git_server(production_ready=True, action="all")
+ cmd = todo.execute.exec_command_live.call_args[0][0]
+ self.assertIn("--production-ready", cmd)
+ self.assertIn("--action all", cmd)
+
+
+class TestProcessKillGitDaemon(unittest.TestCase):
+ def test_calls_pkill(self):
+ todo = TODO()
+ todo.execute = MagicMock()
+ todo.process_kill_git_daemon()
+ cmd = todo.execute.exec_command_live.call_args[0][0]
+ self.assertIn("pkill", cmd)
+ self.assertIn("git daemon", cmd)
+
+
+class TestExecuteUnitTests(unittest.TestCase):
+ def test_success_path(self):
+ todo = TODO()
+ todo.execute = MagicMock()
+ todo.execute.exec_command_live.return_value = (0, ["OK"])
+ with patch("builtins.print") as mock_print:
+ todo.execute_unit_tests()
+ cmd = todo.execute.exec_command_live.call_args[0][0]
+ self.assertIn("unittest discover", cmd)
+
+ def test_failure_path(self):
+ todo = TODO()
+ todo.execute = MagicMock()
+ todo.execute.exec_command_live.return_value = (1, ["FAIL"])
+ with patch("builtins.print") as mock_print:
+ todo.execute_unit_tests()
+ # Verify it was called - error handling path
+
+
+class TestKdbxGetExtraCommandUser(unittest.TestCase):
+ def test_empty_kdbx_key(self):
+ todo = TODO()
+ result = todo.kdbx_get_extra_command_user("")
+ self.assertEqual(result, "")
+
+ def test_none_kdbx_key(self):
+ todo = TODO()
+ result = todo.kdbx_get_extra_command_user(None)
+ self.assertEqual(result, "")
+
+ def test_kdbx_not_available(self):
+ todo = TODO()
+ todo.get_kdbx = MagicMock(return_value=None)
+ result = todo.kdbx_get_extra_command_user("some_key")
+ self.assertEqual(result, "")
+
+
+class TestSetupClaudeCommit(unittest.TestCase):
+ def test_existing_file_skips(self):
+ todo = TODO()
+ with patch("os.path.exists", return_value=True), patch(
+ "builtins.print"
+ ) as mock_print:
+ todo._setup_claude_commit()
+ # Should print exists message without asking for input
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_todo_i18n.py b/test/test_todo_i18n.py
new file mode 100644
index 0000000..64ff1ee
--- /dev/null
+++ b/test/test_todo_i18n.py
@@ -0,0 +1,244 @@
+#!/usr/bin/env python3
+# © 2025 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+import os
+import tempfile
+import unittest
+from unittest.mock import patch
+
+from script.todo import todo_i18n
+
+
+class TestTranslations(unittest.TestCase):
+ """Test TRANSLATIONS dictionary integrity."""
+
+ def test_all_entries_have_fr_and_en(self):
+ for key, entry in todo_i18n.TRANSLATIONS.items():
+ self.assertIn("fr", entry, f"Key '{key}' missing 'fr' translation")
+ self.assertIn("en", entry, f"Key '{key}' missing 'en' translation")
+
+ def test_no_empty_translations(self):
+ for key, entry in todo_i18n.TRANSLATIONS.items():
+ for lang in ("fr", "en"):
+ self.assertTrue(
+ len(entry[lang]) > 0,
+ f"Key '{key}' has empty '{lang}' translation",
+ )
+
+ def test_translations_not_empty(self):
+ self.assertGreater(len(todo_i18n.TRANSLATIONS), 0)
+
+
+class TestT(unittest.TestCase):
+ """Test t() translation function."""
+
+ def setUp(self):
+ todo_i18n._current_lang = None
+
+ def tearDown(self):
+ todo_i18n._current_lang = None
+
+ def test_returns_french_when_lang_fr(self):
+ todo_i18n.set_lang("fr")
+ result = todo_i18n.t("menu_quit")
+ self.assertEqual(result, "Quitter")
+
+ def test_returns_english_when_lang_en(self):
+ todo_i18n.set_lang("en")
+ result = todo_i18n.t("menu_quit")
+ self.assertEqual(result, "Quit")
+
+ def test_unknown_key_returns_key(self):
+ todo_i18n.set_lang("fr")
+ result = todo_i18n.t("nonexistent_key_xyz")
+ self.assertEqual(result, "nonexistent_key_xyz")
+
+ def test_fallback_to_fr_if_lang_missing(self):
+ todo_i18n.set_lang("de")
+ result = todo_i18n.t("menu_quit")
+ self.assertEqual(result, "Quitter")
+
+
+class TestGetLang(unittest.TestCase):
+ """Test get_lang() function."""
+
+ def setUp(self):
+ todo_i18n._current_lang = None
+
+ def tearDown(self):
+ todo_i18n._current_lang = None
+
+ def test_returns_cached_lang(self):
+ todo_i18n._current_lang = "en"
+ result = todo_i18n.get_lang()
+ self.assertEqual(result, "en")
+
+ def test_reads_from_env_var_sh(self):
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".sh", delete=False
+ ) as f:
+ f.write('EL_LANG="en"\n')
+ f.flush()
+ try:
+ with patch.object(
+ todo_i18n, "CONFIG_OVERRIDE_PRIVATE_FILE", f.name
+ ):
+ result = todo_i18n.get_lang()
+ self.assertEqual(result, "en")
+ finally:
+ os.unlink(f.name)
+
+ def test_reads_unquoted_lang(self):
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".sh", delete=False
+ ) as f:
+ f.write("EL_LANG=fr\n")
+ f.flush()
+ try:
+ with patch.object(
+ todo_i18n, "CONFIG_OVERRIDE_PRIVATE_FILE", f.name
+ ):
+ result = todo_i18n.get_lang()
+ self.assertEqual(result, "fr")
+ finally:
+ os.unlink(f.name)
+
+ def test_env_variable_fallback(self):
+ with patch.object(
+ todo_i18n,
+ "CONFIG_OVERRIDE_PRIVATE_FILE",
+ "/nonexistent/path",
+ ), patch.dict(os.environ, {"EL_LANG": "en"}):
+ result = todo_i18n.get_lang()
+ self.assertEqual(result, "en")
+
+ def test_default_is_fr(self):
+ with patch.object(
+ todo_i18n,
+ "CONFIG_OVERRIDE_PRIVATE_FILE",
+ "/nonexistent/path",
+ ), patch.dict(os.environ, {}, clear=True):
+ result = todo_i18n.get_lang()
+ self.assertEqual(result, "fr")
+
+ def test_invalid_lang_in_file_falls_through(self):
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".sh", delete=False
+ ) as f:
+ f.write('EL_LANG="de"\n')
+ f.flush()
+ try:
+ with patch.object(
+ todo_i18n, "CONFIG_OVERRIDE_PRIVATE_FILE", f.name
+ ), patch.dict(os.environ, {}, clear=True):
+ result = todo_i18n.get_lang()
+ self.assertEqual(result, "fr")
+ finally:
+ os.unlink(f.name)
+
+
+class TestSetLang(unittest.TestCase):
+ """Test set_lang() function."""
+
+ def setUp(self):
+ todo_i18n._current_lang = None
+
+ def tearDown(self):
+ todo_i18n._current_lang = None
+
+ def test_sets_current_lang(self):
+ todo_i18n.set_lang("en")
+ self.assertEqual(todo_i18n._current_lang, "en")
+
+ def test_persists_to_file_update(self):
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".sh", delete=False
+ ) as f:
+ f.write('EL_LANG="fr"\nOTHER=value\n')
+ f.flush()
+ try:
+ with patch.object(
+ todo_i18n, "CONFIG_OVERRIDE_PRIVATE_FILE", f.name
+ ):
+ todo_i18n.set_lang("en")
+ with open(f.name) as rf:
+ content = rf.read()
+ self.assertIn('EL_LANG="en"', content)
+ self.assertIn("OTHER=value", content)
+ finally:
+ os.unlink(f.name)
+
+ def test_persists_to_file_append(self):
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".sh", delete=False
+ ) as f:
+ f.write("SOME_VAR=123\n")
+ f.flush()
+ try:
+ with patch.object(
+ todo_i18n, "CONFIG_OVERRIDE_PRIVATE_FILE", f.name
+ ):
+ todo_i18n.set_lang("en")
+ with open(f.name) as rf:
+ content = rf.read()
+ self.assertIn('EL_LANG="en"', content)
+ self.assertIn("SOME_VAR=123", content)
+ finally:
+ os.unlink(f.name)
+
+ def test_nonexistent_file_no_crash(self):
+ with patch.object(
+ todo_i18n,
+ "CONFIG_OVERRIDE_PRIVATE_FILE",
+ "/nonexistent/path",
+ ):
+ todo_i18n.set_lang("en")
+ self.assertEqual(todo_i18n._current_lang, "en")
+
+
+class TestLangIsConfigured(unittest.TestCase):
+ """Test lang_is_configured() function."""
+
+ def test_returns_true_when_configured(self):
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".sh", delete=False
+ ) as f:
+ f.write('EL_LANG="fr"\n')
+ f.flush()
+ try:
+ with patch.object(
+ todo_i18n, "CONFIG_OVERRIDE_PRIVATE_FILE", f.name
+ ):
+ result = todo_i18n.lang_is_configured()
+ self.assertTrue(result)
+ finally:
+ os.unlink(f.name)
+
+ def test_returns_false_when_not_configured(self):
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".sh", delete=False
+ ) as f:
+ f.write("SOME_VAR=123\n")
+ f.flush()
+ try:
+ with patch.object(
+ todo_i18n, "CONFIG_OVERRIDE_PRIVATE_FILE", f.name
+ ):
+ result = todo_i18n.lang_is_configured()
+ self.assertFalse(result)
+ finally:
+ os.unlink(f.name)
+
+ def test_returns_false_when_file_missing(self):
+ with patch.object(
+ todo_i18n,
+ "CONFIG_OVERRIDE_PRIVATE_FILE",
+ "/nonexistent/path",
+ ):
+ result = todo_i18n.lang_is_configured()
+ self.assertFalse(result)
+
+
+if __name__ == "__main__":
+ unittest.main()