diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-15 16:56:39 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-15 16:56:39 -0500 |
| commit | 69c5e4ace81586c05dea6a9a3afd54dafa61a73b (patch) | |
| tree | 6deab67c2a373736d5095ae9b8373c3df2add7a7 /.ai/scripts/tests | |
| download | rulesets-69c5e4ace81586c05dea6a9a3afd54dafa61a73b.tar.gz rulesets-69c5e4ace81586c05dea6a9a3afd54dafa61a73b.zip | |
Squashed 'claude-templates/' content from commit f116888
git-subtree-dir: claude-templates
git-subtree-split: f1168885580e9197dcf57b57644eb576fdca2ab1
Diffstat (limited to '.ai/scripts/tests')
30 files changed, 5231 insertions, 0 deletions
diff --git a/.ai/scripts/tests/conftest.py b/.ai/scripts/tests/conftest.py new file mode 100644 index 0000000..8d965ab --- /dev/null +++ b/.ai/scripts/tests/conftest.py @@ -0,0 +1,77 @@ +"""Shared fixtures for EML extraction tests.""" + +import os +from email.message import EmailMessage +from email.mime.application import MIMEApplication +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +import pytest + + +@pytest.fixture +def fixtures_dir(): + """Return path to the fixtures/ directory.""" + return os.path.join(os.path.dirname(__file__), 'fixtures') + + +def make_plain_message(body="Test body", from_="Jonathan Smith <jsmith@example.com>", + to="Craig <craig@example.com>", + subject="Test Subject", + date="Wed, 05 Feb 2026 11:36:00 -0600"): + """Create an EmailMessage with text/plain body.""" + msg = EmailMessage() + msg['From'] = from_ + msg['To'] = to + msg['Subject'] = subject + msg['Date'] = date + msg.set_content(body) + return msg + + +def make_html_message(html_body="<p>Test body</p>", + from_="Jonathan Smith <jsmith@example.com>", + to="Craig <craig@example.com>", + subject="Test Subject", + date="Wed, 05 Feb 2026 11:36:00 -0600"): + """Create an EmailMessage with text/html body only.""" + msg = EmailMessage() + msg['From'] = from_ + msg['To'] = to + msg['Subject'] = subject + msg['Date'] = date + msg.set_content(html_body, subtype='html') + return msg + + +def make_message_with_attachment(body="Test body", + from_="Jonathan Smith <jsmith@example.com>", + to="Craig <craig@example.com>", + subject="Test Subject", + date="Wed, 05 Feb 2026 11:36:00 -0600", + attachment_filename="document.pdf", + attachment_content=b"fake pdf content"): + """Create a multipart message with a text body and one attachment.""" + msg = MIMEMultipart() + msg['From'] = from_ + msg['To'] = to + msg['Subject'] = subject + msg['Date'] = date + + msg.attach(MIMEText(body, 'plain')) + + att = MIMEApplication(attachment_content, Name=attachment_filename) + att['Content-Disposition'] = f'attachment; filename="{attachment_filename}"' + msg.attach(att) + + return msg + + +def add_received_headers(msg, headers): + """Add Received headers to an existing message. + + headers: list of header strings, added in order (first = most recent). + """ + for header in headers: + msg['Received'] = header + return msg diff --git a/.ai/scripts/tests/fixtures/duplicate-attachment-names.eml b/.ai/scripts/tests/fixtures/duplicate-attachment-names.eml new file mode 100644 index 0000000..827d4f0 --- /dev/null +++ b/.ai/scripts/tests/fixtures/duplicate-attachment-names.eml @@ -0,0 +1,36 @@ +From: Jonathan Smith <jsmith@example.com> +To: Craig Jennings <craig@example.com> +Subject: Re: 4319 Danneel Street +Date: Mon, 27 Apr 2026 23:30:28 +0000 +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="boundary123" + +--boundary123 +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit + +Body with three inlined copies of the same signature image, mimicking +the way Outlook embeds a sender's signature once per quoted reply level. + +--boundary123 +Content-Type: image/png; name="Outlook-Ricci Part.png" +Content-Disposition: inline; filename="Outlook-Ricci Part.png" +Content-Transfer-Encoding: base64 + +aW1hZ2UtY29udGVudC0x + +--boundary123 +Content-Type: image/png; name="Outlook-Ricci Part.png" +Content-Disposition: inline; filename="Outlook-Ricci Part.png" +Content-Transfer-Encoding: base64 + +aW1hZ2UtY29udGVudC0y + +--boundary123 +Content-Type: image/png; name="Outlook-Ricci Part.png" +Content-Disposition: inline; filename="Outlook-Ricci Part.png" +Content-Transfer-Encoding: base64 + +aW1hZ2UtY29udGVudC0z + +--boundary123-- diff --git a/.ai/scripts/tests/fixtures/empty-body.eml b/.ai/scripts/tests/fixtures/empty-body.eml new file mode 100644 index 0000000..cf008df --- /dev/null +++ b/.ai/scripts/tests/fixtures/empty-body.eml @@ -0,0 +1,16 @@ +From: Jonathan Smith <jsmith@example.com> +To: Craig Jennings <craig@example.com> +Subject: Empty Body Test +Date: Thu, 05 Feb 2026 11:36:00 -0600 +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="boundary456" +Received: from mail-sender.example.com by mx.receiver.example.com with ESMTP; Thu, 05 Feb 2026 11:36:05 -0600 + +--boundary456 +Content-Type: application/octet-stream; name="data.bin" +Content-Disposition: attachment; filename="data.bin" +Content-Transfer-Encoding: base64 + +AQIDBA== + +--boundary456-- diff --git a/.ai/scripts/tests/fixtures/html-only.eml b/.ai/scripts/tests/fixtures/html-only.eml new file mode 100644 index 0000000..4db7645 --- /dev/null +++ b/.ai/scripts/tests/fixtures/html-only.eml @@ -0,0 +1,20 @@ +From: Jonathan Smith <jsmith@example.com> +To: Craig Jennings <craig@example.com> +Subject: HTML Update +Date: Thu, 05 Feb 2026 11:36:00 -0600 +MIME-Version: 1.0 +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: 7bit +Received: from mail-sender.example.com by mx.receiver.example.com with ESMTP; Thu, 05 Feb 2026 11:36:05 -0600 + +<html> +<body> +<p>Hi Craig,</p> +<p>Here is the <strong>HTML</strong> update.</p> +<ul> +<li>Item one</li> +<li>Item two</li> +</ul> +<p>Best,<br>Jonathan</p> +</body> +</html> diff --git a/.ai/scripts/tests/fixtures/multiple-received-headers.eml b/.ai/scripts/tests/fixtures/multiple-received-headers.eml new file mode 100644 index 0000000..1b8d6a7 --- /dev/null +++ b/.ai/scripts/tests/fixtures/multiple-received-headers.eml @@ -0,0 +1,12 @@ +From: Jonathan Smith <jsmith@example.com> +To: Craig Jennings <craig@example.com> +Subject: Multiple Received Headers Test +Date: Thu, 05 Feb 2026 11:36:00 -0600 +MIME-Version: 1.0 +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit +Received: by internal.example.com with SMTP; Thu, 05 Feb 2026 11:36:10 -0600 +Received: from mail-sender.example.com by mx.receiver.example.com with ESMTP; Thu, 05 Feb 2026 11:36:05 -0600 +Received: from originator.example.com by relay.example.com with SMTP; Thu, 05 Feb 2026 11:35:58 -0600 + +Test body with multiple received headers. diff --git a/.ai/scripts/tests/fixtures/no-received-headers.eml b/.ai/scripts/tests/fixtures/no-received-headers.eml new file mode 100644 index 0000000..8a05dc7 --- /dev/null +++ b/.ai/scripts/tests/fixtures/no-received-headers.eml @@ -0,0 +1,9 @@ +From: Jonathan Smith <jsmith@example.com> +To: Craig Jennings <craig@example.com> +Subject: No Received Headers +Date: Thu, 05 Feb 2026 11:36:00 -0600 +MIME-Version: 1.0 +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit + +Test body with no received headers at all. diff --git a/.ai/scripts/tests/fixtures/plain-text.eml b/.ai/scripts/tests/fixtures/plain-text.eml new file mode 100644 index 0000000..8cc9d9c --- /dev/null +++ b/.ai/scripts/tests/fixtures/plain-text.eml @@ -0,0 +1,15 @@ +From: Jonathan Smith <jsmith@example.com> +To: Craig Jennings <craig@example.com> +Subject: Re: Fw: 4319 Danneel Street +Date: Thu, 05 Feb 2026 11:36:00 -0600 +MIME-Version: 1.0 +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit +Received: from mail-sender.example.com by mx.receiver.example.com with ESMTP; Thu, 05 Feb 2026 11:36:05 -0600 + +Hi Craig, + +Here is the update on 4319 Danneel Street. + +Best, +Jonathan diff --git a/.ai/scripts/tests/fixtures/todo-sample.org b/.ai/scripts/tests/fixtures/todo-sample.org new file mode 100644 index 0000000..8b9e723 --- /dev/null +++ b/.ai/scripts/tests/fixtures/todo-sample.org @@ -0,0 +1,37 @@ +#+TITLE: Sample todo.org for todo-cleanup tests +#+AUTHOR: synthetic fixture + +# A deliberately varied (but synthetic) todo.org: umbrella "Open Work" / +# "Resolved" headings, mixed TODO/DOING/WAITING/DONE/CANCELLED states, +# priorities, tags, nested level-3 children, and a few structural (no-state) +# section headings. `--archive-done' should move only the direct level-2 +# DONE/CANCELLED subtrees from "Open Work" into "Resolved", intact, and leave +# everything else alone. + +* Sample Open Work +** TODO [#A] Write the README + This one stays — still open. +** DOING [#A] Refactor the parser + In progress; stays. +** DONE [#A] Bootstrap the test harness :tooling: + Finished. Should move to Resolved with this body intact. +** WAITING [#B] Vendor reply on the licensing question + Blocked, not done — stays. +** A grouping heading with no TODO state +*** TODO [#B] sub-task one +*** DONE [#C] sub-task two — done, but nested under an open parent, so stays +** CANCELLED [#B] Drop the legacy importer :chore: + Decided against it. Should move to Resolved. +** TODO [#B] Ship the migration :quick: +*** DONE [#C] write the up migration +*** TODO [#C] write the down migration +** DONE [#B] Tag the 1.0 release +*** DONE [#C] update the changelog +*** TODO [#C] announce on the list + Parent is DONE, so the whole subtree (open child included) moves. +** NEXT [#C] Pick the next milestone + +* Sample Resolved +** DONE [#A] Initial project skeleton + Pre-existing archived entry; new arrivals append after this one. +** CANCELLED [#C] Evaluate the other framework diff --git a/.ai/scripts/tests/fixtures/with-attachment.eml b/.ai/scripts/tests/fixtures/with-attachment.eml new file mode 100644 index 0000000..ac49c5d --- /dev/null +++ b/.ai/scripts/tests/fixtures/with-attachment.eml @@ -0,0 +1,27 @@ +From: Jonathan Smith <jsmith@example.com> +To: Craig Jennings <craig@example.com> +Subject: Ltr from Carrollton +Date: Thu, 05 Feb 2026 11:36:00 -0600 +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="boundary123" +Received: from mail-sender.example.com by mx.receiver.example.com with ESMTP; Thu, 05 Feb 2026 11:36:05 -0600 + +--boundary123 +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit + +Hi Craig, + +Please find the letter attached. + +Best, +Jonathan + +--boundary123 +Content-Type: application/octet-stream; name="Ltr Carrollton.pdf" +Content-Disposition: attachment; filename="Ltr Carrollton.pdf" +Content-Transfer-Encoding: base64 + +ZmFrZSBwZGYgY29udGVudA== + +--boundary123-- diff --git a/.ai/scripts/tests/test-lint-org.el b/.ai/scripts/tests/test-lint-org.el new file mode 100644 index 0000000..8e1ebc4 --- /dev/null +++ b/.ai/scripts/tests/test-lint-org.el @@ -0,0 +1,465 @@ +;;; test-lint-org.el --- ERT tests for lint-org.el -*- lexical-binding: t; -*- +;; +;; Run from the repo root: +;; emacs --batch -q -L .ai/scripts -l ert \ +;; -l .ai/scripts/tests/test-lint-org.el \ +;; -f ert-run-tests-batch-and-exit +;; +;; or from .ai/scripts/tests/: +;; emacs --batch -q -L .. -l ert -l test-lint-org.el \ +;; -f ert-run-tests-batch-and-exit +;; +;; Covers: mechanical auto-fixers (item-number, missing-language-in-src-block, +;; misplaced-planning-info, markdown-bold case of misplaced-heading) and +;; judgment-item emission (link-to-local-file, invalid-fuzzy-link, +;; verbatim-asterisk case of misplaced-heading, suspicious-language-in-src-block, +;; unhandled checkers). + +(require 'ert) +(require 'cl-lib) + +(defconst lo-test--dir + (file-name-directory (or load-file-name buffer-file-name default-directory)) + "Directory of this test file, captured at load time.") + +(add-to-list 'load-path (expand-file-name ".." lo-test--dir)) +(require 'lint-org) + +;;; --------------------------------------------------------------------------- +;;; Harness + +(defun lo-test--reset (&optional check followups-file) + (setq lo-fixes 0 lo-issues nil + lo-check-only (and check t) + lo-current-file nil + lo-followups-file followups-file)) + +(defun lo-test--drop-buffer (file) + (let ((buf (find-buffer-visiting file))) + (when buf + (with-current-buffer buf (set-buffer-modified-p nil)) + (kill-buffer buf)))) + +(defun lo-test--run (content &optional runs check) + "Write CONTENT to a temp .org file, run lint-org RUNS times (default 1). +Return a plist :result (final file contents) :fixes (last run) +:issues (last run). CHECK non-nil ⇒ --check (preview, no writes)." + (let ((file (make-temp-file "lo-test-" nil ".org")) + last-fixes last-issues) + (unwind-protect + (progn + (with-temp-file file (insert content)) + (dotimes (_ (or runs 1)) + (lo-test--reset check) + (lo-process-file file) + (setq last-fixes lo-fixes last-issues lo-issues) + (lo-test--drop-buffer file)) + (list :result (with-temp-buffer (insert-file-contents file) + (buffer-string)) + :fixes last-fixes + :issues last-issues)) + (lo-test--drop-buffer file) + (delete-file file)))) + +(defun lo-test--judgments (issues) + "Return judgment items from ISSUES, in document order." + (reverse + (cl-remove-if-not (lambda (i) (eq (plist-get i :kind) 'judgment)) issues))) + +(defun lo-test--mechanical (issues) + "Return mechanical-fixed items from ISSUES, in document order." + (reverse + (cl-remove-if-not (lambda (i) (eq (plist-get i :kind) 'mechanical-fixed)) + issues))) + +(defun lo-test--checkers (items) + (mapcar (lambda (i) (plist-get i :checker)) items)) + +(defun lo-test--has (string substring) + (and (string-match-p (regexp-quote substring) string) t)) + +;;; --------------------------------------------------------------------------- +;;; Fixtures + +;; item-number — bullets 4. and 5. where org expects items 3 and 4. +(defconst lo-test--item-number "\ +* Heading + +1. first +2. second + +4. out-of-order +5. and another +") + +(defconst lo-test--item-number-already-tagged "\ +* Heading + +1. first +2. second + +4. [@4] already tagged +5. [@5] also already tagged +") + +;; missing-language-in-src-block — bare #+begin_src ... #+end_src. +(defconst lo-test--bare-src "\ +* Heading + +#+begin_src +some prose without a language +#+end_src +") + +;; A src block with a language slug doesn't trip the missing-language checker. +(defconst lo-test--src-with-language "\ +* Heading + +#+begin_src text +some prose with a language +#+end_src +") + +;; misplaced-planning-info — CLOSED and DEADLINE on separate lines. +(defconst lo-test--planning-split "\ +* DONE Task +CLOSED: [2026-05-14] +DEADLINE: <2026-05-20> + +Body. +") + +;; misplaced-heading, markdown-bold case — **X.** at start of body paragraph. +(defconst lo-test--md-bold "\ +* Heading + +**Important.** Body continues here. + +More body. +") + +;; misplaced-heading, verbatim-asterisk case — =*** Foo= inside body prose. +(defconst lo-test--verbatim-asterisk "\ +* Heading + +A reference to =*** Foo= inside body prose. +") + +;; link-to-local-file — broken file: link. +(defconst lo-test--broken-file-link "\ +* Heading + +See [[file:/tmp/does-not-exist-lo-test.org][a link]]. +") + +;; invalid-fuzzy-link — link to a heading that doesn't exist in this file. +(defconst lo-test--broken-fuzzy-link "\ +* Heading + +See [[*Nonexistent Heading]]. +") + +;; suspicious-language-in-src-block — #+begin_src markdown. +(defconst lo-test--suspicious-language "\ +* Heading + +#+begin_src markdown +content +#+end_src +") + +;; Mixed fixture — each category once. +(defconst lo-test--mixed "\ +* Mixed + +1. first +2. second + +4. out-of-order + +** DONE Task +CLOSED: [2026-05-14] +DEADLINE: <2026-05-20> + +**Important.** Body. + +A reference to =*** Foo= inside body. + +See [[file:/tmp/does-not-exist-lo-test.org][a link]]. + +See [[*Nonexistent Heading]]. + +#+begin_src +prose +#+end_src + +#+begin_src markdown +content +#+end_src +") + +;;; --------------------------------------------------------------------------- +;;; item-number tests + +(ert-deftest lo-item-number-adds-counter-directive () + (let* ((out (lo-test--run lo-test--item-number)) + (res (plist-get out :result))) + (should (>= (plist-get out :fixes) 1)) + (should (lo-test--has res "4. [@4] out-of-order")) + (should (lo-test--has res "5. [@5] and another")) + ;; well-formed bullets above stay alone + (should (lo-test--has res "1. first")) + (should (lo-test--has res "2. second")))) + +(ert-deftest lo-item-number-skips-already-tagged () + (let ((out (lo-test--run lo-test--item-number-already-tagged))) + (should (= 0 (plist-get out :fixes))) + (should (equal lo-test--item-number-already-tagged (plist-get out :result))))) + +(ert-deftest lo-item-number-is-idempotent () + (let ((once (plist-get (lo-test--run lo-test--item-number 1) :result)) + (twice (plist-get (lo-test--run lo-test--item-number 2) :result))) + (should (equal once twice)))) + +;;; --------------------------------------------------------------------------- +;;; missing-language-in-src-block tests + +(ert-deftest lo-bare-src-becomes-example () + (let* ((out (lo-test--run lo-test--bare-src)) + (res (plist-get out :result))) + (should (= 1 (plist-get out :fixes))) + (should (lo-test--has res "#+begin_example")) + (should (lo-test--has res "#+end_example")) + (should-not (lo-test--has res "#+begin_src\n")) + (should-not (lo-test--has res "#+end_src")) + (should (lo-test--has res "some prose without a language")))) + +(ert-deftest lo-src-with-language-stays () + (let ((out (lo-test--run lo-test--src-with-language))) + (should (= 0 (plist-get out :fixes))) + (should (equal lo-test--src-with-language (plist-get out :result))))) + +(ert-deftest lo-bare-src-is-idempotent () + (let ((once (plist-get (lo-test--run lo-test--bare-src 1) :result)) + (twice (plist-get (lo-test--run lo-test--bare-src 2) :result))) + (should (equal once twice)))) + +;;; --------------------------------------------------------------------------- +;;; misplaced-planning-info tests + +(ert-deftest lo-planning-info-merges-onto-one-line () + (let* ((out (lo-test--run lo-test--planning-split)) + (res (plist-get out :result))) + (should (>= (plist-get out :fixes) 1)) + ;; Both keywords on the same line, exactly one blank space between values. + (should (string-match-p + "CLOSED: \\[2026-05-14\\][^\n]*DEADLINE: <2026-05-20" + res)) + ;; No stray DEADLINE: line on its own. + (should-not (string-match-p "^DEADLINE: <2026-05-20" res)))) + +(ert-deftest lo-planning-info-is-idempotent () + (let ((once (plist-get (lo-test--run lo-test--planning-split 1) :result)) + (twice (plist-get (lo-test--run lo-test--planning-split 2) :result))) + (should (equal once twice)))) + +;;; --------------------------------------------------------------------------- +;;; misplaced-heading tests + +(ert-deftest lo-markdown-bold-becomes-single-asterisk () + (let* ((out (lo-test--run lo-test--md-bold)) + (res (plist-get out :result))) + (should (= 1 (plist-get out :fixes))) + (should (lo-test--has res "*Important.* Body continues here.")) + (should-not (lo-test--has res "**Important.**")))) + +(ert-deftest lo-markdown-bold-is-idempotent () + (let ((once (plist-get (lo-test--run lo-test--md-bold 1) :result)) + (twice (plist-get (lo-test--run lo-test--md-bold 2) :result))) + (should (equal once twice)))) + +(ert-deftest lo-verbatim-asterisk-is-judgment () + (let* ((out (lo-test--run lo-test--verbatim-asterisk)) + (res (plist-get out :result)) + (judgments (lo-test--judgments (plist-get out :issues)))) + ;; File untouched. + (should (equal lo-test--verbatim-asterisk res)) + (should (= 0 (plist-get out :fixes))) + ;; Emitted as judgment with the misplaced-heading checker. + (should (member 'misplaced-heading (lo-test--checkers judgments))))) + +;;; --------------------------------------------------------------------------- +;;; Judgment-category emission tests + +(ert-deftest lo-broken-file-link-is-judgment () + (let* ((out (lo-test--run lo-test--broken-file-link)) + (res (plist-get out :result)) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should (equal lo-test--broken-file-link res)) + (should (= 0 (plist-get out :fixes))) + (should (member 'link-to-local-file (lo-test--checkers judgments))))) + +(ert-deftest lo-broken-fuzzy-link-is-judgment () + (let* ((out (lo-test--run lo-test--broken-fuzzy-link)) + (res (plist-get out :result)) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should (equal lo-test--broken-fuzzy-link res)) + (should (= 0 (plist-get out :fixes))) + (should (member 'invalid-fuzzy-link (lo-test--checkers judgments))))) + +(ert-deftest lo-suspicious-language-is-judgment () + (let* ((out (lo-test--run lo-test--suspicious-language)) + (res (plist-get out :result)) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should (equal lo-test--suspicious-language res)) + (should (= 0 (plist-get out :fixes))) + (should (member 'suspicious-language-in-src-block + (lo-test--checkers judgments))))) + +;;; --------------------------------------------------------------------------- +;;; --check mode + +(ert-deftest lo-check-mode-does-not-modify-file () + (let* ((out (lo-test--run lo-test--mixed 1 t)) + (res (plist-get out :result))) + (should (equal lo-test--mixed res)))) + +(ert-deftest lo-check-mode-reports-mechanical-and-judgment () + (let* ((out (lo-test--run lo-test--mixed 1 t)) + (issues (plist-get out :issues)) + (kinds (cl-remove-duplicates + (mapcar (lambda (i) (plist-get i :kind)) issues)))) + ;; Both kinds appear — check mode reports would-fix entries as + ;; mechanical-fixed and judgment items as judgment, no writes. + (should (member 'mechanical-fixed kinds)) + (should (member 'judgment kinds)))) + +;;; --------------------------------------------------------------------------- +;;; Mixed-fixture integration + +(ert-deftest lo-mixed-fixture-applies-all-mechanical-and-emits-judgment () + (let* ((out (lo-test--run lo-test--mixed)) + (res (plist-get out :result)) + (judgment-checkers + (cl-remove-duplicates + (lo-test--checkers (lo-test--judgments (plist-get out :issues)))))) + ;; Mechanical: every flagged item-number, bare-src, planning, md-bold fixed. + (should (>= (plist-get out :fixes) 4)) + (should (lo-test--has res "4. [@4] out-of-order")) + (should (lo-test--has res "#+begin_example")) + (should (lo-test--has res "*Important.* Body.")) + (should (string-match-p + "CLOSED: \\[2026-05-14\\][^\n]*DEADLINE: <2026-05-20" + res)) + ;; Judgment: every flagged broken link, suspicious-language, verbatim-asterisk + ;; emitted untouched. + (should (member 'link-to-local-file judgment-checkers)) + (should (member 'invalid-fuzzy-link judgment-checkers)) + (should (member 'suspicious-language-in-src-block judgment-checkers)) + (should (member 'misplaced-heading judgment-checkers)) + ;; Verbatim-asterisk untouched in the file. + (should (lo-test--has res "=*** Foo=")))) + +(ert-deftest lo-mixed-fixture-is-idempotent () + (let ((once (plist-get (lo-test--run lo-test--mixed 1) :result)) + (twice (plist-get (lo-test--run lo-test--mixed 2) :result))) + (should (equal once twice)))) + +;;; --------------------------------------------------------------------------- +;;; Backup file is created in /tmp + +;;; --------------------------------------------------------------------------- +;;; Follow-ups file behavior + +(ert-deftest lo-followups-file-appends-judgments () + (let ((followups (make-temp-file "lo-followups-" nil ".org")) + (file (make-temp-file "lo-test-fup-" nil ".org"))) + (unwind-protect + (progn + (with-temp-file file (insert lo-test--mixed)) + (with-temp-file followups (insert "")) + (lo-test--reset nil followups) + (lo-process-file file) + (lo-emit-report) + (lo-test--drop-buffer file) + (let ((content (with-temp-buffer + (insert-file-contents followups) + (buffer-string)))) + ;; Dated section header. + (should (string-match-p + (format "^\\* %s lint-org follow-ups" + (format-time-string "%Y-%m-%d")) + content)) + ;; Each judgment is a TODO line referencing checker + line number. + (should (string-match-p "TODO line [0-9]+ — link-to-local-file" content)) + (should (string-match-p "TODO line [0-9]+ — invalid-fuzzy-link" content)) + (should (string-match-p + "TODO line [0-9]+ — suspicious-language-in-src-block" + content)))) + (lo-test--drop-buffer file) + (when (file-exists-p file) (delete-file file)) + (when (file-exists-p followups) (delete-file followups))))) + +(ert-deftest lo-followups-file-skipped-in-check-mode () + (let ((followups (make-temp-file "lo-followups-" nil ".org")) + (file (make-temp-file "lo-test-fup-check-" nil ".org"))) + (unwind-protect + (progn + (with-temp-file file (insert lo-test--mixed)) + (with-temp-file followups (insert "")) + (lo-test--reset t followups) ; check=t, followups set + (lo-process-file file) + (lo-emit-report) + (lo-test--drop-buffer file) + ;; followups untouched in check mode + (should (equal "" (with-temp-buffer + (insert-file-contents followups) + (buffer-string))))) + (lo-test--drop-buffer file) + (when (file-exists-p file) (delete-file file)) + (when (file-exists-p followups) (delete-file followups))))) + +(ert-deftest lo-followups-file-noop-when-no-judgments () + ;; A fixture with only mechanical issues should leave the followups file empty. + (let ((followups (make-temp-file "lo-followups-" nil ".org")) + (file (make-temp-file "lo-test-fup-empty-" nil ".org"))) + (unwind-protect + (progn + (with-temp-file file (insert lo-test--item-number)) + (with-temp-file followups (insert "")) + (lo-test--reset nil followups) + (lo-process-file file) + (lo-emit-report) + (lo-test--drop-buffer file) + (should (equal "" (with-temp-buffer + (insert-file-contents followups) + (buffer-string))))) + (lo-test--drop-buffer file) + (when (file-exists-p file) (delete-file file)) + (when (file-exists-p followups) (delete-file followups))))) + +(ert-deftest lo-creates-backup-before-modifying () + (let ((file (make-temp-file "lo-test-bak-" nil ".org"))) + (unwind-protect + (progn + (with-temp-file file (insert lo-test--bare-src)) + (lo-test--reset) + (lo-process-file file) + (lo-test--drop-buffer file) + ;; Backup pattern in lint-org.el: /tmp/<basename>.before-lint-pass.<timestamp> + (let* ((basename (file-name-nondirectory file)) + (backups (directory-files "/tmp" t + (concat (regexp-quote basename) + "\\.before-lint-pass\\.")))) + (should (>= (length backups) 1)) + ;; Backup content matches pre-fix content. + (let ((backup (car backups))) + (with-temp-buffer + (insert-file-contents backup) + (should (equal lo-test--bare-src (buffer-string)))) + (delete-file backup)))) + (lo-test--drop-buffer file) + (when (file-exists-p file) (delete-file file))))) + +(provide 'test-lint-org) +;;; test-lint-org.el ends here diff --git a/.ai/scripts/tests/test-todo-cleanup.el b/.ai/scripts/tests/test-todo-cleanup.el new file mode 100644 index 0000000..5d43f97 --- /dev/null +++ b/.ai/scripts/tests/test-todo-cleanup.el @@ -0,0 +1,518 @@ +;;; test-todo-cleanup.el --- ERT tests for todo-cleanup.el -*- lexical-binding: t; -*- +;; +;; Run from the repo root: +;; emacs --batch -q -L .ai/scripts -l ert \ +;; -l .ai/scripts/tests/test-todo-cleanup.el \ +;; -f ert-run-tests-batch-and-exit +;; +;; or from .ai/scripts/tests/: +;; emacs --batch -q -L .. -l ert -l test-todo-cleanup.el \ +;; -f ert-run-tests-batch-and-exit +;; +;; Covers the `--archive-done' mode: moving level-2 DONE/CANCELLED subtrees +;; out of the "Open Work" section into the "Resolved" section. + +(require 'ert) +(require 'cl-lib) + +(defconst tc-test--dir + (file-name-directory (or load-file-name buffer-file-name default-directory)) + "Directory of this test file, captured at load time.") + +;; Make `todo-cleanup' loadable from the parent directory. Loading it is +;; inert: its CLI dispatch only fires when the trailing command-line args look +;; like a real invocation (recognized flags / readable file paths), which they +;; don't during `ert-run-tests-batch-and-exit'. +(add-to-list 'load-path (expand-file-name ".." tc-test--dir)) +(require 'todo-cleanup) + +;;; --------------------------------------------------------------------------- +;;; Harness + +(defun tc-test--reset (&optional check) + (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-issues nil + tc-check-only (and check t) + tc-archive-done t tc-sync-child-priority nil + tc-current-file nil)) + +(defun tc-test--reset-sync (&optional check) + (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-issues nil + tc-check-only (and check t) + tc-archive-done nil tc-sync-child-priority t + tc-current-file nil)) + +(defun tc-test--drop-buffer (file) + (let ((buf (find-buffer-visiting file))) + (when buf + (with-current-buffer buf (set-buffer-modified-p nil)) + (kill-buffer buf)))) + +(defun tc-test--archive (content &optional runs check) + "Write CONTENT to a temp .org file, run `--archive-done' RUNS times (default 1). +Return a plist: :result final file contents, :archived count from the last run, +:issues from the last run. CHECK non-nil ⇒ --check (preview, no writes)." + (let ((file (make-temp-file "tc-test-" nil ".org")) + last-archived last-issues) + (unwind-protect + (progn + (with-temp-file file (insert content)) + (dotimes (_ (or runs 1)) + (tc-test--reset check) + (tc-process-file file) + (setq last-archived tc-archived last-issues tc-issues) + (tc-test--drop-buffer file)) + (list :result (with-temp-buffer (insert-file-contents file) + (buffer-string)) + :archived last-archived + :issues last-issues)) + (tc-test--drop-buffer file) + (delete-file file)))) + +(defun tc-test--section (content needle) + "Text of the level-1 section in CONTENT whose heading line contains NEEDLE — +from the heading line through (not including) the next level-1 heading or EOF." + (with-temp-buffer + (insert content) + (goto-char (point-min)) + (let (start) + (while (and (not start) (re-search-forward "^\\* .*$" nil t)) + (when (string-match-p (regexp-quote needle) (match-string 0)) + (setq start (match-beginning 0)))) + (unless start (error "no level-1 heading containing %S" needle)) + (goto-char start) + (forward-line 1) + (buffer-substring-no-properties + start + (if (re-search-forward "^\\* " nil t) (match-beginning 0) (point-max)))))) + +(defun tc-test--has (string substring) + (and (string-match-p (regexp-quote substring) string) t)) + +(defun tc-test--before-p (string a b) + "Non-nil when SUBSTRING A occurs before SUBSTRING B in STRING." + (let ((ia (string-match (regexp-quote a) string)) + (ib (string-match (regexp-quote b) string))) + (and ia ib (< ia ib)))) + +(defun tc-test--skip-detail (issues) + (let ((skip (cl-find-if (lambda (i) (eq (plist-get i :kind) 'archive-skip)) issues))) + (and skip (plist-get skip :detail)))) + +(defun tc-test--moved-headings (issues) + (mapcar (lambda (i) (plist-get i :heading)) + (cl-remove-if-not + (lambda (i) (memq (plist-get i :kind) '(archive-moved archive-would))) + (reverse issues)))) + +;;; --------------------------------------------------------------------------- +;;; Fixtures (synthetic — real project todo.org files are examples only) + +(defconst tc-test--basic "\ +* Demo Open Work +** TODO [#A] First open task + first body +** DONE [#A] A finished task + finished body +** TODO [#B] Another open task +* Demo Resolved +** DONE [#A] Previously archived +") + +(defconst tc-test--mixed "\ +* Proj Open Work +** TODO Keep me open +** DONE Done one +*** TODO leftover child of done one +** A structural heading with no state +** CANCELLED Cancelled two :quick: +** TODO Has a done child +*** DONE this nested done stays +** DONE Done three +* Proj Resolved +** DONE Old archived item +") + +(defconst tc-test--nothing "\ +* X Open Work +** TODO a +** WAITING b +** NEXT c +* X Resolved +** DONE old +") + +(defconst tc-test--no-resolved "\ +* Y Open Work +** DONE finished +** TODO ongoing +") + +(defconst tc-test--no-open "\ +* Z Resolved +** DONE old +* Some Other Section +** TODO whatever +") + +(defconst tc-test--two-resolved "\ +* P Open Work +** DONE done +* P Resolved +** DONE old1 +* Q Resolved Notes +** DONE old2 +") + +;; No trailing newline — exercises the EOF / final-line case. Open Work is the +;; last section, so a DONE level-2 here is also the last subtree in the file. +(defconst tc-test--eof "\ +* W Resolved +** DONE pre-existing +* W Open Work +** TODO keep open +** DONE last thing + body of last thing") + +(defconst tc-test--lowercase "\ +* winvm open work +** TODO test rebuilt vm +** DONE fix display resolution +* winvm resolved +** DONE fork linoffice as winvm +") + +;;; --------------------------------------------------------------------------- +;;; Tests + +(ert-deftest tc-archive-moves-one-done-level-2 () + (let* ((out (tc-test--archive tc-test--basic)) + (res (plist-get out :result)) + (open (tc-test--section res "Demo Open Work")) + (resolved (tc-test--section res "Demo Resolved"))) + (should (= 1 (plist-get out :archived))) + (should (tc-test--has resolved "A finished task")) + (should (tc-test--has resolved "finished body")) + (should-not (tc-test--has open "A finished task")) + (should (tc-test--has open "First open task")) + (should (tc-test--has open "Another open task")) + ;; appended at the end of the Resolved section + (should (tc-test--before-p resolved "Previously archived" "A finished task")))) + +(ert-deftest tc-archive-moves-multiple-done-and-cancelled () + (let* ((out (tc-test--archive tc-test--mixed)) + (res (plist-get out :result)) + (open (tc-test--section res "Proj Open Work")) + (resolved (tc-test--section res "Proj Resolved"))) + (should (= 3 (plist-get out :archived))) + ;; stays in Open Work + (should (tc-test--has open "Keep me open")) + (should (tc-test--has open "A structural heading with no state")) + (should (tc-test--has open "Has a done child")) + (should (tc-test--has open "this nested done stays")) + ;; moved to Resolved + (should (tc-test--has resolved "Done one")) + (should (tc-test--has resolved "Cancelled two")) + (should (tc-test--has resolved "Done three")) + ;; a level-2 DONE moves its (open) children along with it + (should (tc-test--has resolved "leftover child of done one")) + (should-not (tc-test--has open "leftover child of done one")) + ;; gone from Open Work + (should-not (tc-test--has open "Done one")) + (should-not (tc-test--has open "Cancelled two")) + (should-not (tc-test--has open "Done three")) + ;; order: pre-existing first, then in document order + (should (tc-test--before-p resolved "Old archived item" "Done one")) + (should (tc-test--before-p resolved "Done one" "Cancelled two")) + (should (tc-test--before-p resolved "Cancelled two" "Done three")))) + +(ert-deftest tc-archive-structural-heading-does-not-move () + (let* ((out (tc-test--archive tc-test--mixed)) + (open (tc-test--section (plist-get out :result) "Proj Open Work"))) + (should (tc-test--has open "A structural heading with no state")))) + +(ert-deftest tc-archive-nothing-to-do-is-noop () + (let ((out (tc-test--archive tc-test--nothing))) + (should (= 0 (plist-get out :archived))) + (should (equal tc-test--nothing (plist-get out :result))))) + +(ert-deftest tc-archive-missing-resolved-section-skips () + (let ((out (tc-test--archive tc-test--no-resolved))) + (should (= 0 (plist-get out :archived))) + (should (equal tc-test--no-resolved (plist-get out :result))) + (should (string-match-p "Resolved" (or (tc-test--skip-detail (plist-get out :issues)) ""))))) + +(ert-deftest tc-archive-missing-open-work-section-skips () + (let ((out (tc-test--archive tc-test--no-open))) + (should (= 0 (plist-get out :archived))) + (should (equal tc-test--no-open (plist-get out :result))) + (should (string-match-p "Open Work" (or (tc-test--skip-detail (plist-get out :issues)) ""))))) + +(ert-deftest tc-archive-ambiguous-resolved-section-skips () + (let ((out (tc-test--archive tc-test--two-resolved))) + (should (= 0 (plist-get out :archived))) + (should (equal tc-test--two-resolved (plist-get out :result))) + (should (string-match-p "Resolved" (or (tc-test--skip-detail (plist-get out :issues)) ""))))) + +(ert-deftest tc-archive-subtree-at-eof () + (let* ((out (tc-test--archive tc-test--eof)) + (res (plist-get out :result)) + (open (tc-test--section res "W Open Work")) + (resolved (tc-test--section res "W Resolved"))) + (should (= 1 (plist-get out :archived))) + (should (tc-test--has resolved "last thing")) + (should (tc-test--has resolved "body of last thing")) + (should (tc-test--has open "keep open")) + (should-not (tc-test--has open "last thing")) + ;; result stays well-formed: a newline separates the moved body from the + ;; following section heading + (should (string-match-p "body of last thing\n\\* W Open Work" res)))) + +(ert-deftest tc-archive-matches-lowercase-headings () + (let* ((out (tc-test--archive tc-test--lowercase)) + (res (plist-get out :result)) + (open (tc-test--section res "winvm open work")) + (resolved (tc-test--section res "winvm resolved"))) + (should (= 1 (plist-get out :archived))) + (should (tc-test--has resolved "fix display resolution")) + (should-not (tc-test--has open "fix display resolution")) + (should (tc-test--has open "test rebuilt vm")))) + +(ert-deftest tc-archive-is-idempotent () + (dolist (fixture (list tc-test--basic tc-test--mixed tc-test--eof + tc-test--lowercase tc-test--nothing)) + (let ((once (plist-get (tc-test--archive fixture 1) :result)) + (twice (plist-get (tc-test--archive fixture 2) :result))) + (should (equal once twice))))) + +(ert-deftest tc-archive-check-mode-previews-without-writing () + (let ((out (tc-test--archive tc-test--basic 1 t))) + (should (= 1 (plist-get out :archived))) + (should (equal tc-test--basic (plist-get out :result))) + (should (member "A finished task" (tc-test--moved-headings (plist-get out :issues)))))) + +(ert-deftest tc-archive-check-mode-is-idempotent () + (let ((once (tc-test--archive tc-test--mixed 1 t)) + (twice (tc-test--archive tc-test--mixed 2 t))) + (should (equal tc-test--mixed (plist-get once :result))) + (should (equal tc-test--mixed (plist-get twice :result))) + (should (= 3 (plist-get once :archived))) + (should (= 3 (plist-get twice :archived))))) + +;;; --------------------------------------------------------------------------- +;;; Realistic synthetic sample (committed under fixtures/) + +(defun tc-test--sample-file () + (expand-file-name "fixtures/todo-sample.org" tc-test--dir)) + +(ert-deftest tc-archive-realistic-sample () + (let* ((src (tc-test--sample-file))) + (skip-unless (file-readable-p src)) + (let* ((content (with-temp-buffer (insert-file-contents src) (buffer-string))) + (out (tc-test--archive content)) + (res (plist-get out :result)) + (out2 (tc-test--archive content 2))) + ;; every DONE/CANCELLED level-2 entry under "Open Work" moved out + (let ((open (tc-test--section res "Sample Open Work"))) + (should-not (string-match-p "^\\*\\* \\(DONE\\|CANCELLED\\) " open))) + ;; structural and still-open level-2 entries stayed + (let ((open (tc-test--section res "Sample Open Work"))) + (should (string-match-p "^\\*\\* TODO " open)) + (should (string-match-p "^\\*\\* DOING " open))) + ;; idempotent + (should (equal res (plist-get out2 :result))) + ;; something actually moved + (should (> (plist-get out :archived) 0))))) + +;;; --------------------------------------------------------------------------- +;;; Sync-child-priority harness + fixtures + +(defun tc-test--sync (content &optional runs check) + "Write CONTENT to a temp .org file, run `--sync-child-priority' RUNS times +\(default 1\). Return a plist: :result final file contents, :bumped count from +the last run, :issues from the last run. CHECK non-nil ⇒ --check (preview)." + (let ((file (make-temp-file "tc-test-sync-" nil ".org")) + last-bumped last-issues) + (unwind-protect + (progn + (with-temp-file file (insert content)) + (dotimes (_ (or runs 1)) + (tc-test--reset-sync check) + (tc-process-file file) + (setq last-bumped tc-bumped last-issues tc-issues) + (tc-test--drop-buffer file)) + (list :result (with-temp-buffer (insert-file-contents file) + (buffer-string)) + :bumped last-bumped + :issues last-issues)) + (tc-test--drop-buffer file) + (delete-file file)))) + +(defun tc-test--priority-of (content heading-substring) + "Return the priority letter (a string like \"A\") on the first heading line +in CONTENT that contains HEADING-SUBSTRING, or nil if the heading has no +priority cookie." + (with-temp-buffer + (insert content) + (goto-char (point-min)) + (let (found-line found-prio) + (while (and (not found-line) (re-search-forward "^\\*+ .*$" nil t)) + (let ((line (match-string 0))) + (when (string-match-p (regexp-quote heading-substring) line) + (setq found-line line) + (when (string-match "\\[#\\([A-Z]\\)\\]" line) + (setq found-prio (match-string 1 line)))))) + (unless found-line + (error "no heading containing %S" heading-substring)) + found-prio))) + +(defun tc-test--sync-bumped-headings (issues) + "Return the heading texts of every `:kind' sync-bumped or sync-would entry +in ISSUES, in document order." + (mapcar (lambda (i) (plist-get i :child-heading)) + (cl-remove-if-not + (lambda (i) (memq (plist-get i :kind) '(sync-bumped sync-would))) + (reverse issues)))) + +(defconst tc-test--sync-basic "\ +* Open Work +** TODO [#B] Parent +*** TODO [#D] Drifted child +*** TODO [#B] Already in sync +") + +(defconst tc-test--sync-multi "\ +* Open Work +** TODO [#B] Parent +*** TODO [#A] Higher-priority child stays +*** TODO [#B] Equal-priority child stays +*** TODO [#C] Lower-priority child bumps +*** TODO [#D] Way-lower-priority child bumps +*** TODO Priority-less child stays +") + +(defconst tc-test--sync-no-sync-tag "\ +* Open Work +** TODO [#B] Parent +*** TODO [#D] Regular drifted child +*** TODO [#D] Follow-up: opted-out :no-sync: +") + +(defconst tc-test--sync-priority-less-parent "\ +* Open Work +** TODO Parent with no priority +*** TODO [#D] Child with priority should not move +") + +(defconst tc-test--sync-cascade "\ +* Open Work +** TODO [#A] Top +*** TODO [#B] Middle +**** TODO [#D] Leaf +") + +(defconst tc-test--sync-no-change "\ +* Open Work +** TODO [#B] Parent +*** TODO [#A] Child higher +*** TODO [#B] Child equal +") + +;; A dated-log heading inside a parent task whose title quotes other priorities +;; in =[#X]= verbatim. Those quoted cookies must NOT be read as the heading's +;; own priority — the cookie has to sit in canonical position to count. +(defconst tc-test--sync-cookie-in-title "\ +* Open Work +** TODO [#B] Parent +*** 2026-05-14 Reprioritized children =[#D]= → =[#B]= to match parent +*** TODO [#D] Regular drifted child +") + +;;; --------------------------------------------------------------------------- +;;; Sync-child-priority tests + +(ert-deftest tc-sync-bumps-lower-priority-child () + (let* ((out (tc-test--sync tc-test--sync-basic)) + (res (plist-get out :result))) + (should (= 1 (plist-get out :bumped))) + (should (equal "B" (tc-test--priority-of res "Drifted child"))) + (should (equal "B" (tc-test--priority-of res "Already in sync"))) + (should (equal "B" (tc-test--priority-of res "Parent"))))) + +(ert-deftest tc-sync-leaves-higher-and-equal-children-alone () + (let* ((out (tc-test--sync tc-test--sync-multi)) + (res (plist-get out :result))) + (should (= 2 (plist-get out :bumped))) + (should (equal "A" (tc-test--priority-of res "Higher-priority child"))) + (should (equal "B" (tc-test--priority-of res "Equal-priority child"))) + (should (equal "B" (tc-test--priority-of res "Lower-priority child"))) + (should (equal "B" (tc-test--priority-of res "Way-lower-priority child"))) + (should-not (tc-test--priority-of res "Priority-less child")))) + +(ert-deftest tc-sync-skips-no-sync-tagged-child () + (let* ((out (tc-test--sync tc-test--sync-no-sync-tag)) + (res (plist-get out :result))) + (should (= 1 (plist-get out :bumped))) + (should (equal "B" (tc-test--priority-of res "Regular drifted child"))) + (should (equal "D" (tc-test--priority-of res "Follow-up: opted-out"))))) + +(ert-deftest tc-sync-leaves-priority-less-parent-alone () + (let ((out (tc-test--sync tc-test--sync-priority-less-parent))) + (should (= 0 (plist-get out :bumped))) + (should (equal tc-test--sync-priority-less-parent (plist-get out :result))))) + +(ert-deftest tc-sync-cascades-through-multiple-levels () + (let* ((out (tc-test--sync tc-test--sync-cascade)) + (res (plist-get out :result))) + ;; one pass should collapse [#A] → [#B] → [#D] to all [#A] because + ;; org-map-entries visits the parent first, bumps the middle, then visits + ;; the (now bumped) middle and bumps its leaf + (should (= 2 (plist-get out :bumped))) + (should (equal "A" (tc-test--priority-of res "Top"))) + (should (equal "A" (tc-test--priority-of res "Middle"))) + (should (equal "A" (tc-test--priority-of res "Leaf"))))) + +(ert-deftest tc-sync-no-change-when-all-children-at-or-above-parent () + (let ((out (tc-test--sync tc-test--sync-no-change))) + (should (= 0 (plist-get out :bumped))) + (should (equal tc-test--sync-no-change (plist-get out :result))))) + +(ert-deftest tc-sync-ignores-cookie-shaped-text-in-title () + (let* ((out (tc-test--sync tc-test--sync-cookie-in-title)) + (res (plist-get out :result))) + ;; Only the real drifted child bumps; the dated-log heading with + ;; =[#D]= / =[#B]= verbatim text in its title is untouched. + (should (= 1 (plist-get out :bumped))) + (should (equal "B" (tc-test--priority-of res "Regular drifted child"))) + ;; Substring still appears in the dated-log heading; the heading itself + ;; was not rewritten. + (should (string-match-p "Reprioritized children =\\[#D\\]= → =\\[#B\\]= to match parent" res)))) + +(ert-deftest tc-sync-is-idempotent () + (dolist (fixture (list tc-test--sync-basic + tc-test--sync-multi + tc-test--sync-no-sync-tag + tc-test--sync-priority-less-parent + tc-test--sync-cascade + tc-test--sync-no-change + tc-test--sync-cookie-in-title)) + (let ((once (plist-get (tc-test--sync fixture 1) :result)) + (twice (plist-get (tc-test--sync fixture 2) :result))) + (should (equal once twice))))) + +(ert-deftest tc-sync-check-mode-previews-without-writing () + (let ((out (tc-test--sync tc-test--sync-basic 1 t))) + (should (= 1 (plist-get out :bumped))) + (should (equal tc-test--sync-basic (plist-get out :result))) + (should (member "Drifted child" + (tc-test--sync-bumped-headings (plist-get out :issues)))))) + +(ert-deftest tc-sync-check-mode-is-idempotent () + (let ((once (tc-test--sync tc-test--sync-cascade 1 t)) + (twice (tc-test--sync tc-test--sync-cascade 2 t))) + (should (equal tc-test--sync-cascade (plist-get once :result))) + (should (equal tc-test--sync-cascade (plist-get twice :result))) + (should (= 2 (plist-get once :bumped))) + (should (= 2 (plist-get twice :bumped))))) + +(provide 'test-todo-cleanup) +;;; test-todo-cleanup.el ends here diff --git a/.ai/scripts/tests/test_cj_remove_block.py b/.ai/scripts/tests/test_cj_remove_block.py new file mode 100644 index 0000000..2c8dade --- /dev/null +++ b/.ai/scripts/tests/test_cj_remove_block.py @@ -0,0 +1,157 @@ +"""Tests for cj-remove-block.py — idempotent removal of cj annotations by line range. + +The script removes lines [start, end] (1-indexed, inclusive) from an org file but +validates first that those lines actually look like a cj annotation. Refusing on +mismatch protects against accidentally trimming the wrong block when line numbers +drift between scan and remove calls. +""" + +import subprocess +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).parent.parent / "cj-remove-block.py" + + +@pytest.fixture +def run_remove(tmp_path): + """Write content to a temp org file, run cj-remove-block, return new contents.""" + def _run(content: str, start: int, end: int) -> str: + f = tmp_path / "test.org" + f.write_text(content) + subprocess.run( + ["python3", str(SCRIPT), + "--file", str(f), + "--start", str(start), + "--end", str(end)], + check=True, + capture_output=True, + ) + return f.read_text() + return _run + + +@pytest.fixture +def run_remove_expecting_failure(tmp_path): + """Write content, run cj-remove-block expecting non-zero exit; return CalledProcessError.""" + def _run(content: str, start: int, end: int): + f = tmp_path / "test.org" + f.write_text(content) + with pytest.raises(subprocess.CalledProcessError) as excinfo: + subprocess.run( + ["python3", str(SCRIPT), + "--file", str(f), + "--start", str(start), + "--end", str(end)], + check=True, + capture_output=True, + ) + return excinfo.value, f.read_text() # file should be unchanged on failure + return _run + + +# ---------------------------------------------------------------------- +# Source-block removal +# ---------------------------------------------------------------------- + +class TestCjRemoveBlockSourceBlock: + """Removing #+begin_src cj: ... #+end_src blocks.""" + + def test_cj_remove_block_minimal_three_line_source_block(self, run_remove): + """Normal: the three lines of a minimal source-block are removed.""" + content = "* S\n#+begin_src cj: comment\nbody\n#+end_src\nafter\n" + result = run_remove(content, start=2, end=4) + assert result == "* S\nafter\n" + + def test_cj_remove_block_source_block_multiline_body(self, run_remove): + """Normal: source-block with multi-line body removed cleanly.""" + content = "* S\n#+begin_src cj: comment\nline 1\nline 2\nline 3\n#+end_src\nafter\n" + result = run_remove(content, start=2, end=6) + assert result == "* S\nafter\n" + + def test_cj_remove_block_preserves_lines_before_and_after(self, run_remove): + """Normal: surrounding lines outside the range stay intact.""" + content = "before\n#+begin_src cj: comment\nx\n#+end_src\nafter\n" + result = run_remove(content, start=2, end=4) + assert result == "before\nafter\n" + + def test_cj_remove_block_source_block_with_label_variant(self, run_remove): + """Boundary: source-block with no trailing label (#+begin_src cj:) also removable.""" + content = "* S\n#+begin_src cj:\nbody\n#+end_src\nafter\n" + result = run_remove(content, start=2, end=4) + assert result == "* S\nafter\n" + + def test_cj_remove_block_case_insensitive_fence(self, run_remove): + """Boundary: case-variant fences (#+BEGIN_SRC / #+END_SRC) also removable.""" + content = "* S\n#+BEGIN_SRC cj: comment\nbody\n#+END_SRC\nafter\n" + result = run_remove(content, start=2, end=4) + assert result == "* S\nafter\n" + + +# ---------------------------------------------------------------------- +# Legacy-inline removal +# ---------------------------------------------------------------------- + +class TestCjRemoveBlockLegacyInline: + """Removing single-line legacy `cj: ...` annotations.""" + + def test_cj_remove_block_legacy_inline_single_line(self, run_remove): + """Normal: single legacy-inline cj line removed.""" + content = "* S\ncj: legacy note\nafter\n" + result = run_remove(content, start=2, end=2) + assert result == "* S\nafter\n" + + def test_cj_remove_block_legacy_inline_at_eof(self, run_remove): + """Boundary: legacy-inline cj at last line; file ends cleanly.""" + content = "* S\ncj: at end\n" + result = run_remove(content, start=2, end=2) + assert result == "* S\n" + + +# ---------------------------------------------------------------------- +# Refusal-on-mismatch safety +# ---------------------------------------------------------------------- + +class TestCjRemoveBlockSafety: + """Refuses to remove if the specified range doesn't look like a cj annotation.""" + + def test_cj_remove_block_refuses_non_cj_single_line(self, run_remove_expecting_failure): + """Error: a single non-cj line is rejected.""" + err, post_content = run_remove_expecting_failure( + "* S\nthis is not a cj line\nafter\n", start=2, end=2, + ) + assert err.returncode != 0 + # File must be unchanged + assert post_content == "* S\nthis is not a cj line\nafter\n" + + def test_cj_remove_block_refuses_mismatched_fence(self, run_remove_expecting_failure): + """Error: multi-line range where line N isn't an opening fence is rejected.""" + err, post_content = run_remove_expecting_failure( + "* S\nbody1\nbody2\n#+end_src\nafter\n", start=2, end=4, + ) + assert err.returncode != 0 + assert "body1" in post_content # file unchanged + + def test_cj_remove_block_refuses_missing_closing_fence(self, run_remove_expecting_failure): + """Error: multi-line range where line M isn't a closing fence is rejected.""" + err, post_content = run_remove_expecting_failure( + "* S\n#+begin_src cj: comment\nbody\nnot-a-close\nafter\n", start=2, end=4, + ) + assert err.returncode != 0 + assert "not-a-close" in post_content + + def test_cj_remove_block_refuses_out_of_bounds(self, run_remove_expecting_failure): + """Error: range outside the file is rejected, file unchanged.""" + err, post_content = run_remove_expecting_failure( + "* S\nafter\n", start=5, end=7, + ) + assert err.returncode != 0 + assert post_content == "* S\nafter\n" + + def test_cj_remove_block_refuses_inverted_range(self, run_remove_expecting_failure): + """Error: end < start is rejected, file unchanged.""" + original = "* S\n#+begin_src cj: comment\nbody\n#+end_src\n" + err, post_content = run_remove_expecting_failure(original, start=4, end=2) + assert err.returncode != 0 + assert post_content == original diff --git a/.ai/scripts/tests/test_cj_scan.py b/.ai/scripts/tests/test_cj_scan.py new file mode 100644 index 0000000..7844474 --- /dev/null +++ b/.ai/scripts/tests/test_cj_scan.py @@ -0,0 +1,250 @@ +"""Tests for cj-scan.py — org-file cj-annotation scanner. + +The script parses an org file and emits JSON describing: +- cj_blocks: every cj annotation found (source-block or legacy-inline form) +- verify_tasks: every VERIFY heading + placement validity (top-level or first-level child only) +- unclosed_blocks: any source-block fence that opened but never closed +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).parent.parent / "cj-scan.py" + + +@pytest.fixture +def run_scan(tmp_path): + """Write content to a temp org file and run cj-scan; return parsed JSON output.""" + def _run(content: str) -> dict: + f = tmp_path / "test.org" + f.write_text(content) + result = subprocess.run( + ["python3", str(SCRIPT), str(f)], + capture_output=True, + text=True, + check=True, + ) + return json.loads(result.stdout) + return _run + + +# ---------------------------------------------------------------------- +# cj-block detection +# ---------------------------------------------------------------------- + +class TestCjScanCjBlockDetection: + """Detection of cj annotations — source-block and legacy-inline forms.""" + + def test_cj_scan_source_block_single_detected(self, run_scan): + """Normal: a single source-block cj is detected with correct line range and body.""" + content = "* Section\n#+begin_src cj: comment\nplease check this\n#+end_src\n" + result = run_scan(content) + assert len(result["cj_blocks"]) == 1 + b = result["cj_blocks"][0] + assert b["form"] == "source-block" + assert b["body"] == "please check this" + assert b["start_line"] == 2 + assert b["end_line"] == 4 + + def test_cj_scan_source_block_multiline_body_preserved(self, run_scan): + """Normal: multi-line body is preserved with embedded newlines.""" + content = "* S\n#+begin_src cj: comment\nline 1\nline 2\nline 3\n#+end_src\n" + result = run_scan(content) + assert result["cj_blocks"][0]["body"] == "line 1\nline 2\nline 3" + + def test_cj_scan_multiple_source_blocks_each_detected(self, run_scan): + """Normal: multiple source-blocks in a file are detected as separate items.""" + content = ( + "* A\n#+begin_src cj: comment\nfirst\n#+end_src\n" + "* B\n#+begin_src cj: comment\nsecond\n#+end_src\n" + ) + result = run_scan(content) + assert len(result["cj_blocks"]) == 2 + bodies = [b["body"] for b in result["cj_blocks"]] + assert bodies == ["first", "second"] + + def test_cj_scan_legacy_inline_single_line_detected(self, run_scan): + """Normal: a legacy inline cj line is detected with form=legacy-inline.""" + content = "* Section\ncj: please check this\n" + result = run_scan(content) + assert len(result["cj_blocks"]) == 1 + b = result["cj_blocks"][0] + assert b["form"] == "legacy-inline" + assert b["body"] == "please check this" + assert b["start_line"] == 2 + assert b["end_line"] == 2 + + def test_cj_scan_mixed_forms_in_same_file(self, run_scan): + """Normal: source-block + legacy inline coexist; both detected as separate items.""" + content = ( + "* A\ncj: legacy form\n" + "* B\n#+begin_src cj: comment\nnew form\n#+end_src\n" + ) + result = run_scan(content) + assert len(result["cj_blocks"]) == 2 + forms = sorted(b["form"] for b in result["cj_blocks"]) + assert forms == ["legacy-inline", "source-block"] + + def test_cj_scan_empty_file_returns_empty_lists(self, run_scan): + """Boundary: empty file → empty cj_blocks and verify_tasks lists.""" + result = run_scan("") + assert result["cj_blocks"] == [] + assert result["verify_tasks"] == [] + assert result["unclosed_blocks"] == [] + + def test_cj_scan_no_cj_content_returns_empty_blocks(self, run_scan): + """Boundary: org file with no cj content → empty cj_blocks.""" + content = "* Section\n** TODO Task\nbody text\n** TODO Another\n" + result = run_scan(content) + assert result["cj_blocks"] == [] + + def test_cj_scan_block_before_any_heading_empty_chain(self, run_scan): + """Boundary: cj block at top of file (before any heading) → empty parent chain.""" + content = "#+begin_src cj: comment\ntop-level note\n#+end_src\n" + result = run_scan(content) + assert result["cj_blocks"][0]["parent_heading_chain"] == [] + assert result["cj_blocks"][0]["parent_depth"] == 0 + + @pytest.mark.parametrize("fence", [ + "#+begin_src cj: comment", + "#+begin_src cj:", + "#+begin_src cj: anything", + "#+BEGIN_SRC cj: comment", # case-insensitive + ]) + def test_cj_scan_source_block_fence_variants_all_recognized(self, run_scan, fence): + """Boundary: fence label and case variants are all valid forms.""" + content = f"* S\n{fence}\nbody\n#+end_src\n" + result = run_scan(content) + assert len(result["cj_blocks"]) == 1 + assert result["cj_blocks"][0]["body"] == "body" + + def test_cj_scan_unclosed_source_block_reported(self, run_scan): + """Error: a source-block that opens but never closes → reported in unclosed_blocks.""" + content = "* S\n#+begin_src cj: comment\nbody that never ends\n" + result = run_scan(content) + assert result["cj_blocks"] == [] + assert len(result["unclosed_blocks"]) == 1 + assert result["unclosed_blocks"][0]["start_line"] == 2 + + +# ---------------------------------------------------------------------- +# Parent heading chain reconstruction +# ---------------------------------------------------------------------- + +class TestCjScanParentChain: + """Parent heading chain construction — walking the org tree backward.""" + + def test_cj_scan_nested_parent_chain_three_levels(self, run_scan): + """Normal: cj block inside three nested headings → chain reflects all three.""" + content = ( + "* Work\n" + "** DOING [#A] Kostya's contract\n" + "*** VERIFY Question?\n" + "#+begin_src cj: comment\nanswer\n#+end_src\n" + ) + result = run_scan(content) + chain = result["cj_blocks"][0]["parent_heading_chain"] + assert len(chain) == 3 + assert chain[0] == {"depth": 1, "heading": "Work"} + assert chain[1] == {"depth": 2, "heading": "DOING [#A] Kostya's contract"} + assert chain[2] == {"depth": 3, "heading": "VERIFY Question?"} + assert result["cj_blocks"][0]["parent_depth"] == 3 + + def test_cj_scan_depth_skip_only_actual_ancestors(self, run_scan): + """Normal: heading depth skip (e.g., * then ***) → chain captures only present headings.""" + content = "* Section\n*** Deep child\n#+begin_src cj: comment\nbody\n#+end_src\n" + result = run_scan(content) + chain = result["cj_blocks"][0]["parent_heading_chain"] + assert [h["depth"] for h in chain] == [1, 3] + + def test_cj_scan_shallower_sibling_pops_deeper_frames(self, run_scan): + """Normal: when a shallower heading appears, deeper frames pop off the stack.""" + content = ( + "* A\n** A.1\n*** A.1.1\n" + "** B\n" + "#+begin_src cj: comment\nunder B\n#+end_src\n" + ) + result = run_scan(content) + chain = result["cj_blocks"][0]["parent_heading_chain"] + assert len(chain) == 2 + assert chain[0]["heading"] == "A" + assert chain[1]["heading"] == "B" + + +# ---------------------------------------------------------------------- +# VERIFY task detection + placement audit +# ---------------------------------------------------------------------- + +class TestCjScanVerifyPlacement: + """VERIFY task detection and placement audit per the canonical rule.""" + + def test_cj_scan_verify_at_depth_2_is_valid(self, run_scan): + """Normal: ** VERIFY (top-level) is valid placement.""" + content = "* Work\n** VERIFY [#C] Hayk's Farearth Evaluation :research:hayk:\n" + result = run_scan(content) + assert len(result["verify_tasks"]) == 1 + v = result["verify_tasks"][0] + assert v["depth"] == 2 + assert v["valid_depth"] is True + assert v["promotion_target"] is None + + def test_cj_scan_verify_at_depth_3_is_valid(self, run_scan): + """Normal: *** VERIFY (first-level child) is valid placement.""" + content = "* Work\n** TODO Parent\n*** VERIFY Question?\n" + result = run_scan(content) + v = result["verify_tasks"][0] + assert v["depth"] == 3 + assert v["valid_depth"] is True + + def test_cj_scan_verify_at_depth_4_invalid_promote_to_3(self, run_scan): + """Normal: **** VERIFY is buried; suggests promotion to depth 3.""" + content = "* W\n** P\n*** Q\n**** VERIFY Buried?\n" + result = run_scan(content) + v = result["verify_tasks"][0] + assert v["depth"] == 4 + assert v["valid_depth"] is False + assert v["promotion_target"] == 3 + + def test_cj_scan_verify_at_depth_6_invalid_promote_to_3(self, run_scan): + """Normal: ****** VERIFY at any deep level → promotion target is still 3.""" + content = "* W\n** P\n*** Q\n**** Q2\n***** Q3\n****** VERIFY Very buried?\n" + result = run_scan(content) + v = result["verify_tasks"][0] + assert v["depth"] == 6 + assert v["promotion_target"] == 3 + + def test_cj_scan_verify_at_depth_1_invalid_promote_to_2(self, run_scan): + """Boundary: * VERIFY at top-section depth → promotion target is 2 (top-level under section).""" + content = "* VERIFY Should-be-deeper\n" + result = run_scan(content) + v = result["verify_tasks"][0] + assert v["depth"] == 1 + assert v["valid_depth"] is False + assert v["promotion_target"] == 2 + + def test_cj_scan_verify_heading_with_priority_and_tags(self, run_scan): + """Boundary: VERIFY heading with priority cookie + tags → heading text captured fully.""" + content = "* W\n** VERIFY [#C] Hayk's Farearth Evaluation :research:hayk:\n" + result = run_scan(content) + v = result["verify_tasks"][0] + assert "Hayk's Farearth Evaluation" in v["heading"] + assert ":research:" in v["heading"] + + def test_cj_scan_no_verify_tasks_empty_list(self, run_scan): + """Boundary: file with only TODO/DOING headings → empty verify_tasks list.""" + content = "* W\n** TODO X\n*** DOING Y\n" + result = run_scan(content) + assert result["verify_tasks"] == [] + + def test_cj_scan_verify_word_in_body_is_not_a_task(self, run_scan): + """Error: the word VERIFY appearing in body prose is not detected as a task.""" + content = ( + "* Work\n" + "** TODO Important task\n" + "Body line mentioning VERIFY in prose.\n" + ) + result = run_scan(content) + assert result["verify_tasks"] == [] diff --git a/.ai/scripts/tests/test_cmail_action.py b/.ai/scripts/tests/test_cmail_action.py new file mode 100644 index 0000000..3f77ca3 --- /dev/null +++ b/.ai/scripts/tests/test_cmail_action.py @@ -0,0 +1,669 @@ +"""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() + + +# --------------------------------------------------------------------------- +# 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): + return SimpleNamespace( + to=to, subject=subject, + body=None if stdin else body, + body_file=body_file, + attach=attach or [], + ) + + 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 diff --git a/.ai/scripts/tests/test_cross_agent_discover.py b/.ai/scripts/tests/test_cross_agent_discover.py new file mode 100644 index 0000000..f0d2bb7 --- /dev/null +++ b/.ai/scripts/tests/test_cross_agent_discover.py @@ -0,0 +1,204 @@ +"""Tests for cross-agent-discover (TDD: tests written before implementation).""" + +from __future__ import annotations + +import json +import os +import subprocess +import textwrap +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-discover" + + +def _run(args: list[str], env: dict | None = None) -> subprocess.CompletedProcess: + return subprocess.run([str(SCRIPT), *args], capture_output=True, text=True, env=env) + + +@pytest.fixture +def fake_home(tmp_path, monkeypatch): + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + return home + + +def _make_project(home: Path, name: str) -> Path: + proj = home / "projects" / name + (proj / ".ai").mkdir(parents=True) + return proj + + +def _write_peers_toml(home: Path, content: str) -> Path: + cfg = home / ".config" / "cross-agent-comms" + cfg.mkdir(parents=True, exist_ok=True) + peers = cfg / "peers.toml" + peers.write_text(content) + return peers + + +def test_discover_help(fake_home): + result = _run(["--help"], env={**os.environ, "HOME": str(fake_home)}) + assert result.returncode == 0 + assert "discover" in result.stdout.lower() or "enumerate" in result.stdout.lower() + + +def test_discover_local_only_no_projects(fake_home): + """Empty home → reports zero local projects, zero peers.""" + result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)}) + assert result.returncode == 0 + # No crash; mentions local somehow. + assert "local" in result.stdout.lower() or "0 project" in result.stdout.lower() + + +def test_discover_lists_local_projects(fake_home): + _make_project(fake_home, "homelab") + _make_project(fake_home, "career") + _make_project(fake_home, "claude-templates") + result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)}) + assert result.returncode == 0 + assert "homelab" in result.stdout + assert "career" in result.stdout + assert "claude-templates" in result.stdout + + +def test_discover_excludes_dirs_without_ai_subdir(fake_home): + """Directories under ~/projects/ that lack .ai/ are NOT projects.""" + _make_project(fake_home, "real-project") + (fake_home / "projects" / "not-a-project").mkdir(parents=True) + result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)}) + assert result.returncode == 0 + assert "real-project" in result.stdout + assert "not-a-project" not in result.stdout + + +def test_discover_no_peers_toml_just_local(fake_home): + _make_project(fake_home, "homelab") + result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)}) + assert result.returncode == 0 + # No peers section since no toml. + assert "homelab" in result.stdout + + +def test_discover_lists_peers_from_toml(fake_home): + _write_peers_toml(fake_home, textwrap.dedent("""\ + [peers.velox] + host = "velox" + ssh_user = "cjennings" + + [peers.bastion] + host = "bastion.local" + ssh_user = "cjennings" + """)) + _make_project(fake_home, "homelab") + result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)}) + assert result.returncode == 0 + assert "velox" in result.stdout + assert "bastion" in result.stdout + + +def test_discover_malformed_peers_toml_errors_clearly(fake_home): + _write_peers_toml(fake_home, "not valid toml at all = = =") + result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)}) + assert result.returncode != 0 + assert "peers.toml" in result.stderr or "TOML" in result.stderr or "parse" in result.stderr.lower() + + +def test_discover_json_output_schema(fake_home): + _make_project(fake_home, "homelab") + _make_project(fake_home, "career") + _write_peers_toml(fake_home, textwrap.dedent("""\ + [peers.velox] + host = "velox" + """)) + result = _run(["--json", "--no-cache"], env={**os.environ, "HOME": str(fake_home)}) + assert result.returncode == 0 + payload = json.loads(result.stdout) + assert "local" in payload + assert "peers" in payload + assert isinstance(payload["local"], list) + assert isinstance(payload["peers"], list) + assert "homelab" in payload["local"] + assert "career" in payload["local"] + velox = next((p for p in payload["peers"] if p["name"] == "velox"), None) + assert velox is not None + # Reachability is a key — value depends on actual SSH state. + assert "reachable" in velox + + +def test_discover_peer_scope(fake_home): + _write_peers_toml(fake_home, textwrap.dedent("""\ + [peers.velox] + host = "velox" + + [peers.bastion] + host = "bastion.local" + """)) + result = _run(["--peer", "velox", "--no-cache", "--json"], env={**os.environ, "HOME": str(fake_home)}) + assert result.returncode == 0 + payload = json.loads(result.stdout) + peer_names = [p["name"] for p in payload["peers"]] + assert "velox" in peer_names + assert "bastion" not in peer_names + + +def test_discover_unreachable_peer_marked(fake_home): + """A peer with a definitely-unreachable host gets reachable=False.""" + _write_peers_toml(fake_home, textwrap.dedent("""\ + [peers.bogus] + host = "definitely-not-a-real-host.invalid" + ssh_user = "nobody" + """)) + result = _run(["--no-cache", "--json"], env={**os.environ, "HOME": str(fake_home)}, ) + assert result.returncode == 0 + payload = json.loads(result.stdout) + bogus = next((p for p in payload["peers"] if p["name"] == "bogus"), None) + assert bogus is not None + assert bogus["reachable"] is False + + +def test_discover_cache_hit_within_window(fake_home): + """Second invocation within 5 min reads cache (skip the SSH probe).""" + _make_project(fake_home, "homelab") + # First call populates cache. + result1 = _run(["--json"], env={**os.environ, "HOME": str(fake_home)}) + assert result1.returncode == 0 + cache = fake_home / ".cache" / "cross-agent-comms" / "discovery.json" + assert cache.exists() + # Tamper with the cache to a marker only the cache path can produce. + payload = json.loads(cache.read_text()) + payload["_test_marker"] = True + cache.write_text(json.dumps(payload)) + # Second call (no --no-cache) should return the tampered payload. + result2 = _run(["--json"], env={**os.environ, "HOME": str(fake_home)}) + assert result2.returncode == 0 + payload2 = json.loads(result2.stdout) + assert payload2.get("_test_marker") is True + + +def test_discover_no_cache_flag_bypasses(fake_home): + """--no-cache ignores even a fresh cache.""" + _make_project(fake_home, "homelab") + cache_dir = fake_home / ".cache" / "cross-agent-comms" + cache_dir.mkdir(parents=True) + cache_dir.joinpath("discovery.json").write_text(json.dumps({ + "_test_marker": True, "local": [], "peers": [] + })) + result = _run(["--no-cache", "--json"], env={**os.environ, "HOME": str(fake_home)}) + assert result.returncode == 0 + payload = json.loads(result.stdout) + # Cache marker should NOT appear in fresh result. + assert payload.get("_test_marker") is None or payload.get("_test_marker") is False + assert "homelab" in payload["local"] + + +def test_discover_halt_shows_banner(fake_home): + halt = fake_home / ".config" / "cross-agent-comms" / "HALT" + halt.parent.mkdir(parents=True) + halt.write_text("halted") + _make_project(fake_home, "homelab") + result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)}) + assert result.returncode == 0 # discover continues to print under HALT + assert "HALT" in result.stdout diff --git a/.ai/scripts/tests/test_cross_agent_halt.py b/.ai/scripts/tests/test_cross_agent_halt.py new file mode 100644 index 0000000..f8bf0b3 --- /dev/null +++ b/.ai/scripts/tests/test_cross_agent_halt.py @@ -0,0 +1,204 @@ +"""Tests for cross-agent-halt and cross-agent-resume (TDD).""" + +from __future__ import annotations + +import os +import subprocess +import textwrap +from pathlib import Path + +import pytest + +HALT_SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-halt" +RESUME_SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-resume" + + +def _run(script: Path, args: list[str], env: dict | None = None) -> subprocess.CompletedProcess: + return subprocess.run([str(script), *args], capture_output=True, text=True, env=env) + + +@pytest.fixture +def isolated_env(tmp_path, monkeypatch): + """Isolated HOME + a fake systemctl that records calls without acting.""" + fake_home = tmp_path / "home" + fake_home.mkdir() + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + # Fake systemctl: no-op, exit 0. + fake_systemctl = fake_bin / "systemctl" + fake_systemctl.write_text("#!/usr/bin/env bash\nexit 0\n") + fake_systemctl.chmod(0o755) + # Fake ssh: succeed only for known-good host. + fake_ssh = fake_bin / "ssh" + fake_ssh.write_text(textwrap.dedent("""\ + #!/usr/bin/env bash + # Find the destination arg (skip flags). + target="" + for arg in "$@"; do + case "$arg" in + -*|*=*) ;; + *@*|localhost|*.local|*.invalid) target="$arg"; break ;; + *) target="$arg"; break ;; + esac + done + case "$target" in + *invalid*|*unreachable*) exit 255 ;; + *) exit 0 ;; + esac + """)) + fake_ssh.chmod(0o755) + + monkeypatch.setenv("HOME", str(fake_home)) + # Prepend our fake bin so systemctl + ssh are intercepted, but keep real /bin etc. + monkeypatch.setenv("PATH", f"{fake_bin}:{os.environ.get('PATH', '')}") + return fake_home + + +# ---- cross-agent-halt ---- + + +def test_halt_help(isolated_env): + result = _run(HALT_SCRIPT, ["--help"], env={**os.environ, "HOME": str(isolated_env), + "PATH": os.environ["PATH"]}) + assert result.returncode == 0 + assert "halt" in result.stdout.lower() + + +def test_halt_creates_halt_file(isolated_env): + halt_file = isolated_env / ".config" / "cross-agent-comms" / "HALT" + assert not halt_file.exists() + result = _run(HALT_SCRIPT, [], env={**os.environ, "HOME": str(isolated_env), + "PATH": os.environ["PATH"]}) + assert result.returncode == 0 + assert halt_file.exists() + + +def test_halt_with_reason_writes_body(isolated_env): + result = _run(HALT_SCRIPT, ["pausing for incident review"], + env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) + assert result.returncode == 0 + halt_file = isolated_env / ".config" / "cross-agent-comms" / "HALT" + assert halt_file.exists() + assert "pausing for incident review" in halt_file.read_text() + + +def test_halt_idempotent(isolated_env): + """Running halt twice doesn't error.""" + halt_file = isolated_env / ".config" / "cross-agent-comms" / "HALT" + r1 = _run(HALT_SCRIPT, [], env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) + assert r1.returncode == 0 + assert halt_file.exists() + r2 = _run(HALT_SCRIPT, [], env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) + assert r2.returncode == 0 + assert halt_file.exists() + + +def test_halt_does_not_pkill(isolated_env): + """Per design: halt does NOT call pkill. Verify by checking no pkill process gets launched.""" + # Replace pkill in PATH with something that fails loudly so we'd see if halt invoked it. + fake_bin = isolated_env.parent / "bin" + pkill = fake_bin / "pkill" + pkill.write_text("#!/usr/bin/env bash\necho 'PKILL CALLED' >&2\nexit 99\n") + pkill.chmod(0o755) + result = _run(HALT_SCRIPT, [], env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) + assert result.returncode == 0 + assert "PKILL CALLED" not in result.stderr + + +def test_halt_tailnet_reports_per_peer(isolated_env): + """--tailnet iterates peers.toml and reports per-peer status.""" + cfg = isolated_env / ".config" / "cross-agent-comms" + cfg.mkdir(parents=True) + (cfg / "peers.toml").write_text(textwrap.dedent("""\ + [peers.velox] + host = "velox" + ssh_user = "cjennings" + + [peers.bogus] + host = "definitely-unreachable.invalid" + ssh_user = "cjennings" + """)) + result = _run(HALT_SCRIPT, ["--tailnet"], + env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) + # Partial halt → exit 1. + assert result.returncode == 1 + assert "velox" in result.stdout + assert "bogus" in result.stdout + # ✓ marker for velox, ✗ for bogus. + assert "✓" in result.stdout + assert "✗" in result.stdout + assert "PARTIAL" in result.stdout or "partial" in result.stdout.lower() + + +def test_halt_tailnet_all_reachable_exits_zero(isolated_env): + cfg = isolated_env / ".config" / "cross-agent-comms" + cfg.mkdir(parents=True) + (cfg / "peers.toml").write_text(textwrap.dedent("""\ + [peers.velox] + host = "velox" + ssh_user = "cjennings" + """)) + result = _run(HALT_SCRIPT, ["--tailnet"], + env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) + assert result.returncode == 0 + assert "velox" in result.stdout + + +# ---- cross-agent-resume ---- + + +def test_resume_help(isolated_env): + result = _run(RESUME_SCRIPT, ["--help"], + env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) + assert result.returncode == 0 + assert "resume" in result.stdout.lower() + + +def test_resume_removes_halt_file(isolated_env): + halt_file = isolated_env / ".config" / "cross-agent-comms" / "HALT" + halt_file.parent.mkdir(parents=True) + halt_file.write_text("halted") + assert halt_file.exists() + result = _run(RESUME_SCRIPT, [], + env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) + assert result.returncode == 0 + assert not halt_file.exists() + + +def test_resume_when_no_halt_active_succeeds(isolated_env): + """No HALT to clear is not an error.""" + result = _run(RESUME_SCRIPT, [], + env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) + assert result.returncode == 0 + + +def test_resume_prints_per_session_instructions(isolated_env): + """Resume must surface that polling does NOT auto-resume.""" + halt_file = isolated_env / ".config" / "cross-agent-comms" / "HALT" + halt_file.parent.mkdir(parents=True) + halt_file.write_text("halted") + result = _run(RESUME_SCRIPT, [], + env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) + assert result.returncode == 0 + out = result.stdout.lower() + assert "polling" in out + assert "auto" in out or "explicit" in out or "session" in out + + +def test_resume_tailnet_partial_failure_exit_1(isolated_env): + cfg = isolated_env / ".config" / "cross-agent-comms" + cfg.mkdir(parents=True) + (cfg / "peers.toml").write_text(textwrap.dedent("""\ + [peers.velox] + host = "velox" + + [peers.bogus] + host = "unreachable-host.invalid" + """)) + halt_file = cfg / "HALT" + halt_file.write_text("halted") + result = _run(RESUME_SCRIPT, ["--tailnet"], + env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) + assert result.returncode == 1 + assert "velox" in result.stdout + assert "bogus" in result.stdout diff --git a/.ai/scripts/tests/test_cross_agent_recv.py b/.ai/scripts/tests/test_cross_agent_recv.py new file mode 100644 index 0000000..27c53a5 --- /dev/null +++ b/.ai/scripts/tests/test_cross_agent_recv.py @@ -0,0 +1,176 @@ +"""Tests for cross-agent-recv.""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-recv" + + +def _make_message(path: Path, *, conv_id: str = "test-conv", seq: int = 1, msg_type: str = "request", + proto_version: str = "5", title: str = "Test", requires_tools: str | None = None, + body: str = "Body.\n") -> Path: + fm_lines = [ + f"#+TITLE: {title}", + f"#+CONVERSATION_ID: {conv_id}", + f"#+MESSAGE_TYPE: {msg_type}", + f"#+SEQUENCE: {seq}", + "#+TIMESTAMP: 2026-04-27T05:00:00-05:00", + f"#+PROTOCOL_VERSION: {proto_version}", + ] + if requires_tools: + fm_lines.append(f"#+REQUIRES_TOOLS: {requires_tools}") + path.write_text("\n".join(fm_lines) + "\n\n" + body) + return path + + +def _run(args: list[str], env: dict | None = None) -> subprocess.CompletedProcess: + return subprocess.run([str(SCRIPT), *args], capture_output=True, text=True, env=env) + + +@pytest.fixture +def isolated_env(tmp_path, monkeypatch): + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + return fake_home + + +def test_recv_help(isolated_env): + result = _run(["--help"], env={**os.environ, "HOME": str(isolated_env)}) + assert result.returncode == 0 + assert "Receive and decide" in result.stdout + + +def test_recv_missing_file_rejects(isolated_env, tmp_path): + result = _run([str(tmp_path / "nope.org")], env={**os.environ, "HOME": str(isolated_env)}) + assert result.returncode == 3 # reject + + +def test_recv_malformed_frontmatter_rejects(isolated_env, tmp_path): + bad = tmp_path / "bad.org" + bad.write_text("not org-mode at all\n") + result = _run([str(bad), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) + assert result.returncode == 3 + assert "decision: reject" in result.stdout + + +def test_recv_missing_required_field_rejects(isolated_env, tmp_path): + msg = tmp_path / "msg.org" + # Missing PROTOCOL_VERSION among others. + msg.write_text("#+TITLE: x\n#+CONVERSATION_ID: c\n\nBody.\n") + result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) + assert result.returncode == 3 + assert "missing required" in result.stdout + + +def test_recv_protocol_version_mismatch_query(isolated_env, tmp_path): + msg = _make_message(tmp_path / "msg.org", proto_version="4") + result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) + assert result.returncode == 2 # query + assert "PROTOCOL_VERSION mismatch" in result.stdout + + +def test_recv_invalid_message_type_rejects(isolated_env, tmp_path): + msg = _make_message(tmp_path / "msg.org", msg_type="banana") + result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) + assert result.returncode == 3 + assert "invalid MESSAGE_TYPE" in result.stdout + + +def test_recv_missing_signature_rejects(isolated_env, tmp_path): + """When verify is on, a missing .asc sibling rejects.""" + msg = _make_message(tmp_path / "msg.org") + # No .asc sidecar. + result = _run([str(msg)], env={**os.environ, "HOME": str(isolated_env)}) + assert result.returncode == 3 + assert "signature file missing" in result.stdout + + +def test_recv_valid_processes(isolated_env, tmp_path): + """A valid message with --no-verify and no dedup match → process.""" + msg = _make_message(tmp_path / "msg.org") + result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) + assert result.returncode == 0 # process + assert "decision: process" in result.stdout + assert "sha256:" in result.stdout + + +def test_recv_dedup_against_identical_existing(isolated_env, tmp_path): + """Same content + same SEQUENCE in same dir → dedup.""" + inbox = tmp_path / "inbox" + inbox.mkdir() + first = _make_message(inbox / "20260427T100000Z-from-x-c.org", conv_id="c", seq=5) + # Second message with same content — name differs (canonical-style would have different timestamp). + second = _make_message(inbox / "20260427T100100Z-from-x-c.org", conv_id="c", seq=5) + # Bodies must be byte-identical for hash equality. + second.write_bytes(first.read_bytes()) + result = _run([str(second), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) + assert result.returncode == 1 # dedup + assert "decision: dedup" in result.stdout + + +def test_recv_collision_with_different_content_processes(isolated_env, tmp_path): + """Same SEQUENCE + same CONVERSATION_ID but different content → process both.""" + inbox = tmp_path / "inbox" + inbox.mkdir() + _make_message(inbox / "20260427T100000Z-from-x-c.org", conv_id="c", seq=5, body="First body.\n") + second = _make_message(inbox / "20260427T100100Z-from-x-c.org", conv_id="c", seq=5, body="Different body.\n") + result = _run([str(second), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) + assert result.returncode == 0 # process + assert "decision: process" in result.stdout + + +def test_recv_requires_tools_missing_query(isolated_env, tmp_path): + """REQUIRES_TOOLS naming a definitely-missing binary → query.""" + msg = _make_message(tmp_path / "msg.org", requires_tools="definitely-not-installed-xyzzy-9000") + result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) + assert result.returncode == 2 # query + assert "required tools unavailable" in result.stdout + + +def test_recv_requires_tools_present_processes(isolated_env, tmp_path): + """REQUIRES_TOOLS naming a real binary → process.""" + msg = _make_message(tmp_path / "msg.org", requires_tools="ls,cat") + result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) + assert result.returncode == 0 + assert "decision: process" in result.stdout + + +def test_recv_json_output(isolated_env, tmp_path): + msg = _make_message(tmp_path / "msg.org") + result = _run([str(msg), "--no-verify", "--json"], env={**os.environ, "HOME": str(isolated_env)}) + assert result.returncode == 0 + payload = json.loads(result.stdout) + assert payload["decision"] == "process" + assert payload["message_type"] == "request" + assert payload["conversation_id"] == "test-conv" + + +def test_recv_halt_blocks(isolated_env, tmp_path): + halt = isolated_env / ".config" / "cross-agent-comms" / "HALT" + halt.parent.mkdir(parents=True) + halt.write_text("halted\n") + msg = _make_message(tmp_path / "msg.org") + result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) + assert result.returncode == 5 + assert "halt active" in result.stderr.lower() + + +def test_recv_halt_leaves_message_in_place(isolated_env, tmp_path): + """Per spec: under HALT, recv must NOT move/dedup/reject — leave file in place.""" + halt = isolated_env / ".config" / "cross-agent-comms" / "HALT" + halt.parent.mkdir(parents=True) + halt.write_text("halted\n") + msg = _make_message(tmp_path / "msg.org") + pre_content = msg.read_text() + result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) + assert result.returncode == 5 + # File still exists with same content. + assert msg.exists() + assert msg.read_text() == pre_content diff --git a/.ai/scripts/tests/test_cross_agent_send.py b/.ai/scripts/tests/test_cross_agent_send.py new file mode 100644 index 0000000..f716e95 --- /dev/null +++ b/.ai/scripts/tests/test_cross_agent_send.py @@ -0,0 +1,210 @@ +"""Tests for cross-agent-send. + +Subprocess-based: treat the script as a black-box CLI and assert on its +exit codes, stdout, and the files it produces. +""" + +from __future__ import annotations + +import os +import subprocess +import textwrap +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-send" + + +def _make_message(tmp_path: Path, conv_id: str = "test-conv", seq: int = 1, msg_type: str = "request", + proto_version: str = "5") -> Path: + msg = tmp_path / "msg.org" + msg.write_text(textwrap.dedent(f"""\ + #+TITLE: Test message + #+CONVERSATION_ID: {conv_id} + #+MESSAGE_TYPE: {msg_type} + #+SEQUENCE: {seq} + #+TIMESTAMP: 2026-04-27T05:00:00-05:00 + #+PROTOCOL_VERSION: {proto_version} + + Body. + """)) + return msg + + +def _run(args: list[str], env: dict | None = None, cwd: Path | None = None) -> subprocess.CompletedProcess: + return subprocess.run( + [str(SCRIPT), *args], + capture_output=True, + text=True, + env=env, + cwd=cwd, + ) + + +@pytest.fixture +def isolated_env(tmp_path, monkeypatch): + """Redirect HOME so peers.toml, HALT, marker files are scoped to the test.""" + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + # Pre-create projects/ so derive_sender_project has somewhere to look. + (fake_home / "projects" / "homelab").mkdir(parents=True) + return fake_home + + +def test_send_help(isolated_env): + """--help works without side effects.""" + result = _run(["--help"], env={**os.environ, "HOME": str(isolated_env)}) + assert result.returncode == 0 + assert "Send a cross-agent message" in result.stdout + + +def test_send_missing_message_file(isolated_env): + """Nonexistent message file returns general error.""" + import socket + machine = socket.gethostname().split(".")[0] + result = _run( + [f"{machine}.homelab", str(isolated_env / "nonexistent.org")], + env={**os.environ, "HOME": str(isolated_env)}, + ) + assert result.returncode == 1 + assert "not found" in result.stderr.lower() + + +def test_send_invalid_destination_format(isolated_env, tmp_path): + """Destination without . returns dest-not-found exit code.""" + msg = _make_message(tmp_path) + result = _run( + ["bogus", str(msg)], + env={**os.environ, "HOME": str(isolated_env)}, + ) + assert result.returncode == 2 + assert "<machine>.<project>" in result.stderr or "destination" in result.stderr.lower() + + +def test_send_dest_not_in_peers(isolated_env, tmp_path): + """Cross-machine destination with no peers.toml entry exits 2.""" + msg = _make_message(tmp_path) + result = _run( + ["unknownmachine.homelab", str(msg)], + env={**os.environ, "HOME": str(isolated_env)}, + ) + assert result.returncode == 2 + assert "not found in peers" in result.stderr + + +def test_send_frontmatter_missing_required(isolated_env, tmp_path): + """Message missing required fields exits 4.""" + bad = tmp_path / "bad.org" + bad.write_text("#+TITLE: nope\n\nBody.\n") + import socket + machine = socket.gethostname().split(".")[0] + result = _run( + [f"{machine}.homelab", str(bad)], + env={**os.environ, "HOME": str(isolated_env)}, + ) + assert result.returncode == 4 + assert "missing required fields" in result.stderr + + +def test_send_invalid_message_type(isolated_env, tmp_path): + """Unknown MESSAGE_TYPE exits 4.""" + msg = _make_message(tmp_path, msg_type="frobnicate") + import socket + machine = socket.gethostname().split(".")[0] + result = _run( + [f"{machine}.homelab", str(msg)], + env={**os.environ, "HOME": str(isolated_env)}, + ) + assert result.returncode == 4 + assert "MESSAGE_TYPE" in result.stderr + + +def test_send_halt_blocks(isolated_env, tmp_path): + """When HALT exists, send refuses with exit 5.""" + halt = isolated_env / ".config" / "cross-agent-comms" / "HALT" + halt.parent.mkdir(parents=True) + halt.write_text("test halt\n") + msg = _make_message(tmp_path) + import socket + machine = socket.gethostname().split(".")[0] + result = _run( + [f"{machine}.homelab", str(msg)], + env={**os.environ, "HOME": str(isolated_env)}, + ) + assert result.returncode == 5 + assert "halt active" in result.stderr.lower() + + +def test_send_same_machine_no_sign_delivers(isolated_env, tmp_path): + """Same-machine delivery with --no-sign produces a canonically named file.""" + msg = _make_message(tmp_path, conv_id="my-conv") + import socket + machine = socket.gethostname().split(".")[0] + # Sender is derived from CWD walking up to ~/projects/<name>/ + cwd = isolated_env / "projects" / "homelab" + result = _run( + [f"{machine}.homelab", str(msg), "--no-sign"], + env={**os.environ, "HOME": str(isolated_env)}, + cwd=cwd, + ) + assert result.returncode == 0, f"stderr={result.stderr}" + inbox = isolated_env / "projects" / "homelab" / "inbox" / "from-agents" + files = list(inbox.glob("*-from-homelab-my-conv.org")) + assert len(files) == 1 + # No sig file with --no-sign. + assert not list(inbox.glob("*.asc")) + # Canonical filename pattern. + assert files[0].name.startswith("2026") and files[0].name.endswith("-from-homelab-my-conv.org") + + +def test_send_same_machine_signed_writes_asc(isolated_env, tmp_path): + """Signed delivery writes both .org and .asc.""" + msg = _make_message(tmp_path, conv_id="signed-conv") + import socket + machine = socket.gethostname().split(".")[0] + cwd = isolated_env / "projects" / "homelab" + # Use the real GPG keyring (not isolating GPG — Craig's existing keys are fine for tests). + real_env = {**os.environ, "HOME": str(isolated_env), "GNUPGHOME": str(Path.home() / ".gnupg")} + result = _run( + [f"{machine}.homelab", str(msg)], + env=real_env, + cwd=cwd, + ) + if result.returncode != 0: + pytest.skip(f"GPG signing unavailable in this environment: {result.stderr}") + inbox = isolated_env / "projects" / "homelab" / "inbox" / "from-agents" + org_files = list(inbox.glob("*-from-homelab-signed-conv.org")) + asc_files = list(inbox.glob("*-from-homelab-signed-conv.org.asc")) + assert len(org_files) == 1 + assert len(asc_files) == 1 + + +def test_send_filename_ignores_input_basename(isolated_env, tmp_path): + """User's input filename is ignored; canonical filename is generated.""" + weird = tmp_path / "weird-user-name.org" + weird.write_text(textwrap.dedent("""\ + #+TITLE: Title + #+CONVERSATION_ID: ignored-input + #+MESSAGE_TYPE: request + #+SEQUENCE: 1 + #+TIMESTAMP: 2026-04-27T05:00:00-05:00 + #+PROTOCOL_VERSION: 5 + + Body. + """)) + import socket + machine = socket.gethostname().split(".")[0] + cwd = isolated_env / "projects" / "homelab" + result = _run( + [f"{machine}.homelab", str(weird), "--no-sign"], + env={**os.environ, "HOME": str(isolated_env)}, + cwd=cwd, + ) + assert result.returncode == 0 + inbox = isolated_env / "projects" / "homelab" / "inbox" / "from-agents" + # No file named after the user's input. + assert not (inbox / "weird-user-name.org").exists() + # Canonical naming used. + assert list(inbox.glob("*-from-homelab-ignored-input.org")) diff --git a/.ai/scripts/tests/test_cross_agent_status.py b/.ai/scripts/tests/test_cross_agent_status.py new file mode 100644 index 0000000..bb5b8ba --- /dev/null +++ b/.ai/scripts/tests/test_cross_agent_status.py @@ -0,0 +1,165 @@ +"""Tests for cross-agent-status (TDD: tests written before implementation).""" + +from __future__ import annotations + +import json +import os +import subprocess +import textwrap +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-status" + + +def _make_msg(path: Path, *, conv_id: str, seq: int, msg_type: str = "request", + proto_version: str = "5", timestamp: str = "2026-04-27T05:00:00-05:00") -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(textwrap.dedent(f"""\ + #+TITLE: T + #+CONVERSATION_ID: {conv_id} + #+MESSAGE_TYPE: {msg_type} + #+SEQUENCE: {seq} + #+TIMESTAMP: {timestamp} + #+PROTOCOL_VERSION: {proto_version} + + Body. + """)) + return path + + +def _run(args: list[str], env: dict | None = None) -> subprocess.CompletedProcess: + return subprocess.run([str(SCRIPT), *args], capture_output=True, text=True, env=env) + + +@pytest.fixture +def fake_projects(tmp_path, monkeypatch): + """Create a fake ~/projects/<name>/inbox/from-agents/ tree under tmp_path.""" + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + return home + + +def test_status_help(fake_projects): + result = _run(["--help"], env={**os.environ, "HOME": str(fake_projects)}) + assert result.returncode == 0 + assert "snapshot" in result.stdout.lower() or "pending" in result.stdout.lower() + + +def test_status_no_projects_clean_output(fake_projects): + result = _run([], env={**os.environ, "HOME": str(fake_projects)}) + assert result.returncode == 0 + # Empty machine prints either header-only table or "no projects" — accept either. + # No crash, no pending claims. + assert "pending" in result.stdout.lower() or result.stdout.strip() == "" + + +def test_status_one_pending_shows_up(fake_projects): + inbox = fake_projects / "projects" / "homelab" / "inbox" / "from-agents" + _make_msg(inbox / "20260427T100000Z-from-career-fixup.org", conv_id="fixup", seq=1) + result = _run([], env={**os.environ, "HOME": str(fake_projects)}) + assert result.returncode == 0 + assert "homelab" in result.stdout + assert "1" in result.stdout # pending count + assert "20260427T100000Z-from-career-fixup.org" in result.stdout + + +def test_status_released_conversation_zero_pending(fake_projects): + """A conversation with a release message in it counts as 0 pending.""" + inbox = fake_projects / "projects" / "homelab" / "inbox" / "from-agents" + _make_msg(inbox / "20260427T100000Z-from-career-done.org", conv_id="done", seq=1) + _make_msg(inbox / "20260427T100100Z-from-homelab-done.org", conv_id="done", seq=2, msg_type="release") + result = _run([], env={**os.environ, "HOME": str(fake_projects)}) + assert result.returncode == 0 + # Check the homelab row shows 0 pending. + lines = [ln for ln in result.stdout.splitlines() if "homelab" in ln] + # At least one homelab line should show 0 pending or "—". + assert any("0" in ln or "—" in ln for ln in lines) + + +def test_status_partial_release(fake_projects): + """Conversation with release + a later message → that later message counts as pending.""" + inbox = fake_projects / "projects" / "homelab" / "inbox" / "from-agents" + _make_msg(inbox / "20260427T100000Z-from-career-x.org", conv_id="x", seq=1, + timestamp="2026-04-27T05:00:00-05:00") + _make_msg(inbox / "20260427T100100Z-from-homelab-x.org", conv_id="x", seq=2, msg_type="release", + timestamp="2026-04-27T05:01:00-05:00") + # New message AFTER release: starts a fresh thread that's pending. + _make_msg(inbox / "20260427T200000Z-from-career-x.org", conv_id="x", seq=3, + timestamp="2026-04-27T15:00:00-05:00") + result = _run([], env={**os.environ, "HOME": str(fake_projects)}) + assert result.returncode == 0 + homelab_line = next(ln for ln in result.stdout.splitlines() if "homelab" in ln) + assert "1" in homelab_line # the post-release message is pending + + +def test_status_multiple_projects(fake_projects): + inbox_a = fake_projects / "projects" / "homelab" / "inbox" / "from-agents" + inbox_b = fake_projects / "projects" / "career" / "inbox" / "from-agents" + _make_msg(inbox_a / "20260427T100000Z-from-x-a.org", conv_id="a", seq=1) + _make_msg(inbox_b / "20260427T100100Z-from-x-b.org", conv_id="b", seq=1) + _make_msg(inbox_b / "20260427T100200Z-from-x-c.org", conv_id="c", seq=1) + result = _run([], env={**os.environ, "HOME": str(fake_projects)}) + assert result.returncode == 0 + # career has 2 pending, homelab has 1. + career_line = next(ln for ln in result.stdout.splitlines() if "career" in ln) + homelab_line = next(ln for ln in result.stdout.splitlines() if "homelab" in ln) + assert "2" in career_line + assert "1" in homelab_line + + +def test_status_json_output(fake_projects): + inbox = fake_projects / "projects" / "homelab" / "inbox" / "from-agents" + _make_msg(inbox / "20260427T100000Z-from-career-test.org", conv_id="test", seq=1) + result = _run(["--json"], env={**os.environ, "HOME": str(fake_projects)}) + assert result.returncode == 0 + payload = json.loads(result.stdout) + assert "projects" in payload + assert isinstance(payload["projects"], list) + homelab = next((p for p in payload["projects"] if p["name"] == "homelab"), None) + assert homelab is not None + assert homelab["pending_count"] == 1 + + +def test_status_sort_pending_first(fake_projects): + """Projects with pending messages sort before projects with 0.""" + (fake_projects / "projects" / "alpha" / "inbox" / "from-agents").mkdir(parents=True) + inbox_zeta = fake_projects / "projects" / "zeta" / "inbox" / "from-agents" + _make_msg(inbox_zeta / "20260427T100000Z-from-x-z.org", conv_id="z", seq=1) + result = _run([], env={**os.environ, "HOME": str(fake_projects)}) + assert result.returncode == 0 + lines = result.stdout.splitlines() + zeta_idx = next(i for i, ln in enumerate(lines) if "zeta" in ln) + alpha_idx = next(i for i, ln in enumerate(lines) if "alpha" in ln) + assert zeta_idx < alpha_idx, "pending project should sort before zero-pending project" + + +def test_status_halt_shows_banner(fake_projects): + halt = fake_projects / ".config" / "cross-agent-comms" / "HALT" + halt.parent.mkdir(parents=True) + halt.write_text("halted for test") + inbox = fake_projects / "projects" / "homelab" / "inbox" / "from-agents" + _make_msg(inbox / "20260427T100000Z-from-x-x.org", conv_id="x", seq=1) + result = _run([], env={**os.environ, "HOME": str(fake_projects)}) + assert result.returncode == 0 # status continues to print under HALT + assert "HALT" in result.stdout + # Banner should mention the reason. + assert "halted for test" in result.stdout + + +def test_status_projects_glob_override(fake_projects): + inbox = fake_projects / "projects" / "homelab" / "inbox" / "from-agents" + _make_msg(inbox / "20260427T100000Z-from-x-a.org", conv_id="a", seq=1) + other_inbox = fake_projects / "projects" / "career" / "inbox" / "from-agents" + _make_msg(other_inbox / "20260427T100100Z-from-x-b.org", conv_id="b", seq=1) + # Glob limits to homelab only. + result = _run( + ["--projects-glob", str(fake_projects / "projects" / "homelab" / "inbox" / "from-agents") + "/"], + env={**os.environ, "HOME": str(fake_projects)}, + ) + assert result.returncode == 0 + assert "homelab" in result.stdout + # career not in scope. + assert "career" not in result.stdout diff --git a/.ai/scripts/tests/test_cross_agent_watch.py b/.ai/scripts/tests/test_cross_agent_watch.py new file mode 100644 index 0000000..417cc19 --- /dev/null +++ b/.ai/scripts/tests/test_cross_agent_watch.py @@ -0,0 +1,155 @@ +"""Tests for cross-agent-watch. + +Black-box: spawn the script, drop files into a watched dir, read the log. +Tests use --no-notify to avoid firing real desktop notifications. +""" + +from __future__ import annotations + +import os +import subprocess +import time +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-watch" + + +def _spawn(watched_dir: Path, log_path: Path, env: dict) -> subprocess.Popen: + return subprocess.Popen( + [ + str(SCRIPT), + "--projects-glob", str(watched_dir) + "/", + "--log", str(log_path), + "--no-notify", + "--quiet", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + env=env, + ) + + +def _wait_for_log_lines(log_path: Path, expected: int, timeout: float = 5.0) -> list[str]: + deadline = time.time() + timeout + while time.time() < deadline: + if log_path.exists(): + lines = [ln for ln in log_path.read_text().splitlines() if ln] + if len(lines) >= expected: + return lines + time.sleep(0.1) + if log_path.exists(): + return [ln for ln in log_path.read_text().splitlines() if ln] + return [] + + +@pytest.fixture +def isolated_env(tmp_path, monkeypatch): + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + return fake_home + + +def test_watch_help(isolated_env): + result = subprocess.run( + [str(SCRIPT), "--help"], + capture_output=True, text=True, + env={**os.environ, "HOME": str(isolated_env)}, + ) + assert result.returncode == 0 + assert "Usage:" in result.stdout + + +def test_watch_empty_glob_exits_nonzero(isolated_env): + """Glob resolving to zero dirs should exit non-zero with a clear message.""" + result = subprocess.run( + [str(SCRIPT), "--projects-glob", "/nonexistent/path/*/foo/", "--no-notify", "--quiet"], + capture_output=True, text=True, + env={**os.environ, "HOME": str(isolated_env)}, + timeout=3, + ) + assert result.returncode != 0 + assert "0 directories" in result.stderr + + +def test_watch_logs_org_file_create(isolated_env, tmp_path): + watched = tmp_path / "watched" + watched.mkdir() + log = tmp_path / "watch.log" + proc = _spawn(watched, log, {**os.environ, "HOME": str(isolated_env)}) + try: + # Give inotifywait a moment to attach. + time.sleep(0.3) + (watched / "test-msg.org").write_text("hello") + lines = _wait_for_log_lines(log, expected=1, timeout=3.0) + assert len(lines) >= 1 + assert "test-msg.org" in lines[-1] + finally: + proc.terminate() + proc.wait(timeout=2) + + +def test_watch_filters_tmp_files(isolated_env, tmp_path): + """Files starting with .tmp. must NOT trigger log entries.""" + watched = tmp_path / "watched" + watched.mkdir() + log = tmp_path / "watch.log" + proc = _spawn(watched, log, {**os.environ, "HOME": str(isolated_env)}) + try: + time.sleep(0.3) + (watched / ".tmp.staging-file.org").write_text("hello") + # Wait briefly to confirm nothing logs. + time.sleep(0.5) + if log.exists(): + content = log.read_text() + assert ".tmp.staging-file" not in content + # Then drop a real file to confirm watcher is alive. + (watched / "real.org").write_text("real") + lines = _wait_for_log_lines(log, expected=1, timeout=3.0) + assert any("real.org" in ln for ln in lines) + finally: + proc.terminate() + proc.wait(timeout=2) + + +def test_watch_filters_asc_sidecars(isolated_env, tmp_path): + """Only .org events fire; .asc sidecars are silent.""" + watched = tmp_path / "watched" + watched.mkdir() + log = tmp_path / "watch.log" + proc = _spawn(watched, log, {**os.environ, "HOME": str(isolated_env)}) + try: + time.sleep(0.3) + (watched / "msg.org.asc").write_text("sig") + time.sleep(0.5) + if log.exists(): + assert "msg.org.asc" not in log.read_text() + # .org event still works. + (watched / "msg.org").write_text("body") + lines = _wait_for_log_lines(log, expected=1, timeout=3.0) + assert any(ln.endswith("msg.org") for ln in lines) + finally: + proc.terminate() + proc.wait(timeout=2) + + +def test_watch_halt_suppresses_but_logs(isolated_env, tmp_path): + """When HALT is set, watcher logs the event with (suppressed by HALT) marker.""" + halt = isolated_env / ".config" / "cross-agent-comms" / "HALT" + halt.parent.mkdir(parents=True) + halt.write_text("halted") + watched = tmp_path / "watched" + watched.mkdir() + log = tmp_path / "watch.log" + proc = _spawn(watched, log, {**os.environ, "HOME": str(isolated_env)}) + try: + time.sleep(0.3) + (watched / "halted-event.org").write_text("body") + lines = _wait_for_log_lines(log, expected=1, timeout=3.0) + assert len(lines) >= 1 + assert "suppressed by HALT" in lines[-1] + finally: + proc.terminate() + proc.wait(timeout=2) diff --git a/.ai/scripts/tests/test_extract_body.py b/.ai/scripts/tests/test_extract_body.py new file mode 100644 index 0000000..7b53cda --- /dev/null +++ b/.ai/scripts/tests/test_extract_body.py @@ -0,0 +1,96 @@ +"""Tests for extract_body().""" + +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from conftest import make_plain_message, make_html_message, make_message_with_attachment +from email.message import EmailMessage +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.mime.application import MIMEApplication + +import importlib.util +spec = importlib.util.spec_from_file_location( + "eml_script", + os.path.join(os.path.dirname(__file__), '..', 'eml-view-and-extract-attachments.py') +) +eml_script = importlib.util.module_from_spec(spec) +spec.loader.exec_module(eml_script) + +extract_body = eml_script.extract_body + + +class TestPlainText: + def test_returns_plain_text(self): + msg = make_plain_message(body="Hello, this is plain text.") + result = extract_body(msg) + assert "Hello, this is plain text." in result + + +class TestHtmlOnly: + def test_returns_converted_html(self): + msg = make_html_message(html_body="<p>Hello <strong>world</strong></p>") + result = extract_body(msg) + assert "Hello" in result + assert "world" in result + # Should not contain raw HTML tags + assert "<p>" not in result + assert "<strong>" not in result + + +class TestBothPlainAndHtml: + def test_prefers_plain_text(self): + msg = MIMEMultipart('alternative') + msg['From'] = 'test@example.com' + msg['To'] = 'dest@example.com' + msg['Subject'] = 'Test' + msg['Date'] = 'Thu, 05 Feb 2026 11:36:00 -0600' + msg.attach(MIMEText("Plain text version", 'plain')) + msg.attach(MIMEText("<p>HTML version</p>", 'html')) + result = extract_body(msg) + assert "Plain text version" in result + assert "HTML version" not in result + + +class TestEmptyBody: + def test_returns_empty_string(self): + # Multipart with only attachments, no text parts + msg = MIMEMultipart() + msg['From'] = 'test@example.com' + att = MIMEApplication(b"binary data", Name="file.bin") + att['Content-Disposition'] = 'attachment; filename="file.bin"' + msg.attach(att) + result = extract_body(msg) + assert result == "" + + +class TestNonUtf8Encoding: + def test_decodes_with_errors_ignore(self): + msg = EmailMessage() + msg['From'] = 'test@example.com' + # Set raw bytes that include invalid UTF-8 + msg.set_content("Valid text with special: café") + result = extract_body(msg) + assert "Valid text" in result + + +class TestHtmlWithStructure: + def test_preserves_list_structure(self): + html = "<ul><li>Item one</li><li>Item two</li></ul>" + msg = make_html_message(html_body=html) + result = extract_body(msg) + assert "Item one" in result + assert "Item two" in result + + +class TestNoTextParts: + def test_returns_empty_string(self): + msg = MIMEMultipart() + msg['From'] = 'test@example.com' + att = MIMEApplication(b"data", Name="image.png") + att['Content-Disposition'] = 'attachment; filename="image.png"' + msg.attach(att) + result = extract_body(msg) + assert result == "" diff --git a/.ai/scripts/tests/test_extract_metadata.py b/.ai/scripts/tests/test_extract_metadata.py new file mode 100644 index 0000000..d5ee52e --- /dev/null +++ b/.ai/scripts/tests/test_extract_metadata.py @@ -0,0 +1,65 @@ +"""Tests for extract_metadata().""" + +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from conftest import make_plain_message, add_received_headers +from email.message import EmailMessage + +import importlib.util +spec = importlib.util.spec_from_file_location( + "eml_script", + os.path.join(os.path.dirname(__file__), '..', 'eml-view-and-extract-attachments.py') +) +eml_script = importlib.util.module_from_spec(spec) +spec.loader.exec_module(eml_script) + +extract_metadata = eml_script.extract_metadata + + +class TestAllHeadersPresent: + def test_complete_dict(self): + msg = make_plain_message( + from_="Jonathan Smith <jsmith@example.com>", + to="Craig <craig@example.com>", + subject="Test Subject", + date="Thu, 05 Feb 2026 11:36:00 -0600" + ) + result = extract_metadata(msg) + assert result['from'] == "Jonathan Smith <jsmith@example.com>" + assert result['to'] == "Craig <craig@example.com>" + assert result['subject'] == "Test Subject" + assert result['date'] == "Thu, 05 Feb 2026 11:36:00 -0600" + assert 'timing' in result + + +class TestMissingFrom: + def test_from_is_none(self): + msg = EmailMessage() + msg['To'] = 'craig@example.com' + msg['Subject'] = 'Test' + msg['Date'] = 'Thu, 05 Feb 2026 11:36:00 -0600' + msg.set_content("body") + result = extract_metadata(msg) + assert result['from'] is None + + +class TestMissingDate: + def test_date_is_none(self): + msg = EmailMessage() + msg['From'] = 'test@example.com' + msg['To'] = 'craig@example.com' + msg['Subject'] = 'Test' + msg.set_content("body") + result = extract_metadata(msg) + assert result['date'] is None + + +class TestLongSubject: + def test_full_subject_returned(self): + long_subject = "Re: Fw: This is a very long subject line that spans many words and might be folded" + msg = make_plain_message(subject=long_subject) + result = extract_metadata(msg) + assert result['subject'] == long_subject diff --git a/.ai/scripts/tests/test_generate_filenames.py b/.ai/scripts/tests/test_generate_filenames.py new file mode 100644 index 0000000..07c8f84 --- /dev/null +++ b/.ai/scripts/tests/test_generate_filenames.py @@ -0,0 +1,157 @@ +"""Tests for generate_basename(), generate_email_filename(), generate_attachment_filename().""" + +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +import importlib.util +spec = importlib.util.spec_from_file_location( + "eml_script", + os.path.join(os.path.dirname(__file__), '..', 'eml-view-and-extract-attachments.py') +) +eml_script = importlib.util.module_from_spec(spec) +spec.loader.exec_module(eml_script) + +generate_basename = eml_script.generate_basename +generate_email_filename = eml_script.generate_email_filename +generate_attachment_filename = eml_script.generate_attachment_filename + + +# --- generate_basename --- + +class TestGenerateBasename: + def test_standard_from_and_date(self): + metadata = { + 'from': 'Jonathan Smith <jsmith@example.com>', + 'date': 'Wed, 05 Feb 2026 11:36:00 -0600', + } + assert generate_basename(metadata) == "2026-02-05-1136-Jonathan" + + def test_from_with_display_name_first_token(self): + metadata = { + 'from': 'C Ciarm <cciarm@example.com>', + 'date': 'Wed, 05 Feb 2026 11:36:00 -0600', + } + result = generate_basename(metadata) + assert result == "2026-02-05-1136-C" + + def test_from_without_display_name(self): + metadata = { + 'from': 'jsmith@example.com', + 'date': 'Wed, 05 Feb 2026 11:36:00 -0600', + } + result = generate_basename(metadata) + assert result == "2026-02-05-1136-jsmith" + + def test_missing_date(self): + metadata = { + 'from': 'Jonathan Smith <jsmith@example.com>', + 'date': None, + } + result = generate_basename(metadata) + assert result == "unknown-Jonathan" + + def test_missing_from(self): + metadata = { + 'from': None, + 'date': 'Wed, 05 Feb 2026 11:36:00 -0600', + } + result = generate_basename(metadata) + assert result == "2026-02-05-1136-unknown" + + def test_both_missing(self): + metadata = {'from': None, 'date': None} + result = generate_basename(metadata) + assert result == "unknown-unknown" + + def test_unparseable_date(self): + metadata = { + 'from': 'Jonathan <j@example.com>', + 'date': 'not a real date', + } + result = generate_basename(metadata) + assert result == "unknown-Jonathan" + + def test_none_date_no_crash(self): + metadata = {'from': 'Test <t@e.com>', 'date': None} + # Should not raise + result = generate_basename(metadata) + assert "unknown" in result + + +# --- generate_email_filename --- + +class TestGenerateEmailFilename: + def test_standard_subject(self): + result = generate_email_filename( + "2026-02-05-1136-Jonathan", + "Re: Fw: 4319 Danneel Street" + ) + assert result == "2026-02-05-1136-Jonathan-EMAIL-Re-Fw-4319-Danneel-Street" + + def test_subject_with_special_chars(self): + result = generate_email_filename( + "2026-02-05-1136-Jonathan", + "Update: Meeting (draft) & notes!" + ) + # Colons, parens, ampersands, exclamation stripped + assert "EMAIL" in result + assert ":" not in result + assert "(" not in result + assert ")" not in result + assert "&" not in result + assert "!" not in result + + def test_none_subject(self): + result = generate_email_filename("2026-02-05-1136-Jonathan", None) + assert result == "2026-02-05-1136-Jonathan-EMAIL-no-subject" + + def test_empty_subject(self): + result = generate_email_filename("2026-02-05-1136-Jonathan", "") + assert result == "2026-02-05-1136-Jonathan-EMAIL-no-subject" + + def test_very_long_subject(self): + long_subject = "A" * 100 + " " + "B" * 100 + result = generate_email_filename("2026-02-05-1136-Jonathan", long_subject) + # The cleaned subject part should be truncated + # basename (27) + "-EMAIL-" (7) + subject + # Subject itself is limited to 80 chars by _clean_for_filename + subject_part = result.split("-EMAIL-")[1] + assert len(subject_part) <= 80 + + +# --- generate_attachment_filename --- + +class TestGenerateAttachmentFilename: + def test_standard_attachment(self): + result = generate_attachment_filename( + "2026-02-05-1136-Jonathan", + "Ltr Carrollton.pdf" + ) + assert result == "2026-02-05-1136-Jonathan-ATTACH-Ltr-Carrollton.pdf" + + def test_filename_with_spaces_and_parens(self): + result = generate_attachment_filename( + "2026-02-05-1136-Jonathan", + "Document (final copy).pdf" + ) + assert " " not in result + assert "(" not in result + assert ")" not in result + assert result.endswith(".pdf") + + def test_preserves_extension(self): + result = generate_attachment_filename( + "2026-02-05-1136-Jonathan", + "photo.jpg" + ) + assert result.endswith(".jpg") + + def test_none_filename(self): + result = generate_attachment_filename("2026-02-05-1136-Jonathan", None) + assert result == "2026-02-05-1136-Jonathan-ATTACH-unnamed" + + def test_empty_filename(self): + result = generate_attachment_filename("2026-02-05-1136-Jonathan", "") + assert result == "2026-02-05-1136-Jonathan-ATTACH-unnamed" diff --git a/.ai/scripts/tests/test_gmail_fetch_attachments.py b/.ai/scripts/tests/test_gmail_fetch_attachments.py new file mode 100644 index 0000000..b4fba41 --- /dev/null +++ b/.ai/scripts/tests/test_gmail_fetch_attachments.py @@ -0,0 +1,420 @@ +"""Tests for gmail-fetch-attachments.py. + +Covers: +- Pure helpers: safe_filename, collect_attachments, load_client_creds +- File I/O: load_mcp_env, load_refresh_token (tmp_path + monkeypatch on + module-level constants CLAUDE_CONFIG and TOKEN_DIR) +- HTTP wrappers: refresh_access_token, gmail_get (monkeypatch on + urllib.request.urlopen) +- Argparse: --help / missing-args via subprocess + +Strategy mirrors test_cmail_action.py: import the script via importlib +(filename has hyphens), mock at external boundaries, no integration +test for main() — the components are tested individually. +""" + +from __future__ import annotations + +import importlib.util +import json +import subprocess +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +SCRIPT_PATH = Path(__file__).resolve().parent.parent / "gmail-fetch-attachments.py" + + +def _load_module(): + spec = importlib.util.spec_from_file_location( + "gmail_fetch_attachments", str(SCRIPT_PATH) + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture(scope="module") +def gfa(): + return _load_module() + + +def _mock_urlopen_response(payload): + """Build a MagicMock mimicking urllib.request.urlopen()'s context-manager response.""" + mock_resp = MagicMock() + mock_resp.read.return_value = json.dumps(payload).encode() + mock_resp.__enter__ = MagicMock(return_value=mock_resp) + mock_resp.__exit__ = MagicMock(return_value=False) + return mock_resp + + +# --------------------------------------------------------------------------- +# safe_filename — pure +# --------------------------------------------------------------------------- + +class TestSafeFilename: + + def test_normal_clean_filename(self, gfa): + assert gfa.safe_filename("report.pdf") == "report.pdf" + + def test_boundary_forward_slash_replaced_with_underscore(self, gfa): + assert gfa.safe_filename("foo/bar.txt") == "foo_bar.txt" + + def test_boundary_backslash_replaced_with_underscore(self, gfa): + assert gfa.safe_filename("foo\\bar.txt") == "foo_bar.txt" + + def test_boundary_path_traversal_stripped(self, gfa): + # "../etc/passwd" -> after slash replace: ".._etc_passwd" + # While loop strips leading "..": "_etc_passwd" + assert gfa.safe_filename("../etc/passwd") == "_etc_passwd" + + def test_boundary_dotfile_preserved(self, gfa): + # The fix Craig requested: single-dot prefixes survive so dotfiles + # like .gitignore aren't silently renamed. + assert gfa.safe_filename(".gitignore") == ".gitignore" + assert gfa.safe_filename(".env.local") == ".env.local" + + def test_boundary_empty_string(self, gfa): + assert gfa.safe_filename("") == "" + + @pytest.mark.parametrize("input_name,expected", [ + ("..", ""), # single ".." stripped, leaves empty + ("...", "."), # one strip leaves a single dot + ("....", ""), # two strips leave empty + (".....", "."), # two strips leave one dot + ]) + def test_boundary_only_dots(self, gfa, input_name, expected): + assert gfa.safe_filename(input_name) == expected + + def test_boundary_double_dot_followed_by_name_stripped(self, gfa): + assert gfa.safe_filename("..foo") == "foo" + + def test_boundary_middle_dotdot_preserved(self, gfa): + # Only LEADING ".." gets stripped. Mid-string ".." stays. + # "foo..bar" has no leading dots, so it's preserved as-is. + assert gfa.safe_filename("foo..bar") == "foo..bar" + + +# --------------------------------------------------------------------------- +# collect_attachments — pure +# --------------------------------------------------------------------------- + +class TestCollectAttachments: + + def test_normal_single_attachment(self, gfa): + payload = { + "parts": [ + {"mimeType": "text/plain", "body": {"size": 100}}, + {"filename": "doc.pdf", "mimeType": "application/pdf", + "body": {"attachmentId": "abc123", "size": 5000}}, + ] + } + result = gfa.collect_attachments(payload) + assert result == [{ + "filename": "doc.pdf", + "attachmentId": "abc123", + "size": 5000, + "mimeType": "application/pdf", + }] + + def test_boundary_nested_multipart_recursion(self, gfa): + payload = { + "parts": [ + {"mimeType": "multipart/mixed", "parts": [ + {"mimeType": "multipart/alternative", "parts": [ + {"filename": "deep.pdf", "mimeType": "application/pdf", + "body": {"attachmentId": "deep1", "size": 100}}, + ]}, + ]}, + ] + } + result = gfa.collect_attachments(payload) + assert len(result) == 1 + assert result[0]["filename"] == "deep.pdf" + assert result[0]["attachmentId"] == "deep1" + + def test_boundary_no_attachments_returns_empty(self, gfa): + payload = { + "parts": [ + {"mimeType": "text/plain", "body": {"size": 100}}, + {"mimeType": "text/html", "body": {"size": 200}}, + ] + } + assert gfa.collect_attachments(payload) == [] + + def test_boundary_inline_image_no_filename_skipped(self, gfa): + # Inline images embedded via cid: typically have an attachmentId + # but no filename. The "user-visible attachments" heuristic skips + # them so they don't litter the output dir as image001.png. + payload = { + "parts": [ + {"mimeType": "image/png", + "body": {"attachmentId": "inline1", "size": 500}}, + ] + } + assert gfa.collect_attachments(payload) == [] + + def test_boundary_empty_filename_skipped(self, gfa): + # Empty-string filename also skipped (truthy check). + payload = { + "parts": [ + {"filename": "", "mimeType": "image/png", + "body": {"attachmentId": "empty1", "size": 500}}, + ] + } + assert gfa.collect_attachments(payload) == [] + + def test_boundary_filename_without_attachment_id_skipped(self, gfa): + # A part with a filename but no attachmentId isn't a separately + # downloadable attachment — it's inline content with a name. + payload = { + "parts": [ + {"filename": "fake.txt", "mimeType": "text/plain", + "body": {"size": 100}}, + ] + } + assert gfa.collect_attachments(payload) == [] + + def test_boundary_multiple_attachments_at_different_depths(self, gfa): + payload = { + "parts": [ + {"filename": "top.pdf", "mimeType": "application/pdf", + "body": {"attachmentId": "top1", "size": 100}}, + {"mimeType": "multipart/mixed", "parts": [ + {"filename": "nested.txt", "mimeType": "text/plain", + "body": {"attachmentId": "nested1", "size": 50}}, + ]}, + ] + } + result = gfa.collect_attachments(payload) + names = sorted(r["filename"] for r in result) + assert names == ["nested.txt", "top.pdf"] + + def test_boundary_default_mimetype_when_missing(self, gfa): + payload = { + "parts": [ + {"filename": "x.bin", + "body": {"attachmentId": "x1", "size": 10}}, + ] + } + result = gfa.collect_attachments(payload) + assert result[0]["mimeType"] == "application/octet-stream" + + def test_error_empty_payload(self, gfa): + assert gfa.collect_attachments({}) == [] + + def test_error_payload_with_null_parts(self, gfa): + # Defensive: parts = None falls through to empty list via `or []`. + payload = {"parts": None} + assert gfa.collect_attachments(payload) == [] + + +# --------------------------------------------------------------------------- +# load_client_creds — pure +# --------------------------------------------------------------------------- + +class TestLoadClientCreds: + + def test_normal_both_credentials_present(self, gfa): + env = {"GOOGLE_CLIENT_ID": "cid123", "GOOGLE_CLIENT_SECRET": "secret456"} + assert gfa.load_client_creds(env) == ("cid123", "secret456") + + def test_error_missing_client_id(self, gfa): + env = {"GOOGLE_CLIENT_SECRET": "secret456"} + with pytest.raises(SystemExit): + gfa.load_client_creds(env) + + def test_error_missing_client_secret(self, gfa): + env = {"GOOGLE_CLIENT_ID": "cid123"} + with pytest.raises(SystemExit): + gfa.load_client_creds(env) + + def test_error_empty_client_id(self, gfa): + env = {"GOOGLE_CLIENT_ID": "", "GOOGLE_CLIENT_SECRET": "secret456"} + with pytest.raises(SystemExit): + gfa.load_client_creds(env) + + def test_error_empty_client_secret(self, gfa): + env = {"GOOGLE_CLIENT_ID": "cid123", "GOOGLE_CLIENT_SECRET": ""} + with pytest.raises(SystemExit): + gfa.load_client_creds(env) + + +# --------------------------------------------------------------------------- +# load_mcp_env — file I/O via tmp_path + monkeypatch CLAUDE_CONFIG +# --------------------------------------------------------------------------- + +class TestLoadMcpEnv: + + @staticmethod + def _write_config(tmp_path, monkeypatch, gfa, content): + config_path = tmp_path / ".claude.json" + config_path.write_text(json.dumps(content)) + monkeypatch.setattr(gfa, "CLAUDE_CONFIG", config_path) + return config_path + + def test_normal_personal_profile_with_env(self, monkeypatch, gfa, tmp_path): + self._write_config(tmp_path, monkeypatch, gfa, { + "mcpServers": { + "google-docs-personal": { + "env": {"GOOGLE_CLIENT_ID": "cid", "GOOGLE_CLIENT_SECRET": "sec"} + } + } + }) + env = gfa.load_mcp_env("personal") + assert env == {"GOOGLE_CLIENT_ID": "cid", "GOOGLE_CLIENT_SECRET": "sec"} + + def test_boundary_server_present_no_env_key(self, monkeypatch, gfa, tmp_path): + self._write_config(tmp_path, monkeypatch, gfa, { + "mcpServers": {"google-docs-work": {}} + }) + assert gfa.load_mcp_env("work") == {} + + def test_boundary_env_explicitly_null(self, monkeypatch, gfa, tmp_path): + # The `or {}` defends against null env. Returns empty dict, not None. + self._write_config(tmp_path, monkeypatch, gfa, { + "mcpServers": {"google-docs-personal": {"env": None}} + }) + assert gfa.load_mcp_env("personal") == {} + + def test_error_config_file_missing(self, monkeypatch, gfa, tmp_path): + monkeypatch.setattr(gfa, "CLAUDE_CONFIG", tmp_path / "nope.json") + with pytest.raises(SystemExit): + gfa.load_mcp_env("personal") + + def test_error_server_not_in_config(self, monkeypatch, gfa, tmp_path): + self._write_config(tmp_path, monkeypatch, gfa, { + "mcpServers": {"google-docs-personal": {"env": {}}} + }) + with pytest.raises(SystemExit): + gfa.load_mcp_env("work") + + +# --------------------------------------------------------------------------- +# load_refresh_token — file I/O via tmp_path + monkeypatch TOKEN_DIR +# --------------------------------------------------------------------------- + +class TestLoadRefreshToken: + + @staticmethod + def _setup_token(tmp_path, monkeypatch, gfa, profile=None, content=None): + token_dir = tmp_path / "google-docs-mcp" + token_dir.mkdir() + if profile: + (token_dir / profile).mkdir() + token_path = token_dir / profile / "token.json" + else: + token_path = token_dir / "token.json" + if content is not None: + token_path.write_text(json.dumps(content)) + monkeypatch.setattr(gfa, "TOKEN_DIR", token_dir) + return token_path + + def test_normal_no_profile_token_at_root(self, monkeypatch, gfa, tmp_path): + self._setup_token(tmp_path, monkeypatch, gfa, + content={"refresh_token": "rt-root"}) + assert gfa.load_refresh_token({}) == "rt-root" + + def test_boundary_with_profile_subdir(self, monkeypatch, gfa, tmp_path): + self._setup_token(tmp_path, monkeypatch, gfa, profile="personal", + content={"refresh_token": "rt-personal"}) + assert gfa.load_refresh_token( + {"GOOGLE_MCP_PROFILE": "personal"} + ) == "rt-personal" + + def test_boundary_explicit_empty_profile_falls_back_to_root( + self, monkeypatch, gfa, tmp_path): + # GOOGLE_MCP_PROFILE="" is treated the same as the key being missing — + # both fall back to TOKEN_DIR/token.json. Pinning both shapes so a + # future refactor that drops `or ""` doesn't silently break this. + self._setup_token(tmp_path, monkeypatch, gfa, + content={"refresh_token": "rt-root"}) + assert gfa.load_refresh_token({"GOOGLE_MCP_PROFILE": ""}) == "rt-root" + + def test_error_token_file_missing(self, monkeypatch, gfa, tmp_path): + token_dir = tmp_path / "google-docs-mcp" + token_dir.mkdir() + monkeypatch.setattr(gfa, "TOKEN_DIR", token_dir) + with pytest.raises(SystemExit): + gfa.load_refresh_token({}) + + def test_error_no_refresh_token_field_in_file(self, monkeypatch, gfa, tmp_path): + self._setup_token(tmp_path, monkeypatch, gfa, + content={"access_token": "at-only"}) + with pytest.raises(SystemExit): + gfa.load_refresh_token({}) + + +# --------------------------------------------------------------------------- +# refresh_access_token — mocked urllib +# --------------------------------------------------------------------------- + +class TestRefreshAccessToken: + + def test_normal_returns_access_token(self, monkeypatch, gfa): + mock_urlopen = MagicMock( + return_value=_mock_urlopen_response({"access_token": "at-new"}) + ) + monkeypatch.setattr(gfa.urllib.request, "urlopen", mock_urlopen) + result = gfa.refresh_access_token("rt-val", "cid-val", "sec-val") + assert result == "at-new" + # Verify the request shape: URL, body grant_type and refresh_token. + req = mock_urlopen.call_args[0][0] + assert req.full_url == gfa.OAUTH_TOKEN_URL + body = req.data.decode() + assert "grant_type=refresh_token" in body + assert "refresh_token=rt-val" in body + assert "client_id=cid-val" in body + + def test_error_response_missing_access_token(self, monkeypatch, gfa): + mock_urlopen = MagicMock( + return_value=_mock_urlopen_response({"error": "invalid_grant"}) + ) + monkeypatch.setattr(gfa.urllib.request, "urlopen", mock_urlopen) + with pytest.raises(SystemExit): + gfa.refresh_access_token("rt", "cid", "sec") + + +# --------------------------------------------------------------------------- +# gmail_get — mocked urllib +# --------------------------------------------------------------------------- + +class TestGmailGet: + + def test_normal_returns_parsed_json_with_bearer_header(self, monkeypatch, gfa): + mock_urlopen = MagicMock( + return_value=_mock_urlopen_response({"id": "msg123", "snippet": "hi"}) + ) + monkeypatch.setattr(gfa.urllib.request, "urlopen", mock_urlopen) + result = gfa.gmail_get("/messages/msg123", "at-token") + assert result == {"id": "msg123", "snippet": "hi"} + req = mock_urlopen.call_args[0][0] + assert req.full_url == f"{gfa.GMAIL_API}/messages/msg123" + # urllib.request.Request lowercases header names except the first + # char via .capitalize() → "Authorization" stays as "Authorization". + assert req.headers["Authorization"] == "Bearer at-token" + + +# --------------------------------------------------------------------------- +# Argparse — black-box subprocess sanity check +# --------------------------------------------------------------------------- + +class TestArgparseShape: + + def test_normal_help_lists_all_required_args(self): + result = subprocess.run( + [sys.executable, str(SCRIPT_PATH), "--help"], + capture_output=True, text=True, + ) + assert result.returncode == 0 + for flag in ("--profile", "--message-id", "--output-dir"): + assert flag in result.stdout + + def test_error_no_args_exits_nonzero(self): + result = subprocess.run( + [sys.executable, str(SCRIPT_PATH)], + capture_output=True, text=True, + ) + assert result.returncode != 0 diff --git a/.ai/scripts/tests/test_inbox_send.py b/.ai/scripts/tests/test_inbox_send.py new file mode 100644 index 0000000..597a7e9 --- /dev/null +++ b/.ai/scripts/tests/test_inbox_send.py @@ -0,0 +1,329 @@ +"""Tests for inbox-send.py — universal cross-project inbox messaging tool. + +The script: +- discovers .ai projects with an inbox/ subdirectory under known roots, +- writes a text message as a dated .org file in the target's inbox/, or +- copies a file into the target's inbox/ with a dated, source-tagged name. + +All discovery is roots-driven (env var INBOX_SEND_ROOTS overrides the +defaults) so tests can sandbox everything inside tmp_path. +""" + +import subprocess +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).parent.parent / "inbox-send.py" + + +@pytest.fixture +def project_root(tmp_path): + """Build a fake project under tmp_path/projects/<name>/ with .ai/ + top-level inbox/.""" + def _make(name: str, has_inbox: bool = True) -> Path: + proj = tmp_path / "projects" / name + proj.mkdir(parents=True, exist_ok=True) + (proj / ".ai").mkdir(exist_ok=True) + if has_inbox: + (proj / "inbox").mkdir(exist_ok=True) + return proj + return _make + + +@pytest.fixture +def run_script(tmp_path): + """Invoke inbox-send with sandboxed roots via INBOX_SEND_ROOTS env var.""" + def _run(args, cwd=None, roots=None, expect_failure=False): + env = {} + # Preserve PATH and a few essentials for python3 to launch. + import os as _os + env["PATH"] = _os.environ.get("PATH", "") + env["HOME"] = _os.environ.get("HOME", "/tmp") + if roots: + env["INBOX_SEND_ROOTS"] = ":".join(str(r) for r in roots) + cmd = ["python3", str(SCRIPT)] + args + result = subprocess.run( + cmd, + capture_output=True, + text=True, + cwd=cwd or tmp_path, + env=env, + check=not expect_failure, + ) + return result + return _run + + +# ---------------------------------------------------------------------- +# Discovery (--list) +# ---------------------------------------------------------------------- + +class TestInboxSendDiscovery: + """Discovering available .ai projects under the configured roots.""" + + def test_inbox_send_list_detects_projects_with_ai_inbox(self, project_root, run_script, tmp_path): + """Normal: --list shows projects that have .ai/inbox/.""" + project_root("foo") + project_root("bar") + result = run_script(["--list"], roots=[tmp_path / "projects"]) + assert "foo" in result.stdout + assert "bar" in result.stdout + + def test_inbox_send_list_skips_projects_without_inbox(self, project_root, run_script, tmp_path): + """Boundary: project with .ai/ but no inbox/ is not surfaced.""" + project_root("withinbox", has_inbox=True) + project_root("noinbox", has_inbox=False) + result = run_script(["--list"], roots=[tmp_path / "projects"]) + assert "withinbox" in result.stdout + assert "noinbox" not in result.stdout + + def test_inbox_send_list_skips_current_project(self, project_root, run_script, tmp_path): + """Normal: --list excludes the project the user is currently in.""" + cwd_project = project_root("current") + project_root("other") + result = run_script(["--list"], cwd=cwd_project, roots=[tmp_path / "projects"]) + assert "other" in result.stdout + assert "current" not in result.stdout + + def test_inbox_send_list_empty_when_no_projects(self, run_script, tmp_path): + """Boundary: no projects under roots → friendly informational message.""" + (tmp_path / "projects").mkdir() + result = run_script(["--list"], roots=[tmp_path / "projects"]) + assert result.returncode == 0 + assert "No projects" in result.stdout + + def test_inbox_send_list_handles_missing_root(self, run_script, tmp_path): + """Boundary: configured root doesn't exist → skip silently.""" + result = run_script(["--list"], roots=[tmp_path / "does-not-exist"]) + assert result.returncode == 0 + + +# ---------------------------------------------------------------------- +# Slug derivation from text and from filenames +# ---------------------------------------------------------------------- + +def _slug_from(inbox_files, source_name): + """Helper: extract the slug from a deposited file's basename.""" + assert len(inbox_files) == 1 + name = inbox_files[0].stem + marker = f"from-{source_name}-" + return name.split(marker, 1)[1] + + +class TestInboxSendNaming: + """Slug derivation from --text (and override via --name).""" + + def test_inbox_send_text_slug_hyphenated_lowercase(self, project_root, run_script, tmp_path): + """Normal: 'ATM cash reminder' → slug 'atm-cash-reminder'.""" + project_root("target") + cwd = project_root("source") + run_script( + ["target", "--text", "ATM cash reminder"], + cwd=cwd, roots=[tmp_path / "projects"], + ) + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + assert _slug_from(files, "source") == "atm-cash-reminder" + + def test_inbox_send_text_slug_truncated_at_word_boundary(self, project_root, run_script, tmp_path): + """Normal: long text truncated under 40 chars at the nearest word boundary.""" + project_root("target") + cwd = project_root("source") + long_text = ( + "Please review the SOFWeek prep doc and confirm the AirBnB kitchen details" + ) + run_script( + ["target", "--text", long_text], + cwd=cwd, roots=[tmp_path / "projects"], + ) + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + slug = _slug_from(files, "source") + assert slug.startswith("please-review-the-sofweek") + assert len(slug) <= 40 + # Truncation should land on a word boundary (last char is a letter/digit, not mid-word). + assert "-" not in slug[-1] + + def test_inbox_send_text_slug_strips_punctuation(self, project_root, run_script, tmp_path): + """Normal: punctuation stripped, lowercased.""" + project_root("target") + cwd = project_root("source") + run_script( + ["target", "--text", "Hey! What's the plan? See you @ 5PM."], + cwd=cwd, roots=[tmp_path / "projects"], + ) + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + slug = _slug_from(files, "source") + for ch in "!?'@.": + assert ch not in slug + assert slug == slug.lower() + + def test_inbox_send_name_override_overrides_slug(self, project_root, run_script, tmp_path): + """Normal: --name wins over derived slug.""" + project_root("target") + cwd = project_root("source") + run_script( + ["target", "--text", "ok", "--name", "pre-call-ack"], + cwd=cwd, roots=[tmp_path / "projects"], + ) + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + assert _slug_from(files, "source") == "pre-call-ack" + + +# ---------------------------------------------------------------------- +# --text mode end-to-end +# ---------------------------------------------------------------------- + +class TestInboxSendText: + """--text mode writes a .org file with the message body.""" + + def test_inbox_send_text_writes_org_file_with_message(self, project_root, run_script, tmp_path): + """Normal: produces a .org file whose body contains the message.""" + project_root("target") + cwd = project_root("source") + run_script( + ["target", "--text", "Remember the ATM run"], + cwd=cwd, roots=[tmp_path / "projects"], + ) + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + assert len(files) == 1 + assert files[0].suffix == ".org" + body = files[0].read_text() + assert "Remember the ATM run" in body + + def test_inbox_send_text_filename_includes_source_project_name(self, project_root, run_script, tmp_path): + """Normal: filename includes 'from-<source>-' so the target knows where it came from.""" + project_root("target") + cwd = project_root("emacs") + run_script( + ["target", "--text", "hello"], + cwd=cwd, roots=[tmp_path / "projects"], + ) + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + assert "from-emacs-" in files[0].name + + +# ---------------------------------------------------------------------- +# --file mode end-to-end +# ---------------------------------------------------------------------- + +class TestInboxSendFile: + """--file mode copies the source file into the target inbox.""" + + def test_inbox_send_file_copies_text_file(self, project_root, run_script, tmp_path): + """Normal: copies a text file to the target inbox, preserving content.""" + project_root("target") + cwd = project_root("source") + src = tmp_path / "doc.org" + src.write_text("file content") + run_script( + ["target", "--file", str(src)], + cwd=cwd, roots=[tmp_path / "projects"], + ) + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + assert len(files) == 1 + assert files[0].read_text() == "file content" + + def test_inbox_send_file_preserves_extension(self, project_root, run_script, tmp_path): + """Normal: extension carried from source file.""" + project_root("target") + cwd = project_root("source") + src = tmp_path / "image.png" + src.write_bytes(b"\x89PNG\r\n...") + run_script( + ["target", "--file", str(src)], + cwd=cwd, roots=[tmp_path / "projects"], + ) + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + assert files[0].suffix == ".png" + + def test_inbox_send_file_slug_from_source_basename(self, project_root, run_script, tmp_path): + """Normal: filename slug derived from the source file's basename when --name omitted.""" + project_root("target") + cwd = project_root("source") + src = tmp_path / "branching-strategy-notes.md" + src.write_text("notes") + run_script( + ["target", "--file", str(src)], + cwd=cwd, roots=[tmp_path / "projects"], + ) + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + assert "branching-strategy-notes" in files[0].name + + def test_inbox_send_file_name_override(self, project_root, run_script, tmp_path): + """Normal: --name overrides the basename-derived slug; extension preserved.""" + project_root("target") + cwd = project_root("source") + src = tmp_path / "random.pdf" + src.write_bytes(b"%PDF-1.4...") + run_script( + ["target", "--file", str(src), "--name", "branching-strategy"], + cwd=cwd, roots=[tmp_path / "projects"], + ) + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + assert "branching-strategy" in files[0].name + assert files[0].suffix == ".pdf" + + +# ---------------------------------------------------------------------- +# Errors and refusal cases +# ---------------------------------------------------------------------- + +class TestInboxSendErrors: + """Refusal cases — surface clearly, exit non-zero, leave filesystem untouched.""" + + def test_inbox_send_refuses_unknown_target(self, project_root, run_script, tmp_path): + """Error: target project not found in discovery → refuse.""" + cwd = project_root("source") + result = run_script( + ["nonexistent", "--text", "hi"], + cwd=cwd, roots=[tmp_path / "projects"], + expect_failure=True, + ) + assert result.returncode != 0 + + def test_inbox_send_refuses_no_text_and_no_file(self, project_root, run_script, tmp_path): + """Error: must provide one of --text / --file.""" + project_root("target") + cwd = project_root("source") + result = run_script( + ["target"], + cwd=cwd, roots=[tmp_path / "projects"], + expect_failure=True, + ) + assert result.returncode != 0 + + def test_inbox_send_refuses_both_text_and_file(self, project_root, run_script, tmp_path): + """Error: --text and --file are mutually exclusive.""" + project_root("target") + cwd = project_root("source") + src = tmp_path / "doc.org" + src.write_text("x") + result = run_script( + ["target", "--text", "hi", "--file", str(src)], + cwd=cwd, roots=[tmp_path / "projects"], + expect_failure=True, + ) + assert result.returncode != 0 + + def test_inbox_send_refuses_missing_source_file(self, project_root, run_script, tmp_path): + """Error: --file path doesn't exist → refuse.""" + project_root("target") + cwd = project_root("source") + result = run_script( + ["target", "--file", str(tmp_path / "definitely-missing.org")], + cwd=cwd, roots=[tmp_path / "projects"], + expect_failure=True, + ) + assert result.returncode != 0 + + def test_inbox_send_refuses_empty_text(self, project_root, run_script, tmp_path): + """Error: empty --text refused; nothing written to target inbox.""" + project_root("target") + cwd = project_root("source") + result = run_script( + ["target", "--text", " "], + cwd=cwd, roots=[tmp_path / "projects"], + expect_failure=True, + ) + assert result.returncode != 0 + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + assert files == [] diff --git a/.ai/scripts/tests/test_integration_stdout.py b/.ai/scripts/tests/test_integration_stdout.py new file mode 100644 index 0000000..d87478e --- /dev/null +++ b/.ai/scripts/tests/test_integration_stdout.py @@ -0,0 +1,68 @@ +"""Integration tests for backwards-compatible stdout mode (no --output-dir).""" + +import os +import shutil +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +import importlib.util +spec = importlib.util.spec_from_file_location( + "eml_script", + os.path.join(os.path.dirname(__file__), '..', 'eml-view-and-extract-attachments.py') +) +eml_script = importlib.util.module_from_spec(spec) +spec.loader.exec_module(eml_script) + +print_email = eml_script.print_email + +FIXTURES = os.path.join(os.path.dirname(__file__), 'fixtures') + + +class TestPlainTextStdout: + def test_metadata_and_body_printed(self, tmp_path, capsys): + eml_src = os.path.join(FIXTURES, 'plain-text.eml') + working_eml = tmp_path / "message.eml" + shutil.copy2(eml_src, working_eml) + + print_email(str(working_eml)) + captured = capsys.readouterr() + + assert "From: Jonathan Smith <jsmith@example.com>" in captured.out + assert "To: Craig Jennings <craig@example.com>" in captured.out + assert "Subject: Re: Fw: 4319 Danneel Street" in captured.out + assert "Date:" in captured.out + assert "Sent:" in captured.out + assert "Received:" in captured.out + assert "4319 Danneel Street" in captured.out + + +class TestHtmlFallbackStdout: + def test_html_converted_on_stdout(self, tmp_path, capsys): + eml_src = os.path.join(FIXTURES, 'html-only.eml') + working_eml = tmp_path / "message.eml" + shutil.copy2(eml_src, working_eml) + + print_email(str(working_eml)) + captured = capsys.readouterr() + + # Should see converted text, not raw HTML + assert "HTML" in captured.out + assert "<p>" not in captured.out + + +class TestAttachmentsStdout: + def test_attachment_extracted_alongside_eml(self, tmp_path, capsys): + eml_src = os.path.join(FIXTURES, 'with-attachment.eml') + working_eml = tmp_path / "message.eml" + shutil.copy2(eml_src, working_eml) + + print_email(str(working_eml)) + captured = capsys.readouterr() + + assert "Extracted attachment:" in captured.out + assert "Ltr Carrollton.pdf" in captured.out + + # File should exist alongside the EML + extracted = tmp_path / "Ltr Carrollton.pdf" + assert extracted.exists() diff --git a/.ai/scripts/tests/test_maildir_flag_manager.py b/.ai/scripts/tests/test_maildir_flag_manager.py new file mode 100644 index 0000000..268af5b --- /dev/null +++ b/.ai/scripts/tests/test_maildir_flag_manager.py @@ -0,0 +1,310 @@ +"""Tests for maildir-flag-manager.py. + +Covers: +- Pure parsers: parse_maildir_flags, build_flagged_filename +- File-I/O ops: rename_with_flag, process_maildir, process_specific_files + (tmp_path with real maildir directory structures) +- Subprocess wrapper: reindex_mu (monkeypatch on shutil.which + subprocess.run) +- Argparse: --help / missing-subcommand via subprocess + +The cmd_mark_read / cmd_star orchestrators are intentionally skipped — +they call the helpers and print summaries; the helpers are tested +directly so testing the orchestrators would mostly assert call counts. +""" + +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +SCRIPT_PATH = Path(__file__).resolve().parent.parent / "maildir-flag-manager.py" + + +def _load_module(): + spec = importlib.util.spec_from_file_location( + "maildir_flag_manager", str(SCRIPT_PATH) + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture(scope="module") +def mfm(): + return _load_module() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_maildir(tmp_path: Path, files=None) -> Path: + """Construct a maildir at tmp_path/inbox with new/ and cur/ subdirs. + + files is a list of (subdir, filename) tuples. Each becomes an empty + file at tmp_path/inbox/<subdir>/<filename>. + """ + inbox = tmp_path / "inbox" + (inbox / "new").mkdir(parents=True) + (inbox / "cur").mkdir() + for subdir, fname in (files or []): + (inbox / subdir / fname).write_text("body") + return inbox + + +# --------------------------------------------------------------------------- +# parse_maildir_flags — pure +# --------------------------------------------------------------------------- + +class TestParseMaildirFlags: + + def test_normal_typical_filename(self, mfm): + assert mfm.parse_maildir_flags("12345.host:2,FS") == ("12345.host", "FS") + + def test_boundary_no_flag_suffix(self, mfm): + # No ":2," in filename — return whole name as base, empty flags. + assert mfm.parse_maildir_flags("12345.host") == ("12345.host", "") + + def test_boundary_empty_flags_section(self, mfm): + # ":2," with nothing after — base is parsed, flags are empty. + assert mfm.parse_maildir_flags("12345.host:2,") == ("12345.host", "") + + def test_boundary_multiple_colons_in_base(self, mfm): + # rsplit on the LAST ":2," — base may contain colons or even ":2,"-like + # substrings. Real maildir names sometimes have these from migrations. + assert mfm.parse_maildir_flags("weird:thing:2,FS") == ("weird:thing", "FS") + + def test_boundary_empty_string(self, mfm): + assert mfm.parse_maildir_flags("") == ("", "") + + +# --------------------------------------------------------------------------- +# build_flagged_filename — pure +# --------------------------------------------------------------------------- + +class TestBuildFlaggedFilename: + + def test_normal_base_plus_flags(self, mfm): + assert mfm.build_flagged_filename("12345.host", "FS") == "12345.host:2,FS" + + def test_boundary_replaces_existing_flags(self, mfm): + # Existing flags get parsed away — the new_flags arg is the source of truth. + assert mfm.build_flagged_filename("12345.host:2,F", "FS") == "12345.host:2,FS" + + def test_boundary_flags_sorted_alphabetically(self, mfm): + # Maildir spec requires alphabetical sort. SFR -> FRS. + assert mfm.build_flagged_filename("12345.host", "SFR") == "12345.host:2,FRS" + + def test_boundary_duplicate_flags_dedup(self, mfm): + # set() dedups before sort. FFS -> FS. + assert mfm.build_flagged_filename("12345.host", "FFS") == "12345.host:2,FS" + + def test_boundary_empty_flags(self, mfm): + assert mfm.build_flagged_filename("12345.host", "") == "12345.host:2," + + +# --------------------------------------------------------------------------- +# rename_with_flag — file I/O via tmp_path +# --------------------------------------------------------------------------- + +class TestRenameWithFlag: + + def test_normal_add_F_to_cur_file_renamed_in_place(self, mfm, tmp_path): + inbox = _make_maildir(tmp_path, [("cur", "12345.host:2,")]) + original = inbox / "cur" / "12345.host:2," + assert mfm.rename_with_flag(str(original), "F") is True + assert not original.exists() + assert (inbox / "cur" / "12345.host:2,F").exists() + + def test_boundary_add_S_to_new_file_moves_to_cur(self, mfm, tmp_path): + # Maildir spec: messages with Seen flag belong in cur/, not new/. + inbox = _make_maildir(tmp_path, [("new", "12345.host:2,")]) + original = inbox / "new" / "12345.host:2," + assert mfm.rename_with_flag(str(original), "S") is True + assert not original.exists() + # Should land in cur/, not new/. + assert (inbox / "cur" / "12345.host:2,S").exists() + assert not (inbox / "new" / "12345.host:2,S").exists() + + def test_boundary_add_F_to_new_file_stays_in_new(self, mfm, tmp_path): + # F (Flagged) doesn't trigger the new/ -> cur/ migration; only S does. + inbox = _make_maildir(tmp_path, [("new", "12345.host:2,")]) + original = inbox / "new" / "12345.host:2," + assert mfm.rename_with_flag(str(original), "F") is True + assert (inbox / "new" / "12345.host:2,F").exists() + assert not (inbox / "cur" / "12345.host:2,F").exists() + + def test_boundary_flag_already_present_returns_false(self, mfm, tmp_path): + inbox = _make_maildir(tmp_path, [("cur", "12345.host:2,FS")]) + original = inbox / "cur" / "12345.host:2,FS" + assert mfm.rename_with_flag(str(original), "F") is False + # Original file unchanged. + assert original.exists() + + def test_boundary_dry_run_does_not_modify_filesystem(self, mfm, tmp_path): + inbox = _make_maildir(tmp_path, [("cur", "12345.host:2,")]) + original = inbox / "cur" / "12345.host:2," + assert mfm.rename_with_flag(str(original), "F", dry_run=True) is True + # Original still exists, no new file. + assert original.exists() + assert not (inbox / "cur" / "12345.host:2,F").exists() + + def test_error_file_path_does_not_exist(self, mfm, tmp_path): + inbox = _make_maildir(tmp_path) + with pytest.raises(FileNotFoundError): + mfm.rename_with_flag(str(inbox / "cur" / "ghost:2,"), "F") + + +# --------------------------------------------------------------------------- +# process_maildir — tmp_path +# --------------------------------------------------------------------------- + +class TestProcessMaildir: + + def test_normal_mixed_flagged_and_unflagged(self, mfm, tmp_path): + inbox = _make_maildir(tmp_path, [ + ("new", "msg1:2,"), + ("new", "msg2:2,"), + ("cur", "msg3:2,S"), # already has S, will skip + ("cur", "msg4:2,"), + ]) + changed, skipped, errors = mfm.process_maildir(str(inbox), "S") + # 3 didn't have S yet, 1 already did. + assert (changed, skipped, errors) == (3, 1, 0) + # The two from new/ have moved to cur/ (S triggers the migration). + assert (inbox / "cur" / "msg1:2,S").exists() + assert (inbox / "cur" / "msg2:2,S").exists() + assert (inbox / "cur" / "msg4:2,S").exists() + + def test_boundary_empty_maildir(self, mfm, tmp_path): + inbox = _make_maildir(tmp_path) + assert mfm.process_maildir(str(inbox), "S") == (0, 0, 0) + + def test_boundary_maildir_does_not_exist(self, mfm, tmp_path, capsys): + # Returns (0, 0, 0) and logs a friendly message to stderr. + result = mfm.process_maildir(str(tmp_path / "nope"), "S") + assert result == (0, 0, 0) + err = capsys.readouterr().err + assert "Skipping" in err + + def test_boundary_non_file_entries_skipped(self, mfm, tmp_path): + # A stray subdirectory in cur/ shouldn't crash the scan. + inbox = _make_maildir(tmp_path, [("cur", "msg1:2,")]) + (inbox / "cur" / "stray-dir").mkdir() + changed, skipped, errors = mfm.process_maildir(str(inbox), "S") + assert (changed, skipped, errors) == (1, 0, 0) + + def test_boundary_only_new_subdir_present(self, mfm, tmp_path): + # If cur/ doesn't exist, the loop just skips it instead of erroring. + inbox = tmp_path / "inbox" + (inbox / "new").mkdir(parents=True) + (inbox / "new" / "msg1:2,").write_text("body") + changed, skipped, errors = mfm.process_maildir(str(inbox), "F") + assert (changed, skipped, errors) == (1, 0, 0) + + +# --------------------------------------------------------------------------- +# process_specific_files — tmp_path +# --------------------------------------------------------------------------- + +class TestProcessSpecificFiles: + + def test_normal_paths_in_cur_and_new(self, mfm, tmp_path): + inbox = _make_maildir(tmp_path, [ + ("cur", "msg1:2,"), + ("new", "msg2:2,"), + ]) + paths = [ + str(inbox / "cur" / "msg1:2,"), + str(inbox / "new" / "msg2:2,"), + ] + changed, skipped, errors = mfm.process_specific_files(paths, "F") + assert (changed, skipped, errors) == (2, 0, 0) + + def test_error_file_not_found(self, mfm, tmp_path, capsys): + inbox = _make_maildir(tmp_path) + ghost = str(inbox / "cur" / "ghost:2,") + changed, skipped, errors = mfm.process_specific_files([ghost], "F") + assert errors == 1 + assert "File not found" in capsys.readouterr().err + + def test_error_file_outside_cur_or_new(self, mfm, tmp_path, capsys): + # Path validation: only files whose parent dir is named "cur" or "new" + # are accepted. Defends against pointing at the wrong file. + bogus = tmp_path / "elsewhere" / "msg1:2," + bogus.parent.mkdir() + bogus.write_text("body") + changed, skipped, errors = mfm.process_specific_files([str(bogus)], "F") + assert errors == 1 + assert "Not in a maildir" in capsys.readouterr().err + # File untouched. + assert bogus.exists() + + def test_error_already_set_counted_as_skipped(self, mfm, tmp_path): + inbox = _make_maildir(tmp_path, [("cur", "msg1:2,F")]) + path = str(inbox / "cur" / "msg1:2,F") + changed, skipped, errors = mfm.process_specific_files([path], "F") + assert (changed, skipped, errors) == (0, 1, 0) + + +# --------------------------------------------------------------------------- +# reindex_mu — mocked subprocess +# --------------------------------------------------------------------------- + +class TestReindexMu: + + def test_normal_mu_present_returns_true(self, mfm, monkeypatch): + monkeypatch.setattr(mfm.shutil, "which", lambda _name: "/usr/bin/mu") + result_obj = MagicMock(returncode=0, stderr="") + monkeypatch.setattr(mfm.subprocess, "run", lambda *a, **kw: result_obj) + assert mfm.reindex_mu() is True + + def test_error_mu_not_in_path_returns_false(self, mfm, monkeypatch, capsys): + monkeypatch.setattr(mfm.shutil, "which", lambda _name: None) + assert mfm.reindex_mu() is False + assert "mu not found" in capsys.readouterr().err + + def test_error_mu_index_returns_nonzero(self, mfm, monkeypatch, capsys): + monkeypatch.setattr(mfm.shutil, "which", lambda _name: "/usr/bin/mu") + result_obj = MagicMock(returncode=1, stderr="db locked") + monkeypatch.setattr(mfm.subprocess, "run", lambda *a, **kw: result_obj) + assert mfm.reindex_mu() is False + assert "mu index failed" in capsys.readouterr().err + + def test_error_mu_index_times_out(self, mfm, monkeypatch, capsys): + monkeypatch.setattr(mfm.shutil, "which", lambda _name: "/usr/bin/mu") + + def raise_timeout(*_a, **_kw): + raise subprocess.TimeoutExpired(cmd="mu index", timeout=120) + + monkeypatch.setattr(mfm.subprocess, "run", raise_timeout) + assert mfm.reindex_mu() is False + assert "timed out" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# Argparse — black-box subprocess sanity check +# --------------------------------------------------------------------------- + +class TestArgparseShape: + + def test_normal_help_lists_subcommands(self): + result = subprocess.run( + [sys.executable, str(SCRIPT_PATH), "--help"], + capture_output=True, text=True, + ) + assert result.returncode == 0 + assert "mark-read" in result.stdout + assert "star" 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 diff --git a/.ai/scripts/tests/test_parse_received_headers.py b/.ai/scripts/tests/test_parse_received_headers.py new file mode 100644 index 0000000..e12e1fb --- /dev/null +++ b/.ai/scripts/tests/test_parse_received_headers.py @@ -0,0 +1,105 @@ +"""Tests for parse_received_headers().""" + +import email +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from conftest import make_plain_message, add_received_headers +from email.message import EmailMessage + +# Import the function under test +import importlib.util +spec = importlib.util.spec_from_file_location( + "eml_script", + os.path.join(os.path.dirname(__file__), '..', 'eml-view-and-extract-attachments.py') +) +eml_script = importlib.util.module_from_spec(spec) +spec.loader.exec_module(eml_script) + +parse_received_headers = eml_script.parse_received_headers + + +class TestSingleHeader: + def test_header_with_from_and_by(self): + msg = EmailMessage() + msg['Received'] = ( + 'from mail-sender.example.com by mx.receiver.example.com ' + 'with ESMTP; Thu, 05 Feb 2026 11:36:05 -0600' + ) + result = parse_received_headers(msg) + assert result['sent_server'] == 'mail-sender.example.com' + assert result['received_server'] == 'mx.receiver.example.com' + assert result['sent_time'] == 'Thu, 05 Feb 2026 11:36:05 -0600' + assert result['received_time'] == 'Thu, 05 Feb 2026 11:36:05 -0600' + + +class TestMultipleHeaders: + def test_uses_first_with_both_from_and_by(self): + msg = EmailMessage() + # Most recent first (by only) + msg['Received'] = 'by internal.example.com with SMTP; Thu, 05 Feb 2026 11:36:10 -0600' + # Next: has both from and by — this should be selected + msg['Received'] = ( + 'from mail-sender.example.com by mx.receiver.example.com ' + 'with ESMTP; Thu, 05 Feb 2026 11:36:05 -0600' + ) + # Oldest + msg['Received'] = ( + 'from originator.example.com by relay.example.com ' + 'with SMTP; Thu, 05 Feb 2026 11:35:58 -0600' + ) + result = parse_received_headers(msg) + assert result['sent_server'] == 'mail-sender.example.com' + assert result['received_server'] == 'mx.receiver.example.com' + + +class TestNoReceivedHeaders: + def test_all_values_none(self): + msg = EmailMessage() + result = parse_received_headers(msg) + assert result['sent_time'] is None + assert result['sent_server'] is None + assert result['received_time'] is None + assert result['received_server'] is None + + +class TestByButNoFrom: + def test_falls_back_to_first_header(self): + msg = EmailMessage() + msg['Received'] = 'by internal.example.com with SMTP; Thu, 05 Feb 2026 11:36:10 -0600' + result = parse_received_headers(msg) + assert result['received_server'] == 'internal.example.com' + assert result['received_time'] == 'Thu, 05 Feb 2026 11:36:10 -0600' + # No from in any header, so sent_server stays None + assert result['sent_server'] is None + + +class TestMultilineFoldedHeader: + def test_normalizes_whitespace(self): + # Use email.message_from_string to parse raw folded headers + # (EmailMessage policy rejects embedded CRLF in set values) + raw = ( + "From: test@example.com\r\n" + "Received: from mail-sender.example.com\r\n" + " by mx.receiver.example.com\r\n" + " with ESMTP; Thu, 05 Feb 2026 11:36:05 -0600\r\n" + "\r\n" + "body\r\n" + ) + msg = email.message_from_string(raw) + result = parse_received_headers(msg) + assert result['sent_server'] == 'mail-sender.example.com' + assert result['received_server'] == 'mx.receiver.example.com' + + +class TestMalformedTimestamp: + def test_no_semicolon(self): + msg = EmailMessage() + msg['Received'] = 'from sender.example.com by receiver.example.com with SMTP' + result = parse_received_headers(msg) + assert result['sent_server'] == 'sender.example.com' + assert result['received_server'] == 'receiver.example.com' + assert result['sent_time'] is None + assert result['received_time'] is None diff --git a/.ai/scripts/tests/test_process_eml.py b/.ai/scripts/tests/test_process_eml.py new file mode 100644 index 0000000..612cbb1 --- /dev/null +++ b/.ai/scripts/tests/test_process_eml.py @@ -0,0 +1,162 @@ +"""Integration tests for process_eml() — full pipeline with --output-dir.""" + +import os +import shutil +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +import importlib.util +spec = importlib.util.spec_from_file_location( + "eml_script", + os.path.join(os.path.dirname(__file__), '..', 'eml-view-and-extract-attachments.py') +) +eml_script = importlib.util.module_from_spec(spec) +spec.loader.exec_module(eml_script) + +process_eml = eml_script.process_eml + +import pytest + + +FIXTURES = os.path.join(os.path.dirname(__file__), 'fixtures') + + +class TestPlainTextPipeline: + def test_creates_eml_and_txt(self, tmp_path): + eml_src = os.path.join(FIXTURES, 'plain-text.eml') + # Copy fixture to tmp_path so temp dir can be created as sibling + working_eml = tmp_path / "inbox" / "message.eml" + working_eml.parent.mkdir() + shutil.copy2(eml_src, working_eml) + + output_dir = tmp_path / "output" + result = process_eml(str(working_eml), str(output_dir)) + + # Should have exactly 2 files: .eml and .txt + assert len(result['files']) == 2 + eml_file = result['files'][0] + txt_file = result['files'][1] + + assert eml_file['type'] == 'eml' + assert txt_file['type'] == 'txt' + assert eml_file['name'].endswith('.eml') + assert txt_file['name'].endswith('.txt') + + # Files exist in output dir + assert os.path.isfile(eml_file['path']) + assert os.path.isfile(txt_file['path']) + + # Filenames contain expected components + assert 'Jonathan' in eml_file['name'] + assert 'EMAIL' in eml_file['name'] + assert '2026-02-05' in eml_file['name'] + + # Temp dir cleaned up (no extract-* dirs in inbox) + inbox_contents = os.listdir(str(tmp_path / "inbox")) + assert not any(d.startswith('extract-') for d in inbox_contents) + + +class TestHtmlFallbackPipeline: + def test_txt_contains_converted_html(self, tmp_path): + eml_src = os.path.join(FIXTURES, 'html-only.eml') + working_eml = tmp_path / "inbox" / "message.eml" + working_eml.parent.mkdir() + shutil.copy2(eml_src, working_eml) + + output_dir = tmp_path / "output" + result = process_eml(str(working_eml), str(output_dir)) + + txt_file = result['files'][1] + with open(txt_file['path'], 'r') as f: + content = f.read() + + # Should be converted, not raw HTML + assert '<p>' not in content + assert '<strong>' not in content + assert 'HTML' in content + + +class TestAttachmentPipeline: + def test_eml_txt_and_attachment_created(self, tmp_path): + eml_src = os.path.join(FIXTURES, 'with-attachment.eml') + working_eml = tmp_path / "inbox" / "message.eml" + working_eml.parent.mkdir() + shutil.copy2(eml_src, working_eml) + + output_dir = tmp_path / "output" + result = process_eml(str(working_eml), str(output_dir)) + + assert len(result['files']) == 3 + types = [f['type'] for f in result['files']] + assert types == ['eml', 'txt', 'attach'] + + # Attachment is auto-renamed + attach_file = result['files'][2] + assert 'ATTACH' in attach_file['name'] + assert attach_file['name'].endswith('.pdf') + assert os.path.isfile(attach_file['path']) + + +class TestDuplicateAttachmentNames: + """Outlook inlines the same signature image multiple times under one + filename. Each part must be saved to its own file, not silently + overwritten in temp_dir (which leaves the move step pointing at a + missing file).""" + + def test_each_duplicate_attachment_kept_with_counter_suffix(self, tmp_path): + eml_src = os.path.join(FIXTURES, 'duplicate-attachment-names.eml') + working_eml = tmp_path / "inbox" / "message.eml" + working_eml.parent.mkdir() + shutil.copy2(eml_src, working_eml) + + output_dir = tmp_path / "output" + result = process_eml(str(working_eml), str(output_dir)) + + # eml + txt + 3 attachments + assert len(result['files']) == 5 + attach_files = [f for f in result['files'] if f['type'] == 'attach'] + assert len(attach_files) == 3 + + # Each file must have a unique name and exist on disk with its own + # bytes — overwriting earlier ones would leave fewer than 3 files + # and the move step would fail. + names = [f['name'] for f in attach_files] + assert len(set(names)) == 3 + for f in attach_files: + assert os.path.isfile(f['path']) + + # Bytes are preserved per part (fixture has -1, -2, -3 payloads) + contents = sorted(open(f['path'], 'rb').read() for f in attach_files) + assert contents == [b'image-content-1', b'image-content-2', b'image-content-3'] + + +class TestCollisionDetection: + def test_raises_on_existing_file(self, tmp_path): + eml_src = os.path.join(FIXTURES, 'plain-text.eml') + working_eml = tmp_path / "inbox" / "message.eml" + working_eml.parent.mkdir() + shutil.copy2(eml_src, working_eml) + + output_dir = tmp_path / "output" + # Run once to create files + result = process_eml(str(working_eml), str(output_dir)) + + # Run again — should raise FileExistsError + with pytest.raises(FileExistsError, match="Collision"): + process_eml(str(working_eml), str(output_dir)) + + +class TestMissingOutputDir: + def test_creates_directory(self, tmp_path): + eml_src = os.path.join(FIXTURES, 'plain-text.eml') + working_eml = tmp_path / "inbox" / "message.eml" + working_eml.parent.mkdir() + shutil.copy2(eml_src, working_eml) + + output_dir = tmp_path / "new" / "nested" / "output" + assert not output_dir.exists() + + result = process_eml(str(working_eml), str(output_dir)) + assert output_dir.exists() + assert len(result['files']) == 2 diff --git a/.ai/scripts/tests/test_save_attachments.py b/.ai/scripts/tests/test_save_attachments.py new file mode 100644 index 0000000..32f02a6 --- /dev/null +++ b/.ai/scripts/tests/test_save_attachments.py @@ -0,0 +1,97 @@ +"""Tests for save_attachments().""" + +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from conftest import make_plain_message, make_message_with_attachment +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.mime.application import MIMEApplication + +import importlib.util +spec = importlib.util.spec_from_file_location( + "eml_script", + os.path.join(os.path.dirname(__file__), '..', 'eml-view-and-extract-attachments.py') +) +eml_script = importlib.util.module_from_spec(spec) +spec.loader.exec_module(eml_script) + +save_attachments = eml_script.save_attachments + + +class TestSingleAttachment: + def test_file_written_and_returned(self, tmp_path): + msg = make_message_with_attachment( + attachment_filename="report.pdf", + attachment_content=b"pdf bytes here" + ) + result = save_attachments(msg, str(tmp_path), "2026-02-05-1136-Jonathan") + + assert len(result) == 1 + assert result[0]['original_name'] == "report.pdf" + assert "ATTACH" in result[0]['renamed_name'] + assert result[0]['renamed_name'].endswith(".pdf") + + # File actually exists and has correct content + written_path = result[0]['path'] + assert os.path.isfile(written_path) + with open(written_path, 'rb') as f: + assert f.read() == b"pdf bytes here" + + +class TestMultipleAttachments: + def test_all_written_and_returned(self, tmp_path): + msg = MIMEMultipart() + msg['From'] = 'test@example.com' + msg['Date'] = 'Thu, 05 Feb 2026 11:36:00 -0600' + msg.attach(MIMEText("body", 'plain')) + + for name, content in [("doc1.pdf", b"pdf1"), ("image.png", b"png1")]: + att = MIMEApplication(content, Name=name) + att['Content-Disposition'] = f'attachment; filename="{name}"' + msg.attach(att) + + result = save_attachments(msg, str(tmp_path), "2026-02-05-1136-Jonathan") + + assert len(result) == 2 + for r in result: + assert os.path.isfile(r['path']) + + +class TestNoAttachments: + def test_empty_list(self, tmp_path): + msg = make_plain_message() + result = save_attachments(msg, str(tmp_path), "2026-02-05-1136-Jonathan") + assert result == [] + + +class TestFilenameWithSpaces: + def test_cleaned_filename(self, tmp_path): + msg = make_message_with_attachment( + attachment_filename="My Document (1).pdf", + attachment_content=b"data" + ) + result = save_attachments(msg, str(tmp_path), "2026-02-05-1136-Jonathan") + + assert len(result) == 1 + assert " " not in result[0]['renamed_name'] + assert os.path.isfile(result[0]['path']) + + +class TestNoContentDisposition: + def test_skipped(self, tmp_path): + msg = MIMEMultipart() + msg['From'] = 'test@example.com' + msg.attach(MIMEText("body", 'plain')) + + # Add a part without Content-Disposition + part = MIMEApplication(b"data", Name="file.bin") + # Explicitly remove Content-Disposition if present + if 'Content-Disposition' in part: + del part['Content-Disposition'] + msg.attach(part) + + result = save_attachments(msg, str(tmp_path), "2026-02-05-1136-Jonathan") + assert result == [] |
