diff options
Diffstat (limited to '.ai/scripts/tests/test_cmail_action.py')
| -rw-r--r-- | .ai/scripts/tests/test_cmail_action.py | 720 |
1 files changed, 0 insertions, 720 deletions
diff --git a/.ai/scripts/tests/test_cmail_action.py b/.ai/scripts/tests/test_cmail_action.py deleted file mode 100644 index 6788464..0000000 --- a/.ai/scripts/tests/test_cmail_action.py +++ /dev/null @@ -1,720 +0,0 @@ -"""Tests for cmail-action.py. - -Covers: -- Pure helpers: parse_fetch_metadata, extract_body, _decode_header -- I/O commands: cmd_list_unread, cmd_read, cmd_trash, _store wrappers, - cmd_folders -- Argparse dispatch (subprocess --help) - -Strategy: import the script via importlib.util (filename has a hyphen, -so a regular `import cmail_action` won't work). Patch -cmail_action.connect to return a configured MagicMock IMAP4 instance -for the I/O tests. connect() itself is testability-blocked (network + -SSL + file I/O); manual smoke testing covers it. -""" - -from __future__ import annotations - -import email -import importlib.util -import json -import subprocess -import sys -from email.message import EmailMessage -from email.mime.application import MIMEApplication -from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText -from email.policy import default as default_policy -from pathlib import Path -from types import SimpleNamespace -from unittest.mock import MagicMock, patch - -import pytest - -SCRIPT_PATH = Path(__file__).resolve().parent.parent / "cmail-action.py" - - -def _load_module(): - spec = importlib.util.spec_from_file_location("cmail_action", str(SCRIPT_PATH)) - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - return mod - - -@pytest.fixture(scope="module") -def cmail_action(): - return _load_module() - - -# --------------------------------------------------------------------------- -# parse_fetch_metadata — pure -# --------------------------------------------------------------------------- - -class TestParseFetchMetadata: - - def test_normal_flags_and_size(self, cmail_action): - meta = "1 (FLAGS (\\Seen) RFC822.SIZE 12345)" - assert cmail_action.parse_fetch_metadata(meta) == { - "flags": "\\Seen", - "size": 12345, - } - - def test_boundary_empty_flags_zero_size(self, cmail_action): - meta = "1 (FLAGS () RFC822.SIZE 0)" - assert cmail_action.parse_fetch_metadata(meta) == { - "flags": "", - "size": 0, - } - - def test_boundary_multiple_flags(self, cmail_action): - meta = "1 (FLAGS (\\Seen \\Flagged \\Recent) RFC822.SIZE 999)" - result = cmail_action.parse_fetch_metadata(meta) - assert result["flags"] == "\\Seen \\Flagged \\Recent" - assert result["size"] == 999 - - def test_boundary_no_size_key(self, cmail_action): - meta = "1 (FLAGS (\\Recent))" - result = cmail_action.parse_fetch_metadata(meta) - assert result["flags"] == "\\Recent" - assert result["size"] is None - - def test_boundary_no_flags_key(self, cmail_action): - meta = "1 (RFC822.SIZE 500)" - result = cmail_action.parse_fetch_metadata(meta) - assert result["flags"] == "" - assert result["size"] == 500 - - def test_boundary_metadata_split_across_chunks_concatenated(self, cmail_action): - # The bug fix that motivated extracting this helper: imaplib returns - # FLAGS / RFC822.SIZE in a non-tuple chunk after the BODY literal - # closes. cmd_list_unread now concatenates all chunks, then - # parse_fetch_metadata sees the combined string. Verify the parser - # handles the combined shape. - combined = ("3315 (BODY[HEADER.FIELDS (FROM TO)] {123}" - " FLAGS () RFC822.SIZE 65546)") - result = cmail_action.parse_fetch_metadata(combined) - assert result["flags"] == "" - assert result["size"] == 65546 - - def test_error_empty_input(self, cmail_action): - assert cmail_action.parse_fetch_metadata("") == {"flags": "", "size": None} - - def test_error_malformed_size_value_does_not_raise(self, cmail_action): - meta = "1 (RFC822.SIZE notanumber)" - result = cmail_action.parse_fetch_metadata(meta) - assert result["size"] is None - - def test_error_unclosed_flags_paren_returns_empty_flags(self, cmail_action): - # Defensive: parser doesn't find a closing paren after FLAGS (, so - # flags stays empty. Size still parses since RFC822.SIZE is found - # via the independent token-scan path. - meta = "1 (FLAGS (\\Seen RFC822.SIZE 100" - result = cmail_action.parse_fetch_metadata(meta) - assert result["flags"] == "" - assert result["size"] == 100 - - -# --------------------------------------------------------------------------- -# extract_body — pure -# --------------------------------------------------------------------------- - -class TestExtractBody: - - @staticmethod - def _multipart_alt(plain="plain text body", html="<p>html body</p>"): - # Build with the legacy MIME* constructors, then round-trip - # through email.message_from_bytes with the default policy so the - # parts are EmailMessage instances with .get_content() — matching - # what cmd_read sees when imaplib hands it RFC822 bytes. - msg = MIMEMultipart("alternative") - if plain is not None: - msg.attach(MIMEText(plain, "plain")) - if html is not None: - msg.attach(MIMEText(html, "html")) - return email.message_from_bytes(msg.as_bytes(), policy=default_policy) - - def test_normal_multipart_prefers_text_plain(self, cmail_action): - msg = self._multipart_alt(plain="plain wins", html="<p>html loses</p>") - assert cmail_action.extract_body(msg) == "plain wins" - - def test_boundary_html_only_multipart_falls_back_to_html(self, cmail_action): - msg = self._multipart_alt(plain=None, html="<p>only html</p>") - result = cmail_action.extract_body(msg) - assert result is not None - assert "only html" in result - - def test_boundary_singlepart_returns_content_directly(self, cmail_action): - msg = EmailMessage() - msg.set_content("single-part body") - # set_content adds Content-Type: text/plain by default; result has - # a trailing newline from the policy formatter. - assert cmail_action.extract_body(msg).strip() == "single-part body" - - def test_error_multipart_with_no_text_parts_returns_none(self, cmail_action): - msg = MIMEMultipart("alternative") - msg.attach(MIMEApplication(b"binary blob")) - # Round-trip for parity with the parser-based path real callers use. - parsed = email.message_from_bytes(msg.as_bytes(), policy=default_policy) - assert cmail_action.extract_body(parsed) is None - - -# --------------------------------------------------------------------------- -# _decode_header — pure -# --------------------------------------------------------------------------- - -class TestDecodeHeader: - - def test_normal_string(self, cmail_action): - assert cmail_action._decode_header("hello") == "hello" - - def test_boundary_empty_string(self, cmail_action): - assert cmail_action._decode_header("") == "" - - def test_boundary_none_returns_empty(self, cmail_action): - assert cmail_action._decode_header(None) == "" - - def test_boundary_non_string_coerced_via_str(self, cmail_action): - assert cmail_action._decode_header(42) == "42" - - -# --------------------------------------------------------------------------- -# Helpers for I/O command tests -# --------------------------------------------------------------------------- - -def _build_fetch_response(uid, from_addr="alice@example.com", subject="Hello", - size=1500): - """Mimic imaplib's FETCH response shape: BODY literal as a tuple, - trailing FLAGS/SIZE/close-paren as a separate bytes chunk. - """ - headers = ( - f"From: {from_addr}\r\n" - f"To: c@cjennings.net\r\n" - f"Subject: {subject}\r\n" - f"Date: Thu, 07 May 2026 12:00:00 -0500\r\n" - ).encode() - return ("OK", [ - (f"{uid} (BODY[HEADER.FIELDS (FROM TO SUBJECT DATE)] " - f"{{{len(headers)}}}".encode(), headers), - f" FLAGS () RFC822.SIZE {size})".encode(), - ]) - - -# --------------------------------------------------------------------------- -# cmd_list_unread — mocked imaplib -# --------------------------------------------------------------------------- - -class TestCmdListUnread: - - def test_normal_three_unread(self, cmail_action, capsys): - fetch_responses = { - b"100": _build_fetch_response("100", "alice@example.com", "Hello", 1500), - b"101": _build_fetch_response("101", "bob@example.com", "Howdy", 2000), - b"102": _build_fetch_response("102", "carol@example.com", "Hi", 500), - } - - def uid_side_effect(cmd, *args): - if cmd == "SEARCH": - return ("OK", [b"100 101 102"]) - if cmd == "FETCH": - return fetch_responses[args[0]] - return ("OK", [b""]) - - mock_imap = MagicMock() - mock_imap.select.return_value = ("OK", [b""]) - mock_imap.uid.side_effect = uid_side_effect - - with patch.object(cmail_action, "connect", return_value=mock_imap): - cmail_action.cmd_list_unread(SimpleNamespace(limit=50)) - - parsed = json.loads(capsys.readouterr().out) - assert len(parsed) == 3 - assert parsed[0]["uid"] == "100" - assert parsed[0]["from"] == "alice@example.com" - assert parsed[0]["subject"] == "Hello" - assert parsed[0]["size"] == 1500 - assert parsed[2]["uid"] == "102" - - def test_boundary_zero_unread(self, cmail_action, capsys): - mock_imap = MagicMock() - mock_imap.select.return_value = ("OK", [b""]) - mock_imap.uid.side_effect = lambda cmd, *a: ("OK", [b""]) - with patch.object(cmail_action, "connect", return_value=mock_imap): - cmail_action.cmd_list_unread(SimpleNamespace(limit=50)) - assert json.loads(capsys.readouterr().out) == [] - - def test_boundary_single_unread(self, cmail_action, capsys): - def uid_se(cmd, *args): - if cmd == "SEARCH": - return ("OK", [b"42"]) - if cmd == "FETCH": - return _build_fetch_response("42", "x@y", "Solo", 100) - return ("OK", [b""]) - mock_imap = MagicMock() - mock_imap.select.return_value = ("OK", [b""]) - mock_imap.uid.side_effect = uid_se - with patch.object(cmail_action, "connect", return_value=mock_imap): - cmail_action.cmd_list_unread(SimpleNamespace(limit=50)) - parsed = json.loads(capsys.readouterr().out) - assert len(parsed) == 1 - assert parsed[0]["uid"] == "42" - - def test_boundary_limit_truncates_to_most_recent(self, cmail_action, capsys): - # 10 unread, limit=3 — keeps the last 3 (most recent). - all_uids = [str(i).encode() for i in range(100, 110)] - - def uid_se(cmd, *args): - if cmd == "SEARCH": - return ("OK", [b" ".join(all_uids)]) - if cmd == "FETCH": - return _build_fetch_response(args[0].decode(), "x@y", "S", 100) - return ("OK", [b""]) - - mock_imap = MagicMock() - mock_imap.select.return_value = ("OK", [b""]) - mock_imap.uid.side_effect = uid_se - with patch.object(cmail_action, "connect", return_value=mock_imap): - cmail_action.cmd_list_unread(SimpleNamespace(limit=3)) - parsed = json.loads(capsys.readouterr().out) - assert [p["uid"] for p in parsed] == ["107", "108", "109"] - - def test_error_search_returns_no(self, cmail_action): - mock_imap = MagicMock() - mock_imap.select.return_value = ("OK", [b""]) - mock_imap.uid.return_value = ("NO", [b"server error"]) - with patch.object(cmail_action, "connect", return_value=mock_imap): - with pytest.raises(SystemExit): - cmail_action.cmd_list_unread(SimpleNamespace(limit=50)) - - -# --------------------------------------------------------------------------- -# cmd_read — mocked imaplib -# --------------------------------------------------------------------------- - -class TestCmdRead: - - @staticmethod - def _rfc822(body="hello world", subject="Test"): - msg = EmailMessage() - msg["From"] = "alice@example.com" - msg["To"] = "c@cjennings.net" - msg["Subject"] = subject - msg["Date"] = "Thu, 07 May 2026 12:00:00 -0500" - msg.set_content(body) - return bytes(msg) - - def test_normal_prints_headers_and_body(self, cmail_action, capsys): - raw = self._rfc822(body="body content here", subject="subj") - mock_imap = MagicMock() - mock_imap.select.return_value = ("OK", [b""]) - mock_imap.uid.return_value = ("OK", [(b"1 (RFC822 {N}", raw)]) - with patch.object(cmail_action, "connect", return_value=mock_imap): - cmail_action.cmd_read(SimpleNamespace(uid=42)) - out = capsys.readouterr().out - assert "From: alice@example.com" in out - assert "Subject: subj" in out - assert "body content here" in out - - def test_error_uid_not_found(self, cmail_action): - mock_imap = MagicMock() - mock_imap.select.return_value = ("OK", [b""]) - # imaplib's shape when the UID has no match: ('OK', [None]) - mock_imap.uid.return_value = ("OK", [None]) - with patch.object(cmail_action, "connect", return_value=mock_imap): - with pytest.raises(SystemExit): - cmail_action.cmd_read(SimpleNamespace(uid=999999)) - - -# --------------------------------------------------------------------------- -# _store wrappers — STORE command shape verification -# --------------------------------------------------------------------------- - -class TestStoreCommands: - - @staticmethod - def _capture_calls(cmail_action, cmd_func, uids, store_typ="OK"): - calls = [] - mock_imap = MagicMock() - mock_imap.select.return_value = ("OK", [b""]) - - def uid_se(cmd, uid, op, flags): - calls.append((cmd, op, flags)) - return (store_typ, [b""]) - - mock_imap.uid.side_effect = uid_se - with patch.object(cmail_action, "connect", return_value=mock_imap): - cmd_func(SimpleNamespace(uids=uids)) - return calls - - def test_normal_mark_read_uses_plus_seen(self, cmail_action): - calls = self._capture_calls(cmail_action, cmail_action.cmd_mark_read, [42]) - assert calls == [("STORE", "+FLAGS", r"(\Seen)")] - - def test_normal_mark_unread_uses_minus_seen(self, cmail_action): - calls = self._capture_calls(cmail_action, cmail_action.cmd_mark_unread, [42]) - assert calls == [("STORE", "-FLAGS", r"(\Seen)")] - - def test_normal_star_uses_plus_flagged_and_seen(self, cmail_action): - calls = self._capture_calls(cmail_action, cmail_action.cmd_star, [42]) - assert calls == [("STORE", "+FLAGS", r"(\Flagged \Seen)")] - - def test_normal_unstar_uses_minus_flagged(self, cmail_action): - calls = self._capture_calls(cmail_action, cmail_action.cmd_unstar, [42]) - assert calls == [("STORE", "-FLAGS", r"(\Flagged)")] - - def test_boundary_multi_uid_calls_store_per_uid(self, cmail_action): - calls = self._capture_calls( - cmail_action, cmail_action.cmd_mark_read, [1, 2, 3] - ) - assert len(calls) == 3 - assert all(c == ("STORE", "+FLAGS", r"(\Seen)") for c in calls) - - def test_error_store_failure_raises_systemexit(self, cmail_action): - with pytest.raises(SystemExit): - self._capture_calls( - cmail_action, cmail_action.cmd_mark_read, [42], store_typ="NO" - ) - - -# --------------------------------------------------------------------------- -# cmd_trash — MOVE happy path + COPY+DELETE+EXPUNGE fallback -# --------------------------------------------------------------------------- - -class TestCmdTrash: - - def test_normal_move_succeeds_and_expunges(self, cmail_action): - mock_imap = MagicMock() - mock_imap.select.return_value = ("OK", [b""]) - mock_imap.uid.return_value = ("OK", [b""]) - mock_imap.expunge.return_value = ("OK", [b""]) - with patch.object(cmail_action, "connect", return_value=mock_imap): - cmail_action.cmd_trash(SimpleNamespace(uids=[100, 101])) - move_calls = [c for c in mock_imap.uid.call_args_list - if c[0][0] == "MOVE"] - assert len(move_calls) == 2 - assert mock_imap.expunge.called - - def test_boundary_move_fails_falls_back_to_copy_then_delete(self, cmail_action): - # MOVE returns NO -> fallback path: COPY, then STORE +FLAGS \Deleted, - # then EXPUNGE. Verify the sequence executes as documented. - seen_cmds = [] - - def uid_se(cmd, *args): - seen_cmds.append(cmd) - if cmd == "MOVE": - return ("NO", [b"not supported"]) - return ("OK", [b""]) - - mock_imap = MagicMock() - mock_imap.select.return_value = ("OK", [b""]) - mock_imap.uid.side_effect = uid_se - mock_imap.expunge.return_value = ("OK", [b""]) - with patch.object(cmail_action, "connect", return_value=mock_imap): - cmail_action.cmd_trash(SimpleNamespace(uids=[100])) - assert seen_cmds == ["MOVE", "COPY", "STORE"] - assert mock_imap.expunge.called - - def test_error_copy_also_fails(self, cmail_action): - mock_imap = MagicMock() - mock_imap.select.return_value = ("OK", [b""]) - mock_imap.uid.side_effect = lambda cmd, *a: ("NO", [b"both fail"]) - with patch.object(cmail_action, "connect", return_value=mock_imap): - with pytest.raises(SystemExit): - cmail_action.cmd_trash(SimpleNamespace(uids=[100])) - - -# --------------------------------------------------------------------------- -# cmd_folders -# --------------------------------------------------------------------------- - -class TestCmdFolders: - - def test_normal_lists_folders(self, cmail_action, capsys): - mock_imap = MagicMock() - mock_imap.list.return_value = ("OK", [ - b'(\\HasNoChildren) "/" "INBOX"', - b'(\\HasNoChildren \\Trash) "/" "Trash"', - ]) - with patch.object(cmail_action, "connect", return_value=mock_imap): - cmail_action.cmd_folders(SimpleNamespace()) - out = capsys.readouterr().out - assert "INBOX" in out - assert "Trash" in out - - def test_error_list_returns_no(self, cmail_action): - mock_imap = MagicMock() - mock_imap.list.return_value = ("NO", [b"server error"]) - with patch.object(cmail_action, "connect", return_value=mock_imap): - with pytest.raises(SystemExit): - cmail_action.cmd_folders(SimpleNamespace()) - - -# --------------------------------------------------------------------------- -# build_message — pure -# --------------------------------------------------------------------------- - -class TestBuildMessage: - - def test_normal_no_attachments_is_singlepart(self, cmail_action): - msg = cmail_action.build_message( - from_addr="c@cjennings.net", - to_addr="recipient@example.com", - subject="Hello", - body="hello world", - ) - assert msg["From"] == "c@cjennings.net" - assert msg["To"] == "recipient@example.com" - assert msg["Subject"] == "Hello" - assert not msg.is_multipart() - assert msg.get_content().strip() == "hello world" - assert msg.get_content_type() == "text/plain" - - def test_normal_one_attachment_makes_multipart(self, cmail_action): - attachment = ("report.txt", "text", "plain", b"line1\nline2\n") - msg = cmail_action.build_message( - from_addr="c@cjennings.net", - to_addr="recipient@example.com", - subject="With file", - body="see attached", - attachments=[attachment], - ) - assert msg.is_multipart() - # Find the attachment part by Content-Disposition. - attached_parts = [ - p for p in msg.iter_attachments() - if p.get_filename() == "report.txt" - ] - assert len(attached_parts) == 1 - att = attached_parts[0] - assert att.get_content_type() == "text/plain" - assert att.get_content().rstrip("\n") == "line1\nline2" - - def test_boundary_two_attachments(self, cmail_action): - atts = [ - ("a.txt", "text", "plain", b"alpha"), - ("b.bin", "application", "octet-stream", b"\x00\x01\x02"), - ] - msg = cmail_action.build_message( - from_addr="c@cjennings.net", - to_addr="recipient@example.com", - subject="Two files", - body="see attached", - attachments=atts, - ) - names = sorted(p.get_filename() for p in msg.iter_attachments()) - assert names == ["a.txt", "b.bin"] - - def test_boundary_empty_body(self, cmail_action): - msg = cmail_action.build_message( - from_addr="c@cjennings.net", - to_addr="recipient@example.com", - subject="Empty", - body="", - ) - # Body part exists, content is empty (modulo trailing newline). - assert msg.get_content().strip() == "" - - def test_boundary_unicode_preserved_through_serialization(self, cmail_action): - msg = cmail_action.build_message( - from_addr="c@cjennings.net", - to_addr="recipient@example.com", - subject="日本語 ñ ü", - body="café — naïve résumé", - ) - # Round-trip: serialize, parse, check both Subject and body survived. - raw = msg.as_bytes() - parsed = email.message_from_bytes(raw, policy=default_policy) - assert parsed["Subject"] == "日本語 ñ ü" - assert "café" in parsed.get_content() - - def test_cc_and_bcc_headers_set_from_lists(self, cmail_action): - msg = cmail_action.build_message( - from_addr="c@cjennings.net", - to_addr="to@example.com", - subject="Re: thread", - body="body", - cc=["cc1@example.com", "cc2@example.com"], - bcc=["bcc@example.com"], - ) - assert msg["Cc"] == "cc1@example.com, cc2@example.com" - assert msg["Bcc"] == "bcc@example.com" - - def test_threading_headers_set(self, cmail_action): - msg = cmail_action.build_message( - from_addr="c@cjennings.net", - to_addr="to@example.com", - subject="Re: thread", - body="body", - in_reply_to="<abc@host>", - references="<root@host> <abc@host>", - ) - assert msg["In-Reply-To"] == "<abc@host>" - assert msg["References"] == "<root@host> <abc@host>" - - def test_no_cc_bcc_or_threading_headers_when_omitted(self, cmail_action): - msg = cmail_action.build_message( - from_addr="c@cjennings.net", - to_addr="to@example.com", - subject="plain", - body="body", - ) - assert msg["Cc"] is None - assert msg["Bcc"] is None - assert msg["In-Reply-To"] is None - assert msg["References"] is None - - def test_cc_accepts_a_bare_string(self, cmail_action): - msg = cmail_action.build_message( - from_addr="c@cjennings.net", - to_addr="to@example.com", - subject="s", - body="b", - cc="solo@example.com", - ) - assert msg["Cc"] == "solo@example.com" - - -# --------------------------------------------------------------------------- -# load_attachment — file I/O via tmp_path -# --------------------------------------------------------------------------- - -class TestLoadAttachment: - - def test_normal_text_file(self, cmail_action, tmp_path): - p = tmp_path / "notes.txt" - p.write_text("hello\n") - filename, maintype, subtype, content = cmail_action.load_attachment(p) - assert filename == "notes.txt" - assert maintype == "text" - assert subtype == "plain" - assert content == b"hello\n" - - def test_normal_pdf_mime_detected(self, cmail_action, tmp_path): - p = tmp_path / "doc.pdf" - p.write_bytes(b"%PDF-1.4 fake") - filename, maintype, subtype, _ = cmail_action.load_attachment(p) - assert filename == "doc.pdf" - assert (maintype, subtype) == ("application", "pdf") - - def test_boundary_no_extension_falls_back_to_octet_stream(self, cmail_action, tmp_path): - p = tmp_path / "README" - p.write_text("readme content") - filename, maintype, subtype, _ = cmail_action.load_attachment(p) - assert filename == "README" - assert (maintype, subtype) == ("application", "octet-stream") - - def test_boundary_empty_file(self, cmail_action, tmp_path): - p = tmp_path / "empty.txt" - p.write_text("") - _, _, _, content = cmail_action.load_attachment(p) - assert content == b"" - - def test_error_missing_file_raises(self, cmail_action, tmp_path): - p = tmp_path / "does-not-exist.txt" - with pytest.raises(FileNotFoundError): - cmail_action.load_attachment(p) - - def test_error_directory_raises(self, cmail_action, tmp_path): - with pytest.raises(IsADirectoryError): - cmail_action.load_attachment(tmp_path) - - -# --------------------------------------------------------------------------- -# cmd_send — mocked smtp_connect -# --------------------------------------------------------------------------- - -class TestCmdSend: - - @staticmethod - def _args(to="r@example.com", subject="s", body="b", body_file=None, - attach=None, stdin=False, cc=None, bcc=None, - in_reply_to=None, references=None): - return SimpleNamespace( - to=to, subject=subject, - body=None if stdin else body, - body_file=body_file, - attach=attach or [], - cc=cc or [], - bcc=bcc or [], - in_reply_to=in_reply_to, - references=references, - ) - - def test_normal_inline_body_calls_send_message(self, cmail_action): - mock_smtp = MagicMock() - with patch.object(cmail_action, "smtp_connect", return_value=mock_smtp): - cmail_action.cmd_send(self._args( - to="recipient@example.com", - subject="testing cmail action script", - body="lorem ipsum dolor sit amet", - )) - mock_smtp.send_message.assert_called_once() - sent = mock_smtp.send_message.call_args[0][0] - assert sent["To"] == "recipient@example.com" - assert sent["Subject"] == "testing cmail action script" - assert sent["From"] == cmail_action.USER - assert "lorem ipsum dolor sit amet" in sent.get_content() - mock_smtp.quit.assert_called_once() - - def test_boundary_body_from_file(self, cmail_action, tmp_path): - body_file = tmp_path / "body.txt" - body_file.write_text("body from file") - mock_smtp = MagicMock() - with patch.object(cmail_action, "smtp_connect", return_value=mock_smtp): - cmail_action.cmd_send(self._args(body=None, body_file=str(body_file))) - sent = mock_smtp.send_message.call_args[0][0] - assert "body from file" in sent.get_content() - - def test_boundary_with_attachment(self, cmail_action, tmp_path): - att = tmp_path / "report.txt" - att.write_text("attachment content") - mock_smtp = MagicMock() - with patch.object(cmail_action, "smtp_connect", return_value=mock_smtp): - cmail_action.cmd_send(self._args(attach=[str(att)])) - sent = mock_smtp.send_message.call_args[0][0] - assert sent.is_multipart() - atts = list(sent.iter_attachments()) - assert len(atts) == 1 - assert atts[0].get_filename() == "report.txt" - assert atts[0].get_content().rstrip("\n") == "attachment content" - - def test_error_missing_attachment_exits_before_smtp(self, cmail_action, tmp_path): - # Attachment files are validated first; SMTP is never opened on failure. - mock_smtp = MagicMock() - with patch.object(cmail_action, "smtp_connect", return_value=mock_smtp): - with pytest.raises((SystemExit, FileNotFoundError)): - cmail_action.cmd_send(self._args( - attach=[str(tmp_path / "does-not-exist.txt")] - )) - mock_smtp.send_message.assert_not_called() - - def test_error_smtp_send_failure_raises(self, cmail_action): - import smtplib - mock_smtp = MagicMock() - mock_smtp.send_message.side_effect = smtplib.SMTPException("boom") - with patch.object(cmail_action, "smtp_connect", return_value=mock_smtp): - with pytest.raises((SystemExit, smtplib.SMTPException)): - cmail_action.cmd_send(self._args()) - - -# --------------------------------------------------------------------------- -# Argparse — black-box subprocess sanity check -# --------------------------------------------------------------------------- - -class TestArgparseShape: - - def test_normal_help_lists_all_subcommands(self): - result = subprocess.run( - [sys.executable, str(SCRIPT_PATH), "--help"], - capture_output=True, text=True, - ) - assert result.returncode == 0 - for sub in ("list-unread", "read", "mark-read", "mark-unread", - "star", "unstar", "trash", "folders", "send"): - assert sub in result.stdout - - def test_error_no_subcommand_exits_nonzero(self): - result = subprocess.run( - [sys.executable, str(SCRIPT_PATH)], - capture_output=True, text=True, - ) - assert result.returncode != 0 |
