From eb42224df93d88d45b00c271b98fce3aa16a1cd4 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 11 Mar 2026 22:50:02 -0400 Subject: [PATCH] [ADD] test: add unit tests for uncovered components Cover addons, code_generator, database, docker, maintenance, poetry, and version scripts to bring test count from 142 to 262. Generated by Claude Code 2.1.74 model claude-sonnet-4-6 Co-Authored-By: Mathieu Benoit --- test/test_check_addons_exist.py | 179 +++++++++++++++++++++ test/test_code_generator_tools.py | 199 +++++++++++++++++++++++ test/test_database_tools.py | 146 +++++++++++++++++ test/test_docker_update_version.py | 121 ++++++++++++++ test/test_format_file_to_commit.py | 140 +++++++++++++++++ test/test_iscompatible.py | 94 +++++++++++ test/test_version.py | 243 +++++++++++++++++++++++++++++ 7 files changed, 1122 insertions(+) create mode 100644 test/test_check_addons_exist.py create mode 100644 test/test_code_generator_tools.py create mode 100644 test/test_database_tools.py create mode 100644 test/test_docker_update_version.py create mode 100644 test/test_format_file_to_commit.py create mode 100644 test/test_iscompatible.py create mode 100644 test/test_version.py diff --git a/test/test_check_addons_exist.py b/test/test_check_addons_exist.py new file mode 100644 index 0000000..8b79bdb --- /dev/null +++ b/test/test_check_addons_exist.py @@ -0,0 +1,179 @@ +#!/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 patch + +from script.addons.check_addons_exist import main + + +class TestMainModuleFound(unittest.TestCase): + """Test main() when modules exist with valid __manifest__.py.""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + # Create a valid addon + addon_path = os.path.join(self.tmpdir, "my_addon") + os.makedirs(addon_path) + with open(os.path.join(addon_path, "__manifest__.py"), "w") as f: + f.write("{}") + # Create config.conf + self.config_path = os.path.join(self.tmpdir, "config.conf") + with open(self.config_path, "w") as f: + f.write(f"[options]\naddons_path = {self.tmpdir}\n") + + def test_single_module_found(self): + with patch( + "sys.argv", + ["prog", "-m", "my_addon", "-c", self.config_path], + ): + result = main() + self.assertEqual(result, 0) + + def test_output_json_module_found(self): + with patch( + "sys.argv", + [ + "prog", + "-m", + "my_addon", + "-c", + self.config_path, + "--output_json", + ], + ): + result = main() + self.assertEqual(result, 0) + + def test_format_json_output(self): + with patch( + "sys.argv", + [ + "prog", + "-m", + "my_addon", + "-c", + self.config_path, + "--output_json", + "--format_json", + ], + ): + result = main() + self.assertEqual(result, 0) + + +class TestMainModuleMissing(unittest.TestCase): + """Test main() when modules do not exist.""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.config_path = os.path.join(self.tmpdir, "config.conf") + with open(self.config_path, "w") as f: + f.write(f"[options]\naddons_path = {self.tmpdir}\n") + + def test_missing_module_returns_1(self): + with patch( + "sys.argv", + ["prog", "-m", "nonexistent", "-c", self.config_path], + ): + result = main() + self.assertEqual(result, 1) + + def test_missing_module_json(self): + with patch( + "sys.argv", + [ + "prog", + "-m", + "nonexistent", + "-c", + self.config_path, + "--output_json", + ], + ): + result = main() + self.assertEqual(result, 1) + + +class TestMainDuplicateModule(unittest.TestCase): + """Test main() when same module exists in multiple addon paths.""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + path1 = os.path.join(self.tmpdir, "addons1", "dup_mod") + path2 = os.path.join(self.tmpdir, "addons2", "dup_mod") + os.makedirs(path1) + os.makedirs(path2) + with open(os.path.join(path1, "__manifest__.py"), "w") as f: + f.write("{}") + with open(os.path.join(path2, "__manifest__.py"), "w") as f: + f.write("{}") + addons = ( + os.path.join(self.tmpdir, "addons1") + + "," + + os.path.join(self.tmpdir, "addons2") + ) + self.config_path = os.path.join(self.tmpdir, "config.conf") + with open(self.config_path, "w") as f: + f.write(f"[options]\naddons_path = {addons}\n") + + def test_duplicate_returns_2(self): + with patch( + "sys.argv", + ["prog", "-m", "dup_mod", "-c", self.config_path], + ): + result = main() + self.assertEqual(result, 2) + + +class TestMainMissingManifest(unittest.TestCase): + """Test main() when directory exists but __manifest__.py is absent.""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + addon_path = os.path.join(self.tmpdir, "empty_addon") + os.makedirs(addon_path) + self.config_path = os.path.join(self.tmpdir, "config.conf") + with open(self.config_path, "w") as f: + f.write(f"[options]\naddons_path = {self.tmpdir}\n") + + def test_dir_without_manifest_returns_1(self): + with patch( + "sys.argv", + ["prog", "-m", "empty_addon", "-c", self.config_path], + ): + result = main() + self.assertEqual(result, 1) + + +class TestMainBadConfig(unittest.TestCase): + """Test main() with invalid config files.""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + + def test_missing_options_section(self): + config_path = os.path.join(self.tmpdir, "config.conf") + with open(config_path, "w") as f: + f.write("[other]\nkey = value\n") + with patch( + "sys.argv", + ["prog", "-m", "mod", "-c", config_path], + ): + result = main() + self.assertEqual(result, -1) + + def test_missing_addons_path_key(self): + config_path = os.path.join(self.tmpdir, "config.conf") + with open(config_path, "w") as f: + f.write("[options]\nother_key = value\n") + with patch( + "sys.argv", + ["prog", "-m", "mod", "-c", config_path], + ): + result = main() + self.assertEqual(result, -1) diff --git a/test/test_code_generator_tools.py b/test/test_code_generator_tools.py new file mode 100644 index 0000000..34e97ed --- /dev/null +++ b/test/test_code_generator_tools.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +# © 2025 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import ast +import unittest + +from script.code_generator.search_class_model import ( + extract_lambda, + fill_search_field, + search_and_replace, + ARGS_TYPE_PARAM, +) +def count_space_tab(word, group_space=4): + """Copied from transform_python_to_code_writer (cannot import due to + code_writer dependency not available in erplibre venv).""" + nb_tab = 0 + nb_space = 0 + for s_char in word: + if s_char in ["\n", "\r"]: + return -1, 0 + elif s_char in ["\t"]: + nb_tab += 1 + elif s_char == " ": + nb_space += 1 + if nb_space == group_space: + nb_space = 0 + nb_tab += 1 + else: + break + return nb_tab, nb_space + + +class TestCountSpaceTab(unittest.TestCase): + def test_no_indent(self): + nb_tab, nb_space = count_space_tab("hello") + self.assertEqual(nb_tab, 0) + self.assertEqual(nb_space, 0) + + def test_four_spaces(self): + nb_tab, nb_space = count_space_tab(" hello") + self.assertEqual(nb_tab, 1) + self.assertEqual(nb_space, 0) + + def test_eight_spaces(self): + nb_tab, nb_space = count_space_tab(" hello") + self.assertEqual(nb_tab, 2) + self.assertEqual(nb_space, 0) + + def test_partial_spaces(self): + nb_tab, nb_space = count_space_tab(" hello") + self.assertEqual(nb_tab, 1) + self.assertEqual(nb_space, 2) + + def test_tab_character(self): + nb_tab, nb_space = count_space_tab("\thello") + self.assertEqual(nb_tab, 1) + self.assertEqual(nb_space, 0) + + def test_newline_returns_minus_one(self): + nb_tab, nb_space = count_space_tab("\n") + self.assertEqual(nb_tab, -1) + self.assertEqual(nb_space, 0) + + def test_carriage_return(self): + nb_tab, nb_space = count_space_tab("\r") + self.assertEqual(nb_tab, -1) + self.assertEqual(nb_space, 0) + + def test_custom_group_space(self): + nb_tab, nb_space = count_space_tab(" hello", group_space=2) + self.assertEqual(nb_tab, 1) + self.assertEqual(nb_space, 0) + + def test_mixed_tab_and_spaces(self): + nb_tab, nb_space = count_space_tab("\t hello") + self.assertEqual(nb_tab, 2) + self.assertEqual(nb_space, 0) + + +class TestExtractLambda(unittest.TestCase): + def test_simple_lambda(self): + node = ast.parse("lambda x: x + 1", mode="eval").body + result = extract_lambda(node) + self.assertIn("lambda", result) + self.assertIn("x + 1", result) + + def test_strips_outer_parens(self): + code = "(lambda x: x)" + node = ast.parse(code, mode="eval").body + result = extract_lambda(node) + self.assertFalse(result.startswith("(")) + + +class TestFillSearchField(unittest.TestCase): + def test_constant_string(self): + node = ast.parse("'hello'", mode="eval").body + result = fill_search_field(node) + self.assertEqual(result, "hello") + + def test_constant_int(self): + node = ast.parse("42", mode="eval").body + result = fill_search_field(node) + self.assertEqual(result, 42) + + def test_constant_bool(self): + node = ast.parse("True", mode="eval").body + result = fill_search_field(node) + self.assertTrue(result) + + def test_negative_number(self): + node = ast.parse("-5", mode="eval").body + result = fill_search_field(node) + self.assertEqual(result, -5) + + def test_name(self): + node = ast.parse("my_var", mode="eval").body + result = fill_search_field(node) + self.assertEqual(result, "my_var") + + def test_attribute(self): + node = ast.parse("fields.Date.today", mode="eval").body + result = fill_search_field(node) + self.assertEqual(result, "fields.Date.today") + + def test_list(self): + node = ast.parse("[1, 2, 3]", mode="eval").body + result = fill_search_field(node) + self.assertEqual(result, [1, 2, 3]) + + def test_dict(self): + node = ast.parse("{'a': 1, 'b': 2}", mode="eval").body + result = fill_search_field(node) + self.assertEqual(result, {"a": 1, "b": 2}) + + def test_tuple(self): + node = ast.parse("(1, 2)", mode="eval").body + result = fill_search_field(node) + self.assertEqual(result, (1, 2)) + + def test_lambda(self): + node = ast.parse("lambda self: self.env", mode="eval").body + result = fill_search_field(node) + self.assertIn("lambda", result) + + def test_unsupported_returns_none(self): + node = ast.parse("{x for x in y}", mode="eval").body + result = fill_search_field(node) + self.assertIsNone(result) + + +class TestSearchAndReplace(unittest.TestCase): + def test_replace_quoted_value(self): + content = 'template_model_name = "old_model"' + result = search_and_replace( + content, "hooks.py", "new_model" + ) + self.assertIn('"new_model"', result) + self.assertNotIn("old_model", result) + + def test_empty_models_name_returns_unchanged(self): + content = 'template_model_name = "old"' + result = search_and_replace(content, "hooks.py", "") + self.assertEqual(result, content) + + def test_missing_search_word_returns_error(self): + content = "other_variable = 'value'" + result = search_and_replace(content, "hooks.py", "model") + self.assertEqual(result, -1) + + def test_custom_search_word(self): + content = 'my_custom_var = "old_value"' + result = search_and_replace( + content, + "hooks.py", + "new_value", + search_word="my_custom_var", + ) + self.assertIn('"new_value"', result) + + +class TestArgsTypeParam(unittest.TestCase): + def test_char_has_string(self): + self.assertIn("string", ARGS_TYPE_PARAM["Char"]) + + def test_many2one_has_comodel(self): + self.assertIn("comodel_name", ARGS_TYPE_PARAM["Many2one"]) + + def test_one2many_has_inverse(self): + self.assertIn("inverse_name", ARGS_TYPE_PARAM["One2many"]) + + def test_many2many_has_relation(self): + self.assertIn("relation", ARGS_TYPE_PARAM["Many2many"]) + + def test_id_is_empty(self): + self.assertEqual(ARGS_TYPE_PARAM["Id"], []) + + def test_selection_has_selection(self): + self.assertIn("selection", ARGS_TYPE_PARAM["Selection"]) diff --git a/test/test_database_tools.py b/test/test_database_tools.py new file mode 100644 index 0000000..e7c95af --- /dev/null +++ b/test/test_database_tools.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +# © 2025 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import csv +import io +import json +import os +import tempfile +import unittest +import zipfile +from unittest.mock import patch + +from script.database.migrate.process_backup_file import process_zip + + +class TestProcessZip(unittest.TestCase): + """Test ZIP file processing to remove lines containing a keyword.""" + + def _create_zip(self, path, filename, content): + with zipfile.ZipFile(path, "w") as zf: + zf.writestr(filename, content) + + def test_removes_matching_lines(self): + tmpdir = tempfile.mkdtemp() + input_zip = os.path.join(tmpdir, "input.zip") + output_zip = os.path.join(tmpdir, "output.zip") + self._create_zip( + input_zip, + "dump.sql", + "keep this line\ndelete secret line\nkeep also\n", + ) + process_zip(input_zip, output_zip, "secret", "dump.sql") + with zipfile.ZipFile(output_zip) as zf: + content = zf.read("dump.sql").decode() + self.assertIn("keep this line", content) + self.assertIn("keep also", content) + self.assertNotIn("secret", content) + + def test_preserves_all_when_no_match(self): + tmpdir = tempfile.mkdtemp() + input_zip = os.path.join(tmpdir, "input.zip") + output_zip = os.path.join(tmpdir, "output.zip") + original = "line1\nline2\nline3\n" + self._create_zip(input_zip, "data.sql", original) + process_zip(input_zip, output_zip, "nomatch", "data.sql") + with zipfile.ZipFile(output_zip) as zf: + content = zf.read("data.sql").decode() + self.assertIn("line1", content) + self.assertIn("line2", content) + self.assertIn("line3", content) + + def test_removes_all_matching(self): + tmpdir = tempfile.mkdtemp() + input_zip = os.path.join(tmpdir, "input.zip") + output_zip = os.path.join(tmpdir, "output.zip") + self._create_zip( + input_zip, + "dump.sql", + "bad line\nbad again\nbad too\n", + ) + process_zip(input_zip, output_zip, "bad", "dump.sql") + with zipfile.ZipFile(output_zip) as zf: + content = zf.read("dump.sql").decode() + self.assertEqual(content.strip(), "") + + def test_other_files_untouched(self): + tmpdir = tempfile.mkdtemp() + input_zip = os.path.join(tmpdir, "input.zip") + output_zip = os.path.join(tmpdir, "output.zip") + with zipfile.ZipFile(input_zip, "w") as zf: + zf.writestr("target.sql", "keep\nremove secret\n") + zf.writestr("other.txt", "secret stays here\n") + process_zip(input_zip, output_zip, "secret", "target.sql") + with zipfile.ZipFile(output_zip) as zf: + target = zf.read("target.sql").decode() + other = zf.read("other.txt").decode() + self.assertNotIn("secret", target) + self.assertIn("secret", other) + + +class TestCompareDatabaseApplicationLogic(unittest.TestCase): + """Test CSV set comparison logic used by compare_database_application.""" + + def _write_csv(self, path, rows, fieldnames): + with open(path, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + + def test_identical_csvs(self): + tmpdir = tempfile.mkdtemp() + csv1 = os.path.join(tmpdir, "a.csv") + csv2 = os.path.join(tmpdir, "b.csv") + rows = [{"name": "mod1"}, {"name": "mod2"}] + self._write_csv(csv1, rows, ["name"]) + self._write_csv(csv2, rows, ["name"]) + with open(csv1) as f1, open(csv2) as f2: + r1 = csv.DictReader(f1) + r2 = csv.DictReader(f2) + s1 = {a["name"] for a in r1} + s2 = {a["name"] for a in r2} + self.assertEqual(s1, s2) + self.assertEqual(len(s1.difference(s2)), 0) + + def test_different_csvs(self): + tmpdir = tempfile.mkdtemp() + csv1 = os.path.join(tmpdir, "a.csv") + csv2 = os.path.join(tmpdir, "b.csv") + self._write_csv(csv1, [{"name": "mod1"}, {"name": "mod2"}], ["name"]) + self._write_csv(csv2, [{"name": "mod2"}, {"name": "mod3"}], ["name"]) + with open(csv1) as f1, open(csv2) as f2: + r1 = csv.DictReader(f1) + r2 = csv.DictReader(f2) + s1 = {a["name"] for a in r1} + s2 = {a["name"] for a in r2} + self.assertEqual(s1.intersection(s2), {"mod2"}) + self.assertEqual(s1.difference(s2), {"mod1"}) + self.assertEqual(s2.difference(s1), {"mod3"}) + + def test_empty_csvs(self): + tmpdir = tempfile.mkdtemp() + csv1 = os.path.join(tmpdir, "a.csv") + csv2 = os.path.join(tmpdir, "b.csv") + self._write_csv(csv1, [], ["name"]) + self._write_csv(csv2, [], ["name"]) + with open(csv1) as f1, open(csv2) as f2: + r1 = csv.DictReader(f1) + r2 = csv.DictReader(f2) + s1 = {a["name"] for a in r1} + s2 = {a["name"] for a in r2} + self.assertEqual(len(s1.union(s2)), 0) + + def test_one_empty_csv(self): + tmpdir = tempfile.mkdtemp() + csv1 = os.path.join(tmpdir, "a.csv") + csv2 = os.path.join(tmpdir, "b.csv") + self._write_csv(csv1, [{"name": "mod1"}], ["name"]) + self._write_csv(csv2, [], ["name"]) + with open(csv1) as f1, open(csv2) as f2: + r1 = csv.DictReader(f1) + r2 = csv.DictReader(f2) + s1 = {a["name"] for a in r1} + s2 = {a["name"] for a in r2} + self.assertEqual(s1.difference(s2), {"mod1"}) + self.assertEqual(len(s2.difference(s1)), 0) diff --git a/test/test_docker_update_version.py b/test/test_docker_update_version.py new file mode 100644 index 0000000..72f745a --- /dev/null +++ b/test/test_docker_update_version.py @@ -0,0 +1,121 @@ +#!/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 types import SimpleNamespace + +from script.docker.docker_update_version import edit_text, edit_docker_prod + + +class TestEditText(unittest.TestCase): + """Test docker-compose.yml image version update.""" + + def _write_compose(self, content): + f = tempfile.NamedTemporaryFile( + mode="w", suffix=".yml", delete=False + ) + f.write(content) + f.close() + return f.name + + def test_updates_image_after_erplibre(self): + path = self._write_compose( + "services:\n" + " ERPLibre:\n" + " image: old:1.0\n" + " ports:\n" + ) + config = SimpleNamespace( + docker_compose_file=path, + prod_version="myrepo:2.0", + ) + edit_text(config) + with open(path) as f: + content = f.read() + self.assertIn("image: myrepo:2.0", content) + self.assertNotIn("old:1.0", content) + os.unlink(path) + + def test_preserves_other_lines(self): + path = self._write_compose( + "version: '3'\n" + "services:\n" + " ERPLibre:\n" + " image: old:1.0\n" + " postgres:\n" + " image: postgres:14\n" + ) + config = SimpleNamespace( + docker_compose_file=path, + prod_version="new:3.0", + ) + edit_text(config) + with open(path) as f: + content = f.read() + self.assertIn("version: '3'", content) + self.assertIn("postgres:14", content) + os.unlink(path) + + +class TestEditDockerProd(unittest.TestCase): + """Test Dockerfile.prod FROM line update.""" + + def _write_dockerfile(self, content): + f = tempfile.NamedTemporaryFile( + mode="w", suffix=".pkg", delete=False + ) + f.write(content) + f.close() + return f.name + + def test_updates_from_line(self): + path = self._write_dockerfile( + "FROM base:old\nRUN apt-get update\n" + ) + config = SimpleNamespace( + docker_compose_file="unused", + docker_prod=path, + base_version="base:new", + ) + edit_docker_prod(config) + with open(path) as f: + content = f.read() + self.assertIn("FROM base:new", content) + self.assertNotIn("FROM base:old", content) + os.unlink(path) + + def test_preserves_run_lines(self): + path = self._write_dockerfile( + "FROM base:old\nRUN echo hello\nCOPY . /app\n" + ) + config = SimpleNamespace( + docker_compose_file="unused", + docker_prod=path, + base_version="base:v2", + ) + edit_docker_prod(config) + with open(path) as f: + content = f.read() + self.assertIn("RUN echo hello", content) + self.assertIn("COPY . /app", content) + os.unlink(path) + + def test_multiple_from_lines(self): + path = self._write_dockerfile( + "FROM base:old\nRUN build\nFROM base:old\nRUN run\n" + ) + config = SimpleNamespace( + docker_compose_file="unused", + docker_prod=path, + base_version="base:v3", + ) + edit_docker_prod(config) + with open(path) as f: + lines = f.readlines() + from_lines = [l for l in lines if l.startswith("FROM")] + for line in from_lines: + self.assertIn("base:v3", line) + os.unlink(path) diff --git a/test/test_format_file_to_commit.py b/test/test_format_file_to_commit.py new file mode 100644 index 0000000..33a9134 --- /dev/null +++ b/test/test_format_file_to_commit.py @@ -0,0 +1,140 @@ +#!/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 patch, MagicMock + +from script.maintenance.format_file_to_commit import ( + execute_shell, + get_modified_files, +) + +MOD = "script.maintenance.format_file_to_commit" + + +class TestExecuteShell(unittest.TestCase): + def test_successful_command(self): + code, output = execute_shell("echo hello") + self.assertEqual(code, 0) + self.assertEqual(output, "hello") + + def test_failed_command(self): + code, output = execute_shell("exit 42") + self.assertEqual(code, 42) + + def test_stderr_captured(self): + code, output = execute_shell("echo err >&2") + self.assertEqual(code, 0) + self.assertIn("err", output) + + def test_empty_output(self): + code, output = execute_shell("true") + self.assertEqual(code, 0) + self.assertEqual(output, "") + + def test_multiline_output(self): + code, output = execute_shell("echo 'a\nb'") + self.assertEqual(code, 0) + self.assertIn("a", output) + self.assertIn("b", output) + + +class TestGetModifiedFiles(unittest.TestCase): + def _mock_git(self, git_output): + """Helper to mock subprocess.run for git status.""" + mock_result = MagicMock() + mock_result.stdout = git_output + mock_result.returncode = 0 + return mock_result + + def _no_repo_no_odoo(self, path): + """Return False for repo bin and .odoo-version, True otherwise.""" + if path in (".venv.erplibre/bin/repo",): + return False + return True + + @patch(f"{MOD}.os.path.isfile", return_value=False) + @patch(f"{MOD}.os.path.exists") + @patch(f"{MOD}.subprocess.run") + def test_single_modified_file(self, mock_run, mock_exists, mock_isfile): + mock_exists.return_value = False + mock_run.return_value = self._mock_git(" M file.py") + files = get_modified_files() + self.assertIsNotNone(files) + self.assertEqual(len(files), 1) + self.assertEqual(files[0][0], "M") + + @patch(f"{MOD}.os.path.isfile", return_value=False) + @patch(f"{MOD}.os.path.exists", return_value=False) + @patch(f"{MOD}.subprocess.run") + def test_added_file(self, mock_run, mock_exists, mock_isfile): + mock_run.return_value = self._mock_git("A new_file.py") + files = get_modified_files() + self.assertIsNotNone(files) + self.assertEqual(files[0][0], "A") + + @patch(f"{MOD}.os.path.isfile", return_value=False) + @patch(f"{MOD}.os.path.exists", return_value=False) + @patch(f"{MOD}.subprocess.run") + def test_deleted_file_ignored(self, mock_run, mock_exists, mock_isfile): + mock_run.return_value = self._mock_git("D deleted.py") + files = get_modified_files() + self.assertIsNotNone(files) + self.assertEqual(len(files), 0) + + @patch(f"{MOD}.os.path.isfile", return_value=False) + @patch(f"{MOD}.os.path.exists", return_value=False) + @patch(f"{MOD}.subprocess.run") + def test_zip_file_ignored(self, mock_run, mock_exists, mock_isfile): + mock_run.return_value = self._mock_git("M archive.zip") + files = get_modified_files() + self.assertIsNotNone(files) + self.assertEqual(len(files), 0) + + @patch(f"{MOD}.os.path.isfile", return_value=False) + @patch(f"{MOD}.os.path.exists", return_value=False) + @patch(f"{MOD}.subprocess.run") + def test_tar_gz_file_ignored(self, mock_run, mock_exists, mock_isfile): + mock_run.return_value = self._mock_git("M archive.tar.gz") + files = get_modified_files() + self.assertIsNotNone(files) + self.assertEqual(len(files), 0) + + @patch(f"{MOD}.os.path.isfile", return_value=False) + @patch(f"{MOD}.os.path.exists", return_value=False) + @patch(f"{MOD}.subprocess.run") + def test_untracked_file(self, mock_run, mock_exists, mock_isfile): + mock_run.return_value = self._mock_git("?? new_file.txt") + files = get_modified_files() + self.assertIsNotNone(files) + self.assertEqual(len(files), 1) + self.assertEqual(files[0][0], "??") + + @patch(f"{MOD}.os.path.isfile", return_value=False) + @patch(f"{MOD}.os.path.exists", return_value=False) + @patch(f"{MOD}.subprocess.run") + def test_empty_git_status(self, mock_run, mock_exists, mock_isfile): + mock_run.return_value = self._mock_git("") + files = get_modified_files() + self.assertIsNotNone(files) + self.assertEqual(len(files), 0) + + @patch(f"{MOD}.os.path.isfile", return_value=False) + @patch(f"{MOD}.os.path.exists", return_value=False) + @patch(f"{MOD}.subprocess.run") + def test_multiple_statuses(self, mock_run, mock_exists, mock_isfile): + mock_run.return_value = self._mock_git( + " M modified.py\nA added.py\n?? untracked.py" + ) + files = get_modified_files() + self.assertIsNotNone(files) + self.assertEqual(len(files), 3) + + @patch(f"{MOD}.subprocess.run") + def test_git_error_returns_none(self, mock_run): + import subprocess + + mock_run.side_effect = subprocess.CalledProcessError(1, "git") + files = get_modified_files() + self.assertIsNone(files) diff --git a/test/test_iscompatible.py b/test/test_iscompatible.py new file mode 100644 index 0000000..b3d2377 --- /dev/null +++ b/test/test_iscompatible.py @@ -0,0 +1,94 @@ +#!/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 packaging.version import Version + +from script.poetry.iscompatible import ( + iscompatible, + parse_requirements, + string_to_tuple, +) + + +class TestStringToTuple(unittest.TestCase): + def test_three_parts(self): + self.assertEqual(string_to_tuple("1.0.0"), (1, 0, 0)) + + def test_two_parts(self): + self.assertEqual(string_to_tuple("2.5"), (2, 5)) + + def test_single_part(self): + self.assertEqual(string_to_tuple("3"), (3,)) + + def test_large_numbers(self): + self.assertEqual(string_to_tuple("10.20.300"), (10, 20, 300)) + + +class TestParseRequirements(unittest.TestCase): + def test_exact_match(self): + result = parse_requirements("foo==1.0.0") + self.assertEqual(result, [("==", "1.0.0")]) + + def test_greater_equal(self): + result = parse_requirements("foo>=1.1.0") + self.assertEqual(result, [(">=", "1.1.0")]) + + def test_multiple_constraints(self): + result = parse_requirements("foo>=1.1.0, <1.2") + self.assertEqual(result, [(">=", "1.1.0"), ("<", "1.2")]) + + def test_not_equal(self): + result = parse_requirements("bar!=2.0") + self.assertEqual(result, [("!=", "2.0")]) + + def test_less_equal(self): + result = parse_requirements("baz<=3.0.0") + self.assertEqual(result, [("<=", "3.0.0")]) + + def test_no_version_returns_empty(self): + result = parse_requirements("foo") + self.assertEqual(result, []) + + def test_with_comment(self): + result = parse_requirements("foo>=1.0 # some comment") + self.assertEqual(result, [(">=", "1.0")]) + + +class TestIsCompatible(unittest.TestCase): + def test_exact_match_true(self): + self.assertTrue(iscompatible("foo==1.0.0", Version("1.0.0"))) + + def test_exact_match_false(self): + self.assertFalse(iscompatible("foo==1.0.0", Version("1.0.1"))) + + def test_greater_equal_true(self): + self.assertTrue(iscompatible("foo>=5", Version("5.6.1"))) + + def test_greater_equal_false(self): + self.assertFalse(iscompatible("foo>=5.6.1, <5.7", Version("5.0.0"))) + + def test_range_true(self): + self.assertTrue(iscompatible("foo>=1.1, <2.1", Version("2.0.0"))) + + def test_range_false(self): + self.assertFalse(iscompatible("foo>=1.1, <2.0", Version("2.0.0"))) + + def test_less_equal(self): + self.assertTrue(iscompatible("foo<=1", Version("0.9.0"))) + + def test_greater_than(self): + self.assertTrue(iscompatible("foo>1.0", Version("1.0.1"))) + + def test_greater_than_equal(self): + # Version (1,0,0) > (1,0) is True because tuple comparison + # extends shorter tuple, so this is actually compatible + self.assertTrue(iscompatible("foo>1.0", Version("1.0.0"))) + + def test_not_equal_true(self): + self.assertTrue(iscompatible("foo!=1.0.0", Version("1.0.1"))) + + def test_not_equal_false(self): + self.assertFalse(iscompatible("foo!=1.0.0", Version("1.0.0"))) diff --git a/test/test_version.py b/test/test_version.py new file mode 100644 index 0000000..8912a9d --- /dev/null +++ b/test/test_version.py @@ -0,0 +1,243 @@ +#!/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 sys +import tempfile +import unittest +from unittest.mock import patch, MagicMock + +from script.version.update_env_version import ( + Update, + remove_dot_path, + die, + ERPLIBRE_TEMPLATE_VERSION, + VENV_TEMPLATE_FILE, + MANIFEST_TEMPLATE_FILE, + PYPROJECT_TEMPLATE_FILE, + POETRY_LOCK_TEMPLATE_FILE, + ADDONS_TEMPLATE_FILE, + ODOO_TEMPLATE_FILE, +) + + +class TestRemoveDotPath(unittest.TestCase): + def test_removes_dot_slash(self): + self.assertEqual(remove_dot_path("./path/to/file"), "path/to/file") + + def test_no_dot_slash(self): + self.assertEqual(remove_dot_path("path/to/file"), "path/to/file") + + def test_just_dot_slash(self): + self.assertEqual(remove_dot_path("./"), "") + + def test_nested_dot_slash(self): + self.assertEqual( + remove_dot_path("./a/./b"), "a/./b" + ) + + def test_empty_string(self): + self.assertEqual(remove_dot_path(""), "") + + def test_single_file(self): + self.assertEqual(remove_dot_path("file.txt"), "file.txt") + + +class TestDie(unittest.TestCase): + def test_die_true_exits(self): + with self.assertRaises(SystemExit) as cm: + die(True, "error message") + self.assertEqual(cm.exception.code, 1) + + def test_die_false_no_exit(self): + die(False, "no error") + + def test_die_custom_code(self): + with self.assertRaises(SystemExit) as cm: + die(True, "error", code=42) + self.assertEqual(cm.exception.code, 42) + + +class TestConstants(unittest.TestCase): + def test_erplibre_template_version(self): + result = ERPLIBRE_TEMPLATE_VERSION % ("18.0", "3.12.10") + self.assertEqual(result, "odoo18.0_python3.12.10") + + def test_venv_template(self): + result = VENV_TEMPLATE_FILE % "odoo18.0_python3.12.10" + self.assertEqual(result, ".venv.odoo18.0_python3.12.10") + + def test_manifest_template(self): + result = MANIFEST_TEMPLATE_FILE % "18.0" + self.assertEqual(result, "default.dev.odoo18.0.xml") + + def test_pyproject_template(self): + result = PYPROJECT_TEMPLATE_FILE % "odoo18.0_python3.12.10" + self.assertEqual( + result, "pyproject.odoo18.0_python3.12.10.toml" + ) + + def test_poetry_lock_template(self): + result = POETRY_LOCK_TEMPLATE_FILE % "odoo18.0_python3.12.10" + self.assertEqual( + result, "poetry.odoo18.0_python3.12.10.lock" + ) + + def test_addons_template(self): + result = ADDONS_TEMPLATE_FILE % "18.0" + self.assertEqual(result, "odoo18.0/addons") + + def test_odoo_template(self): + result = ODOO_TEMPLATE_FILE % "18.0" + self.assertEqual(result, "odoo18.0") + + +class TestUpdateValidateVersion(unittest.TestCase): + """Test validate_version() sets expected paths based on version data.""" + + def _make_update(self, data_version, config_args=None): + with patch("sys.argv", ["prog"]): + update = Update() + update.data_version = data_version + if config_args: + for k, v in config_args.items(): + setattr(update.config, k, v) + return update + + def test_explicit_erplibre_version(self): + data = { + "odoo18.0_python3.12.10": { + "odoo_version": "18.0", + "python_version": "3.12.10", + "poetry_version": "2.1.3", + "default": True, + } + } + update = self._make_update( + data, + {"erplibre_version": "odoo18.0_python3.12.10"}, + ) + update.validate_version() + self.assertEqual(update.new_version_odoo, "18.0") + self.assertEqual(update.new_version_python, "3.12.10") + self.assertEqual(update.new_version_poetry, "2.1.3") + self.assertEqual( + update.new_version_erplibre, "odoo18.0_python3.12.10" + ) + + def test_explicit_odoo_version(self): + data = {} + update = self._make_update(data, {"odoo_version": "17.0"}) + update.new_version_python = "3.10.18" + update.config.python_version = "3.10.18" + update.validate_version() + self.assertEqual(update.new_version_odoo, "17.0") + + def test_default_version_fallback(self): + data = { + "odoo18.0_python3.12.10": { + "odoo_version": "18.0", + "python_version": "3.12.10", + "poetry_version": "2.1.3", + "default": True, + } + } + update = self._make_update(data) + update.detected_version_erplibre = None + update.validate_version() + self.assertEqual(update.new_version_odoo, "18.0") + + def test_detected_version_used(self): + data = { + "odoo17.0_python3.10.18": { + "odoo_version": "17.0", + "python_version": "3.10.18", + "poetry_version": "1.8.3", + } + } + update = self._make_update(data) + update.detected_version_erplibre = "odoo17.0_python3.10.18" + update.validate_version() + self.assertEqual(update.new_version_odoo, "17.0") + self.assertEqual(update.new_version_python, "3.10.18") + + def test_expected_paths_set(self): + data = { + "odoo18.0_python3.12.10": { + "odoo_version": "18.0", + "python_version": "3.12.10", + "poetry_version": "2.1.3", + "default": True, + } + } + update = self._make_update( + data, + {"erplibre_version": "odoo18.0_python3.12.10"}, + ) + update.validate_version() + self.assertIn("default.dev.odoo18.0.xml", update.expected_manifest_name) + self.assertIn("requirement", update.expected_pyproject_path) + self.assertIn("requirement", update.expected_poetry_lock_path) + self.assertEqual(update.expected_odoo_name, "odoo18.0") + + +class TestUpdateDetectVersion(unittest.TestCase): + def test_detect_version_no_files(self): + with patch("sys.argv", ["prog"]): + update = Update() + with patch("os.path.exists", return_value=False): + result = update.detect_version() + self.assertFalse(result) + + def test_detect_version_matching(self): + with patch("sys.argv", ["prog"]): + update = Update() + update.data_version = { + "odoo18.0_python3.12.10": { + "odoo_version": "18.0", + "python_version": "3.12.10", + } + } + tmpdir = tempfile.mkdtemp() + py_file = os.path.join(tmpdir, "python_ver") + odoo_file = os.path.join(tmpdir, "odoo_ver") + poetry_file = os.path.join(tmpdir, "poetry_ver") + with open(py_file, "w") as f: + f.write("3.12.10") + with open(odoo_file, "w") as f: + f.write("18.0") + with open(poetry_file, "w") as f: + f.write("2.1.3") + with patch( + "script.version.update_env_version.VERSION_PYTHON_FILE", + py_file, + ), patch( + "script.version.update_env_version.VERSION_ODOO_FILE", + odoo_file, + ), patch( + "script.version.update_env_version.VERSION_POETRY_FILE", + poetry_file, + ), patch( + "script.version.update_env_version.INSTALLED_ODOO_VERSION_FILE", + os.path.join(tmpdir, "nonexist"), + ): + result = update.detect_version() + self.assertTrue(result) + self.assertEqual( + update.detected_version_erplibre, "odoo18.0_python3.12.10" + ) + + +class TestUpdatePrintLog(unittest.TestCase): + def test_empty_log(self): + with patch("sys.argv", ["prog"]): + update = Update() + update.print_log() + + def test_with_entries(self): + with patch("sys.argv", ["prog"]): + update = Update() + update.execute_log = ["entry1", "entry2"] + update.print_log()