aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts/tests/test_cmail_action.py
diff options
context:
space:
mode:
Diffstat (limited to '.ai/scripts/tests/test_cmail_action.py')
-rw-r--r--.ai/scripts/tests/test_cmail_action.py720
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