#!/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 logging import os import tempfile import unittest from pathlib import Path from script.todo.mail.accounts import account_from_preset from script.todo.mail.menu import cache_summary from script.todo.mail.store import Store class TestCacheSummary(unittest.TestCase): # Sans injection, `resolve_mode` retombe sur les VRAIES préférences de la # machine, et `todo_prefs` crée `~/.erplibre` au passage. CLEAR = staticmethod(lambda k, d=None: "clear") def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.base = Path(self.tmp.name) self.accounts = [ account_from_preset("perso", "a@x.ca", "generic"), account_from_preset("travail", "b@x.ca", "generic"), ] def tearDown(self): self.tmp.cleanup() def test_one_row_per_account(self): rows = cache_summary( self.accounts, base=self.base, prefs_get=self.CLEAR ) self.assertEqual([r["name"] for r in rows], ["perso", "travail"]) def test_reports_the_effective_mode(self): self.accounts[0].cache_mode = "encrypted" rows = cache_summary( self.accounts, base=self.base, prefs_get=lambda k, d=None: "clear" ) self.assertEqual(rows[0]["mode"], "encrypted") self.assertEqual(rows[1]["mode"], "clear") def test_size_is_zero_before_any_sync(self): rows = cache_summary( self.accounts, base=self.base, prefs_get=self.CLEAR ) self.assertEqual(rows[0]["size"], 0) def test_size_grows_with_the_cache(self): store = Store(self.accounts[0], mode="clear", base=self.base) store.open() store.upsert_folder("INBOX") store.write_body("INBOX", 1, b"x" * 4096) store.close() rows = cache_summary( self.accounts, base=self.base, prefs_get=self.CLEAR ) self.assertGreater(rows[0]["size"], 4000) def test_missing_cache_does_not_raise(self): rows = cache_summary( self.accounts, base=self.base / "inexistant", prefs_get=self.CLEAR, ) self.assertEqual(len(rows), 2) class TestAddAccountRollsBack(unittest.TestCase): """Une sauvegarde ratée ne doit pas laisser le mot de passe dans le coffre.""" def test_failed_save_removes_the_orphan_secret(self): from unittest.mock import MagicMock, patch import script.todo.mail.menu as menu vault = MagicMock() vault.available_backends.return_value = ["kdbx"] with patch.object( menu, "secret_store_for", return_value=vault ), patch.object( menu.mail_accounts, "save", side_effect=OSError("disque plein") ), patch.object( menu, "_load_accounts", return_value=[] ), patch( "builtins.input", side_effect=[ "perso", "moi@x.ca", "", "4", "imap.x.ca", "smtp.x.ca", ], ), patch( "getpass.getpass", return_value="hunter2" ): menu._add_account(MagicMock()) vault.set.assert_called_once() vault.delete.assert_called_once_with("kdbx:ERPLibre/Mail/perso") def test_failed_save_does_not_crash_the_menu(self): from unittest.mock import MagicMock, patch import script.todo.mail.menu as menu vault = MagicMock() vault.available_backends.return_value = ["kdbx"] with patch.object( menu, "secret_store_for", return_value=vault ), patch.object( menu.mail_accounts, "save", side_effect=OSError("disque plein") ), patch.object( menu, "_load_accounts", return_value=[] ), patch( "builtins.input", side_effect=[ "perso", "moi@x.ca", "", "4", "imap.x.ca", "smtp.x.ca", ], ), patch( "getpass.getpass", return_value="hunter2" ): menu._add_account(MagicMock()) # ne doit pas lever class TestOpenTuiAllowsEmptyAccounts(unittest.TestCase): """Le TUI sait désormais créer un compte depuis son propre écran : le refus historique de s'ouvrir sans compte (`mail_no_account`) défait exactement la fonctionnalité que cette tâche ajoute.""" def test_opens_with_zero_accounts_instead_of_refusing(self): from unittest.mock import MagicMock, patch import script.todo.mail.menu as menu todo = MagicMock() with patch.object(menu, "_load_accounts", return_value=[]), patch( "script.todo.mail.tui.open_sessions", return_value=[] ) as mock_open, patch("script.todo.mail.tui.run_tui") as mock_run: menu._open_tui(todo) mock_open.assert_called_once() mock_run.assert_called_once() def test_passes_config_file_and_the_secret_store_through(self): """Sans ça, l'écran d'ajout de compte du TUI n'aurait ni où écrire le chemin du kdbx, ni de coffre pour y déposer le mot de passe.""" from unittest.mock import MagicMock, patch import script.todo.mail.menu as menu todo = MagicMock() secrets = MagicMock() with patch.object( menu, "_load_accounts", return_value=[] ), patch.object(menu, "secret_store_for", return_value=secrets), patch( "script.todo.mail.tui.open_sessions", return_value=[] ), patch( "script.todo.mail.tui.run_tui" ) as mock_run: menu._open_tui(todo) _, kwargs = mock_run.call_args self.assertIs(kwargs["config_file"], todo.config_file) self.assertIs(kwargs["secret_store"], secrets) class TestSyncNowSurfacesResync(unittest.TestCase): """`report.purged` (dossiers vidés car l'UIDVALIDITY a changé) doit atteindre l'utilisateur — il ne suffit pas qu'il soit calculé et testé dans `imap_sync.py`, encore faut-il qu'un appelant l'affiche.""" def test_purged_folders_are_printed(self): import io from contextlib import redirect_stdout from types import SimpleNamespace from unittest.mock import MagicMock, patch import script.todo.mail.menu as menu account = account_from_preset("perso", "a@x.ca", "generic") class FakeSession: def __init__(self): self.account = account self.online = True self.error = "" def sync(self): return SimpleNamespace( new_messages=1, errors=[], purged=["INBOX"] ) def close(self): pass buf = io.StringIO() with patch.object( menu, "_load_accounts", return_value=[account] ), patch( "script.todo.mail.tui.open_sessions", return_value=[FakeSession()], ), redirect_stdout( buf ): menu._sync_now(MagicMock()) self.assertIn("INBOX", buf.getvalue()) class TestMailLogFile(unittest.TestCase): """Aucun gestionnaire n'existait nulle part dans `script/todo/mail/` avant ce correctif : les modules journalisent (`_logger.exception(...)`), mais brancher un gestionnaire est le travail de L'APPLICATION — ici, `prompt_execute_mail`, le seul point d'entrée du paquet. Jamais vers la console : Textual possède le terminal pendant tout le TUI. """ def setUp(self): self.fake_home = tempfile.TemporaryDirectory() self._old_home = os.environ.get("HOME") os.environ["HOME"] = self.fake_home.name def tearDown(self): import script.todo.mail.menu as menu logger = logging.getLogger("script.todo.mail") for handler in list(logger.handlers): logger.removeHandler(handler) handler.close() logger.propagate = True menu._LOG_CONFIGURED = False if self._old_home is None: os.environ.pop("HOME", None) else: os.environ["HOME"] = self._old_home self.fake_home.cleanup() def test_creates_the_log_file_under_home(self): import script.todo.mail.menu as menu menu._configure_mail_logging() logging.getLogger("script.todo.mail.tui").error("boum") log_path = Path(self.fake_home.name) / ".erplibre" / "mail.log" self.assertTrue(log_path.exists()) self.assertIn("boum", log_path.read_text()) def test_is_idempotent(self): import script.todo.mail.menu as menu menu._configure_mail_logging() menu._configure_mail_logging() logger = logging.getLogger("script.todo.mail") file_handlers = [ h for h in logger.handlers if isinstance(h, logging.FileHandler) ] self.assertEqual(len(file_handlers), 1) def test_never_installs_a_console_handler(self): import script.todo.mail.menu as menu menu._configure_mail_logging() logger = logging.getLogger("script.todo.mail") non_file_stream_handlers = [ h for h in logger.handlers if isinstance(h, logging.StreamHandler) and not isinstance(h, logging.FileHandler) ] self.assertEqual(non_file_stream_handlers, []) def test_never_leaks_to_the_console_via_root_propagation(self): """`script/todo/todo.py:68` calls `logging.basicConfig()` at import time, which installs a `StreamHandler` on the ROOT logger. `propagate` defaults to `True`: without disabling it explicitly, every `_logger.exception(...)` in the mail package would ALSO reach that root handler — straight onto the terminal Textual owns for the whole TUI. `test_never_installs_a_console_handler` above cannot catch this: it only inspects `script.todo.mail`'s OWN handlers, never what a PARENT logger does with a propagated record. Reproduced for real in the full suite: this exact leak showed up, unprompted, in the middle of `unittest`'s dotted progress output the first time the mail tests ran in a process where `script.todo.todo` (and its `basicConfig`) had already been imported by an earlier test file. """ import io import script.todo.mail.menu as menu import script.todo.todo # noqa: F401 - installe le handler racine root = logging.getLogger() buf = io.StringIO() capture = logging.StreamHandler(buf) root.addHandler(capture) try: menu._configure_mail_logging() logging.getLogger("script.todo.mail.tui").error("ne doit pas fuir") finally: root.removeHandler(capture) self.assertEqual(buf.getvalue(), "") def test_prompt_execute_mail_configures_logging_before_the_loop(self): """`prompt_execute_mail` est le seul point d'entrée du paquet `mail` : c'est là, et nulle part ailleurs, que le gestionnaire doit être branché.""" from unittest.mock import MagicMock, patch import script.todo.mail.menu as menu with patch("click.prompt", return_value="0"): menu.prompt_execute_mail(MagicMock()) logger = logging.getLogger("script.todo.mail") self.assertTrue( any(isinstance(h, logging.FileHandler) for h in logger.handlers) ) class TestCacheSizeAndPurge(unittest.TestCase): """`_cache_size_and_purge` doit survivre à un coffre absent et à un cache corrompu — les deux tuaient tout le CLI avant ce correctif. `Store(account)` sans `base` retombe sur `~/.erplibre/mail` : on détourne `$HOME`, comme `TestComposeScreenMounted` (test_mail_compose.py), plutôt que d'ajouter un paramètre `base` qu'aucun appelant réel n'utilise. """ def setUp(self): self.fake_home = tempfile.TemporaryDirectory() self._old_home = os.environ.get("HOME") os.environ["HOME"] = self.fake_home.name def tearDown(self): if self._old_home is None: os.environ.pop("HOME", None) else: os.environ["HOME"] = self._old_home self.fake_home.cleanup() def test_purges_a_healthy_encrypted_account_without_raising(self): from unittest.mock import MagicMock, patch import script.todo.mail.menu as menu account = account_from_preset("perso", "a@x.ca", "generic") account.cache_mode = "encrypted" vault = {} secrets = MagicMock() secrets.get.side_effect = vault.get secrets.set.side_effect = lambda ref, value: vault.__setitem__( ref, value ) with patch.object( menu, "_load_accounts", return_value=[account] ), patch.object(menu, "secret_store_for", return_value=secrets), patch( "builtins.input", side_effect=["1", "o"] ): menu._cache_size_and_purge(MagicMock()) # ne doit pas lever def test_corrupted_cache_is_removed_from_disk_instead_of_crashing(self): from unittest.mock import MagicMock, patch import script.todo.mail.menu as menu account = account_from_preset("perso", "a@x.ca", "generic") store = Store(account) store.root.mkdir(parents=True, exist_ok=True) (store.root / "cache.db").write_bytes(b"pas une base sqlite" * 50) with patch.object( menu, "_load_accounts", return_value=[account] ), patch.object( menu, "secret_store_for", return_value=MagicMock() ), patch( "builtins.input", side_effect=["1", "o"] ): menu._cache_size_and_purge(MagicMock()) # ne doit pas lever self.assertFalse(store.root.exists()) class TestEnsureKdbx(unittest.TestCase): """`_ensure_kdbx` doit tenir la promesse de la conception (lignes 204-207 du design) : créer un nouveau kdbx ou en choisir un existant quand aucun n'est configuré. Un vrai `ConfigFile`, pointé vers un dossier temporaire, sert de `todo.config_file` : on veut vérifier que `set_config_value` est réellement câblé, pas seulement appelé sur un mock. """ def setUp(self): from types import SimpleNamespace from unittest.mock import patch as mock_patch from script.config.config_file import ConfigFile self.tmp = tempfile.TemporaryDirectory() self.private_path = os.path.join( self.tmp.name, "private", "todo", "todo_override_private.json" ) # Rejoue la forme du vrai `script/todo/todo.json`, qui déclare déjà # une section "kdbx" avec path/password vides (ce squelette existe # justement pour que `get_config_value(["kdbx", "path"])` renvoie # toujours une chaîne, jamais None, quand rien n'est configuré) — # sans quoi l'absence totale de fichiers ferait planter # `get_config_value` (`"path" in None`), un cas que la vraie # configuration versionnée n'expose jamais. base_path = os.path.join(self.tmp.name, "base.json") with open(base_path, "w") as f: json.dump({"kdbx": {"path": "", "password": ""}}, f) self.patchers = [ mock_patch( "script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE", self.private_path, ), mock_patch( "script.config.config_file.CONFIG_FILE", base_path, ), mock_patch( "script.config.config_file.CONFIG_OVERRIDE_FILE", os.path.join(self.tmp.name, "nonexistent_override.json"), ), ] for patcher in self.patchers: patcher.start() self.todo = SimpleNamespace(config_file=ConfigFile()) def tearDown(self): for patcher in self.patchers: patcher.stop() self.tmp.cleanup() def test_skips_the_prompt_when_already_configured(self): from unittest.mock import patch as mock_patch import script.todo.mail.menu as menu self.todo.config_file.set_config_value( ["kdbx", "path"], "/already/there.kdbx" ) with mock_patch("builtins.input") as mock_input: result = menu._ensure_kdbx(self.todo) self.assertTrue(result) mock_input.assert_not_called() def test_create_writes_a_real_kdbx_and_records_its_path(self): from unittest.mock import patch as mock_patch import script.todo.mail.menu as menu kdbx_path = os.path.join(self.tmp.name, "new.kdbx") with mock_patch( "builtins.input", side_effect=["1", kdbx_path] ), mock_patch("getpass.getpass", side_effect=["hunter2", "hunter2"]): result = menu._ensure_kdbx(self.todo) self.assertTrue(result) self.assertTrue(os.path.isfile(kdbx_path)) self.assertEqual( self.todo.config_file.get_config_value(["kdbx", "path"]), kdbx_path, ) def test_mismatched_passwords_are_refused_and_nothing_is_created(self): from unittest.mock import patch as mock_patch import script.todo.mail.menu as menu kdbx_path = os.path.join(self.tmp.name, "new.kdbx") with mock_patch( "builtins.input", side_effect=["1", kdbx_path] ), mock_patch( "getpass.getpass", side_effect=["hunter2", "somethingelse"] ): result = menu._ensure_kdbx(self.todo) self.assertFalse(result) self.assertFalse(os.path.exists(kdbx_path)) # Le squelette du vrai `todo.json` donne "" (pas None) tant que # rien n'a été configuré — c'est la valeur falsy que teste # `_ensure_kdbx`, peu importe sa forme exacte. self.assertFalse( self.todo.config_file.get_config_value(["kdbx", "path"]) ) def test_choosing_a_nonexistent_file_is_refused(self): from unittest.mock import patch as mock_patch import script.todo.mail.menu as menu missing_path = os.path.join(self.tmp.name, "nope.kdbx") with mock_patch("builtins.input", side_effect=["2", missing_path]): result = menu._ensure_kdbx(self.todo) self.assertFalse(result) # Le squelette du vrai `todo.json` donne "" (pas None) tant que # rien n'a été configuré — c'est la valeur falsy que teste # `_ensure_kdbx`, peu importe sa forme exacte. self.assertFalse( self.todo.config_file.get_config_value(["kdbx", "path"]) ) def test_choosing_an_existing_file_records_its_path(self): from unittest.mock import patch as mock_patch import script.todo.mail.menu as menu existing_path = os.path.join(self.tmp.name, "existing.kdbx") Path(existing_path).write_bytes(b"not a real kdbx, just a file") with mock_patch("builtins.input", side_effect=["2", existing_path]): result = menu._ensure_kdbx(self.todo) self.assertTrue(result) self.assertEqual( self.todo.config_file.get_config_value(["kdbx", "path"]), existing_path, ) def test_cancel_creates_neither_file_nor_account(self): from unittest.mock import patch as mock_patch import script.todo.mail.menu as menu with mock_patch.object( menu.mail_accounts, "save" ) as mock_save, mock_patch("builtins.input", side_effect=["0"]): menu._add_account(self.todo) mock_save.assert_not_called() # Le squelette du vrai `todo.json` donne "" (pas None) tant que # rien n'a été configuré — c'est la valeur falsy que teste # `_ensure_kdbx`, peu importe sa forme exacte. self.assertFalse( self.todo.config_file.get_config_value(["kdbx", "path"]) ) class TestMailKeysAreTranslated(unittest.TestCase): """Le seul filet contre une clé oubliée. `t()` rend la clé elle-même quand elle est absente : rien n'échoue, et l'interface affiche « mail_body_error » en toutes lettres à l'utilisateur. Aucun autre test ne peut attraper ça. """ def test_every_key_used_in_the_mail_package_is_declared(self): import re from pathlib import Path import script.todo.mail as mail_pkg from script.todo.todo_i18n import TRANSLATIONS pattern = re.compile(r"""(?