diff options
Diffstat (limited to 'test_mailctl.py')
| -rwxr-xr-x | test_mailctl.py | 252 |
1 files changed, 252 insertions, 0 deletions
diff --git a/test_mailctl.py b/test_mailctl.py new file mode 100755 index 0000000..c6a50b9 --- /dev/null +++ b/test_mailctl.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Self-checks for the bits of mailctl with real logic in them: + + - the senders address merge: notmuch dedupes on name-addr, so one address + appears once per display name it ever used, and we merge on the address. + - subject_terms: tokenizing/stopword-filtering subject lines, counting each + term once per subject. + - validate_accounts: the config gate. Every rejection case here is a typo + that would otherwise silently produce a query matching nothing, or a + draft written under the wrong identity. + +Run: ./test_mailctl.py +""" + +import io +import json +import os +import tempfile +from contextlib import redirect_stdout +from pathlib import Path +from unittest.mock import patch + +# mailctl validates its account config at import time, so a valid one has to +# exist before the import below. Point it at a throwaway maildir tree. +_tmp = tempfile.TemporaryDirectory() +FIXTURE_ROOT = Path(_tmp.name) +(FIXTURE_ROOT / "mail" / "acct-a" / "Drafts").mkdir(parents=True) +(FIXTURE_ROOT / "mail" / "acct-b").mkdir(parents=True) +FIXTURE_CONFIG = FIXTURE_ROOT / "accounts.json" +FIXTURE_CONFIG.write_text(json.dumps({"accounts": { + "acct-a": {"maildir": "acct-a", "address": "a@example.org", + "drafts": "Drafts"}, + "acct-b": {"maildir": "acct-b", "address": "b@example.org", + "drafts": None}, +}})) +os.environ["MAILCTL_CONFIG"] = str(FIXTURE_CONFIG) +os.environ["MAILCTL_MAIL_ROOT"] = str(FIXTURE_ROOT / "mail") + +import mailctl # noqa: E402 (must follow the env vars above) + +MAIL = FIXTURE_ROOT / "mail" + + +def senders_json(raw, **overrides): + """Run cmd_senders against a canned notmuch reply, return parsed JSON.""" + opts = {"query": "*", "account": None, "top": None, "json": True} + opts.update(overrides) + args = type("Args", (), opts)() + buf = io.StringIO() + with patch.object(mailctl, "run_notmuch", return_value=json.dumps(raw)), \ + redirect_stdout(buf): + mailctl.cmd_senders(args) + return json.loads(buf.getvalue()) + + +def test_merges_on_address_keeping_longest_name(): + got = senders_json([ + {"name": "", "address": "a@x", "count": 112}, + {"name": "DPReview", "address": "a@x", "count": 353}, + {"name": "B", "address": "b@x", "count": 5}, + ]) + assert got == [ + {"address": "a@x", "name": "DPReview", "count": 465}, + {"address": "b@x", "name": "B", "count": 5}, + ], got + + +def test_top_truncates_after_sorting(): + got = senders_json([ + {"name": "small", "address": "s@x", "count": 1}, + {"name": "big", "address": "b@x", "count": 99}, + ], top=1) + assert [a["address"] for a in got] == ["b@x"], got + + +def test_empty(): + assert senders_json([]) == [] + + +def test_subject_terms_counts_each_term_once_per_subject(): + # "promo" three times in one subject must not outrank "sconto" in two + got = mailctl.subject_terms(["Promo promo PROMO", "sconto", "Sconto!"]) + assert got["promo"] == 1, got + assert got["sconto"] == 2, got + + +def test_subject_terms_drops_stopwords_digits_and_emoji(): + got = mailctl.subject_terms(["🔥 Le offerte di oggi for you 2024 ⏳"]) + assert set(got) == {"offerte"}, got + + +def test_subject_terms_keeps_accented_words(): + got = mailctl.subject_terms(["Località e novità"]) + assert set(got) == {"località", "novità"}, got + + +def test_subject_terms_empty(): + assert mailctl.subject_terms([]) == {} + assert mailctl.subject_terms(["", "🔥"]) == {} + + +def rejects(accounts_value, expect_in_message): + """Assert a config is refused, and that the message names the problem. + + Checking the message matters as much as the exit: these fire on the user's + own typo, and a rejection that doesn't say which account and which field + is barely better than a silent wrong answer. + """ + buf = io.StringIO() + try: + with patch("sys.stderr", buf): + mailctl.validate_accounts(accounts_value, MAIL) + except SystemExit as e: + assert e.code == 2, f"expected exit 2, got {e.code}" + msg = buf.getvalue() + assert expect_in_message in msg, f"want {expect_in_message!r} in:\n{msg}" + return + raise AssertionError(f"config was accepted but should not be: {accounts_value}") + + +def good(**overrides): + spec = {"maildir": "acct-a", "address": "a@example.org", "drafts": "Drafts"} + spec.update(overrides) + return {"accounts": {"acct-a": spec}} + + +def test_valid_config_returns_both_maps(): + accounts, drafts = mailctl.validate_accounts(json.loads( + FIXTURE_CONFIG.read_text()), MAIL) + assert accounts == {"acct-a": ("acct-a", "a@example.org"), + "acct-b": ("acct-b", "b@example.org")}, accounts + assert drafts == {"acct-a": "Drafts", "acct-b": None}, drafts + + +def test_rejects_misspelled_maildir(): + # the typo this whole gate exists for: scoping would match zero mail + rejects(good(maildir="acct-A"), "does not exist") + + +def test_rejects_misspelled_drafts_dir(): + # would write a draft into a folder mbsync never syncs back + rejects(good(drafts="Bozze"), "does not exist") + + +def test_rejects_absolute_and_traversing_maildir(): + rejects(good(maildir="/etc"), "plain subdirectory") + rejects(good(maildir="../../etc"), "plain subdirectory") + + +def test_rejects_bad_address(): + rejects(good(address="a@example"), "not a valid email address") + rejects(good(address="not-an-address"), "not a valid email address") + + +def test_rejects_unknown_field(): + # catches "addresss"/"maildirs" style typos instead of ignoring them + rejects(good(adress="a@example.org"), "unknown field") + + +def test_rejects_missing_required_field(): + spec = good() + del spec["accounts"]["acct-a"]["address"] + rejects(spec, 'missing required field "address"') + + +def test_rejects_duplicate_maildir(): + rejects({"accounts": { + "one": {"maildir": "acct-a", "address": "a@example.org", "drafts": None}, + "two": {"maildir": "acct-a", "address": "b@example.org", "drafts": None}, + }}, "is used by 2 accounts") + + +def test_rejects_structural_problems(): + rejects([], "must be a JSON object") + rejects({}, 'missing top-level "accounts" key') + rejects({"accounts": {}}, "non-empty") + rejects({"accounts": {"acct-a": "acct-a"}}, "must be an object") + rejects({"accounts": {"Acct A": good()["accounts"]["acct-a"]}}, + "invalid name") + + +def test_message_names_the_offending_account(): + buf = io.StringIO() + try: + with patch("sys.stderr", buf): + mailctl.validate_accounts({"accounts": { + "fine": {"maildir": "acct-b", "address": "b@example.org"}, + "broken": {"maildir": "nope", "address": "c@example.org"}, + }}, MAIL) + except SystemExit: + pass + assert '"broken"' in buf.getvalue(), buf.getvalue() + + +def test_drafts_null_is_allowed(): + accounts, drafts = mailctl.validate_accounts(good(drafts=None), MAIL) + assert drafts == {"acct-a": None}, drafts + + +def test_drafts_field_is_optional(): + spec = good() + del spec["accounts"]["acct-a"]["drafts"] + _, drafts = mailctl.validate_accounts(spec, MAIL) + assert drafts == {"acct-a": None}, drafts + + +def test_load_accounts_rejects_bad_json(): + bad = FIXTURE_ROOT / "bad.json" + bad.write_text("{not json") + buf = io.StringIO() + try: + with patch("sys.stderr", buf): + mailctl.load_accounts(bad, MAIL) + except SystemExit as e: + assert e.code == 2 + assert "not valid JSON" in buf.getvalue(), buf.getvalue() + return + raise AssertionError("bad JSON was accepted") + + +def test_load_accounts_missing_file_explains_how_to_create_it(): + buf = io.StringIO() + try: + with patch("sys.stderr", buf): + mailctl.load_accounts(FIXTURE_ROOT / "absent.json", MAIL) + except SystemExit as e: + assert e.code == 2 + assert "no config at" in buf.getvalue(), buf.getvalue() + return + raise AssertionError("missing config was accepted") + + +if __name__ == "__main__": + for name, fn in sorted(globals().items()): + if name.startswith("test_") and callable(fn): + fn() + print("ok") |
