erplibre/test/test_config_file.py
Mathieu Benoit 16aecc2d33 [ADD] mail: read and send email from the TODO CLI
An IMAP/SMTP client in the menu, with its tests against real servers. Most
of the work went into refusals: an application password is named only when
the server actually refuses, an accented password is reported as never
having left the machine, and a refusal is recognised by what the server
SAYS rather than by matching its wording. A malformed date no longer takes
the whole folder down, and a received email is never read as markup.

--- FR ---

Un client IMAP/SMTP dans le menu, avec ses tests contre de vrais serveurs.
L'essentiel du travail porte sur les refus : le mot de passe d'application
n'est nommé que lorsque le serveur refuse vraiment, un mot de passe accentué
est signalé comme n'ayant jamais quitté la machine, et un refus se reconnaît
à ce que le serveur DIT plutôt qu'à ses mots. Une date illisible n'emporte
plus le dossier entier, et un courriel reçu n'est jamais lu comme du balisage.

Assisted-by: Claude Opus 5
2026-08-16 03:56:34 -04:00

329 lines
12 KiB
Python

#!/usr/bin/env python3
# © 2026 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.config.config_file import ConfigFile
class TestDeepMergeWithLists(unittest.TestCase):
def setUp(self):
self.cfg = ConfigFile()
def test_empty_dicts(self):
result = self.cfg.deep_merge_with_lists({}, {})
self.assertEqual(result, {})
def test_dest_only(self):
result = self.cfg.deep_merge_with_lists({"a": 1, "b": 2}, {})
self.assertEqual(result, {"a": 1, "b": 2})
def test_src_only(self):
result = self.cfg.deep_merge_with_lists({}, {"a": 1, "b": 2})
self.assertEqual(result, {"a": 1, "b": 2})
def test_simple_merge(self):
result = self.cfg.deep_merge_with_lists({"a": 1}, {"b": 2})
self.assertEqual(result, {"a": 1, "b": 2})
def test_src_overrides_dest_string(self):
result = self.cfg.deep_merge_with_lists({"a": "old"}, {"a": "new"})
self.assertEqual(result, {"a": "new"})
def test_empty_src_string_keeps_dest(self):
result = self.cfg.deep_merge_with_lists({"a": "old"}, {"a": ""})
self.assertEqual(result, {"a": "old"})
def test_nested_dict_merge(self):
dest = {"a": {"x": 1, "y": 2}}
src = {"a": {"y": 3, "z": 4}}
result = self.cfg.deep_merge_with_lists(dest, src)
self.assertEqual(result, {"a": {"x": 1, "y": 3, "z": 4}})
def test_deeply_nested_dict_merge(self):
dest = {"a": {"b": {"c": 1}}}
src = {"a": {"b": {"d": 2}}}
result = self.cfg.deep_merge_with_lists(dest, src)
self.assertEqual(result, {"a": {"b": {"c": 1, "d": 2}}})
def test_list_replace_strategy(self):
result = self.cfg.deep_merge_with_lists(
{"a": [1, 2]}, {"a": [3, 4]}, list_strategy="replace"
)
self.assertEqual(result, {"a": [3, 4]})
def test_list_extend_strategy(self):
result = self.cfg.deep_merge_with_lists(
{"a": [1, 2]}, {"a": [3, 4]}, list_strategy="extend"
)
self.assertEqual(result, {"a": [1, 2, 3, 4]})
def test_dest_dict_not_mutated(self):
dest = {"a": {"x": 1}}
src = {"a": {"y": 2}}
self.cfg.deep_merge_with_lists(dest, src)
self.assertEqual(dest, {"a": {"x": 1}})
def test_src_overrides_non_string_non_dict_non_list(self):
result = self.cfg.deep_merge_with_lists({"a": 1}, {"a": 2})
self.assertEqual(result, {"a": 2})
class TestGetConfig(unittest.TestCase):
def setUp(self):
self.cfg = ConfigFile()
self.tmpdir = tempfile.mkdtemp()
def tearDown(self):
for f in os.listdir(self.tmpdir):
os.remove(os.path.join(self.tmpdir, f))
os.rmdir(self.tmpdir)
def _write_json(self, filename, data):
path = os.path.join(self.tmpdir, filename)
with open(path, "w") as f:
json.dump(data, f)
return path
def test_get_config_base_only(self):
base_path = self._write_json(
"base.json", {"instance": [{"name": "test"}]}
)
with patch("script.config.config_file.CONFIG_FILE", base_path), patch(
"script.config.config_file.CONFIG_OVERRIDE_FILE",
os.path.join(self.tmpdir, "nonexistent1.json"),
), patch(
"script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE",
os.path.join(self.tmpdir, "nonexistent2.json"),
):
result = self.cfg.get_config("instance")
self.assertEqual(result, [{"name": "test"}])
def test_get_config_returns_none_for_missing_key(self):
base_path = self._write_json("base.json", {"a": 1})
with patch("script.config.config_file.CONFIG_FILE", base_path), patch(
"script.config.config_file.CONFIG_OVERRIDE_FILE",
os.path.join(self.tmpdir, "nonexistent1.json"),
), patch(
"script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE",
os.path.join(self.tmpdir, "nonexistent2.json"),
):
result = self.cfg.get_config("missing")
self.assertIsNone(result)
def test_get_config_override_merges(self):
base_path = self._write_json(
"base.json",
{"instance": [{"name": "base"}]},
)
override_path = self._write_json(
"override.json",
{"instance": [{"name": "override"}]},
)
with patch("script.config.config_file.CONFIG_FILE", base_path), patch(
"script.config.config_file.CONFIG_OVERRIDE_FILE",
override_path,
), patch(
"script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE",
os.path.join(self.tmpdir, "nonexistent.json"),
):
result = self.cfg.get_config("instance")
# Lists with extend: base + override
self.assertEqual(
result,
[{"name": "base"}, {"name": "override"}],
)
def test_get_config_private_merges(self):
base_path = self._write_json(
"base.json",
{"data": {"key": "base_val"}},
)
private_path = self._write_json(
"private.json",
{"data": {"key": "private_val"}},
)
with patch("script.config.config_file.CONFIG_FILE", base_path), patch(
"script.config.config_file.CONFIG_OVERRIDE_FILE",
os.path.join(self.tmpdir, "nonexistent.json"),
), patch(
"script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE",
private_path,
):
result = self.cfg.get_config("data")
self.assertEqual(result, {"key": "private_val"})
def test_get_config_all_three_files(self):
base_path = self._write_json(
"base.json",
{"items": [1], "meta": {"a": "base"}},
)
override_path = self._write_json(
"override.json",
{"items": [2], "meta": {"b": "override"}},
)
private_path = self._write_json(
"private.json",
{"items": [3], "meta": {"a": "private"}},
)
with patch("script.config.config_file.CONFIG_FILE", base_path), patch(
"script.config.config_file.CONFIG_OVERRIDE_FILE",
override_path,
), patch(
"script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE",
private_path,
):
result_items = self.cfg.get_config("items")
result_meta = self.cfg.get_config("meta")
# Merge order: base + private, then + override
# Lists extend: [1] + [3] = [1,3], then [1,3] + [2] = [1,3,2]
self.assertEqual(result_items, [1, 3, 2])
# Dict merge: {a: base} + {a: private} = {a: private}
# then {a: private} + {b: override} = {a: private, b: override}
self.assertEqual(result_meta, {"a": "private", "b": "override"})
def test_no_config_files_exist(self):
with patch(
"script.config.config_file.CONFIG_FILE",
os.path.join(self.tmpdir, "nope1.json"),
), patch(
"script.config.config_file.CONFIG_OVERRIDE_FILE",
os.path.join(self.tmpdir, "nope2.json"),
), patch(
"script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE",
os.path.join(self.tmpdir, "nope3.json"),
):
result = self.cfg.get_config("anything")
self.assertIsNone(result)
class TestSetConfigValue(unittest.TestCase):
"""`set_config_value` est le pendant écriture de `get_config_value` :
seul `CONFIG_OVERRIDE_PRIVATE_FILE` est gitignored (vérifié avec
`git check-ignore`), donc c'est le seul des trois fichiers où écrire un
chemin personnel (ex. `kdbx.path`) sans risquer de le committer.
"""
def setUp(self):
self.cfg = ConfigFile()
self.tmp = tempfile.TemporaryDirectory()
# Sous un sous-dossier qui n'existe pas encore, comme le vrai
# `private/todo/` d'un checkout neuf.
self.private_path = os.path.join(
self.tmp.name, "private", "todo", "todo_override_private.json"
)
self.patcher = patch(
"script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE",
self.private_path,
)
self.patcher.start()
def tearDown(self):
self.patcher.stop()
self.tmp.cleanup()
def test_creates_missing_file_and_directory(self):
self.cfg.set_config_value(["kdbx", "path"], "/x/y.kdbx")
self.assertTrue(os.path.exists(self.private_path))
with open(self.private_path) as f:
data = json.load(f)
self.assertEqual(data, {"kdbx": {"path": "/x/y.kdbx"}})
def test_nested_key_creation(self):
self.cfg.set_config_value(["a", "b", "c"], "v")
with open(self.private_path) as f:
data = json.load(f)
self.assertEqual(data, {"a": {"b": {"c": "v"}}})
def test_preserves_existing_unrelated_content(self):
os.makedirs(os.path.dirname(self.private_path))
with open(self.private_path, "w") as f:
json.dump({"other": {"key": "kept"}}, f)
self.cfg.set_config_value(["kdbx", "path"], "/x/y.kdbx")
with open(self.private_path) as f:
data = json.load(f)
self.assertEqual(
data,
{"other": {"key": "kept"}, "kdbx": {"path": "/x/y.kdbx"}},
)
def test_overwrites_only_the_targeted_key(self):
self.cfg.set_config_value(["kdbx", "path"], "/first.kdbx")
self.cfg.set_config_value(["kdbx", "path"], "/second.kdbx")
with open(self.private_path) as f:
data = json.load(f)
self.assertEqual(data, {"kdbx": {"path": "/second.kdbx"}})
def test_file_mode_is_0600(self):
self.cfg.set_config_value(["kdbx", "path"], "/x/y.kdbx")
mode = os.stat(self.private_path).st_mode & 0o777
self.assertEqual(mode, 0o600)
def test_directory_mode_is_0700(self):
self.cfg.set_config_value(["kdbx", "path"], "/x/y.kdbx")
mode = os.stat(os.path.dirname(self.private_path)).st_mode & 0o777
self.assertEqual(mode, 0o700)
def test_existing_file_with_looser_mode_is_corrected(self):
os.makedirs(os.path.dirname(self.private_path))
with open(self.private_path, "w") as f:
json.dump({}, f)
os.chmod(self.private_path, 0o644)
self.cfg.set_config_value(["kdbx", "path"], "/x/y.kdbx")
mode = os.stat(self.private_path).st_mode & 0o777
self.assertEqual(mode, 0o600)
def test_round_trips_through_get_config_value(self):
self.cfg.set_config_value(["kdbx", "path"], "/round/trip.kdbx")
with patch(
"script.config.config_file.CONFIG_FILE",
os.path.join(self.tmp.name, "nonexistent_base.json"),
), patch(
"script.config.config_file.CONFIG_OVERRIDE_FILE",
os.path.join(self.tmp.name, "nonexistent_override.json"),
):
result = self.cfg.get_config_value(["kdbx", "path"])
self.assertEqual(result, "/round/trip.kdbx")
class TestGetConfigValue(unittest.TestCase):
def setUp(self):
self.cfg = ConfigFile()
def test_nested_value(self):
with patch.object(
self.cfg,
"get_config",
return_value={"level1": {"level2": "found"}},
):
result = self.cfg.get_config_value(["root", "level1", "level2"])
self.assertEqual(result, "found")
def test_single_key(self):
with patch.object(
self.cfg,
"get_config",
return_value={"key": "value"},
):
result = self.cfg.get_config_value(["root"])
self.assertEqual(result, {"key": "value"})
class TestGetLogoAsciiFilePath(unittest.TestCase):
def test_returns_logo_path(self):
cfg = ConfigFile()
result = cfg.get_logo_ascii_file_path()
self.assertEqual(result, "./script/todo/logo_ascii.txt")
if __name__ == "__main__":
unittest.main()