[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 <mathben@technolibre.ca>
This commit is contained in:
Mathieu Benoit 2026-03-11 22:50:02 -04:00
parent 438edc3694
commit eb42224df9
7 changed files with 1122 additions and 0 deletions

View file

@ -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)

View file

@ -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"])

146
test/test_database_tools.py Normal file
View file

@ -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)

View file

@ -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)

View file

@ -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)

94
test/test_iscompatible.py Normal file
View file

@ -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")))

243
test/test_version.py Normal file
View file

@ -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()