f8c5dd539d
When enabled, IMAP processing will fetch and process attachments but will NOT modify the mailbox state (no starring, labeling, deleting, or flag changes). This allows preprod instances to safely share a Gmail inbox with production without interfering with production email processing. Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
1357 lines
48 KiB
Python
1357 lines
48 KiB
Python
"""Tests for app/tasks/imap_tasks.py module."""
|
|
|
|
import os
|
|
from datetime import datetime, timedelta, timezone
|
|
from email.message import EmailMessage
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from app.tasks.imap_tasks import (
|
|
acquire_lock,
|
|
check_and_pull_mailbox,
|
|
cleanup_old_entries,
|
|
email_already_has_label,
|
|
fetch_attachments_and_enqueue,
|
|
find_all_mail_folder,
|
|
find_all_mail_xlist,
|
|
get_capabilities,
|
|
load_processed_emails,
|
|
mark_as_processed_with_label,
|
|
mark_as_processed_with_star,
|
|
pull_all_inboxes,
|
|
pull_inbox,
|
|
release_lock,
|
|
save_processed_emails,
|
|
)
|
|
|
|
_TEST_CREDENTIAL = "pass" # noqa: S105
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestCleanupOldEntries:
|
|
"""Tests for cleanup_old_entries function."""
|
|
|
|
def test_removes_old_entries(self):
|
|
"""Test that entries older than 7 days are removed."""
|
|
old_date = (datetime.now(timezone.utc) - timedelta(days=10)).strftime("%Y-%m-%dT%H:%M:%S")
|
|
recent_date = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
|
|
|
processed = {"old-msg": old_date, "recent-msg": recent_date}
|
|
result = cleanup_old_entries(processed)
|
|
assert "old-msg" not in result
|
|
assert "recent-msg" in result
|
|
|
|
def test_keeps_recent_entries(self):
|
|
"""Test that entries within 7 days are kept."""
|
|
recent_date = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
|
processed = {"msg-1": recent_date, "msg-2": recent_date}
|
|
result = cleanup_old_entries(processed)
|
|
assert len(result) == 2
|
|
|
|
def test_empty_dict(self):
|
|
"""Test with empty dictionary."""
|
|
result = cleanup_old_entries({})
|
|
assert result == {}
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestLoadSaveProcessedEmails:
|
|
"""Tests for load_processed_emails and save_processed_emails."""
|
|
|
|
@patch("app.tasks.imap_tasks.CACHE_FILE", "/tmp/test_processed_mails.json")
|
|
def test_load_returns_empty_when_no_file(self):
|
|
"""Test load returns empty dict when file doesn't exist."""
|
|
if os.path.exists("/tmp/test_processed_mails.json"):
|
|
os.remove("/tmp/test_processed_mails.json")
|
|
result = load_processed_emails()
|
|
assert result == {}
|
|
|
|
@patch("app.tasks.imap_tasks.CACHE_FILE", "/tmp/test_processed_mails.json")
|
|
def test_save_and_load_roundtrip(self):
|
|
"""Test saving and loading processed emails."""
|
|
recent_date = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
|
emails = {"msg-1": recent_date}
|
|
save_processed_emails(emails)
|
|
result = load_processed_emails()
|
|
assert "msg-1" in result
|
|
|
|
# Clean up
|
|
os.remove("/tmp/test_processed_mails.json")
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestCheckAndPullMailbox:
|
|
"""Tests for check_and_pull_mailbox function."""
|
|
|
|
@patch("app.tasks.imap_tasks.pull_inbox")
|
|
def test_skips_when_no_host(self, mock_pull):
|
|
"""Test that it skips when host is not configured."""
|
|
check_and_pull_mailbox(
|
|
mailbox_key="imap1",
|
|
host=None,
|
|
port=993,
|
|
username="user",
|
|
password=_TEST_CREDENTIAL,
|
|
use_ssl=True,
|
|
delete_after_process=False,
|
|
)
|
|
mock_pull.assert_not_called()
|
|
|
|
@patch("app.tasks.imap_tasks.pull_inbox")
|
|
def test_skips_when_no_password(self, mock_pull):
|
|
"""Test that it skips when password is not configured."""
|
|
check_and_pull_mailbox(
|
|
mailbox_key="imap1",
|
|
host="imap.example.com",
|
|
port=993,
|
|
username="user",
|
|
password=None,
|
|
use_ssl=True,
|
|
delete_after_process=False,
|
|
)
|
|
mock_pull.assert_not_called()
|
|
|
|
@patch("app.tasks.imap_tasks.pull_inbox")
|
|
def test_calls_pull_inbox_when_configured(self, mock_pull):
|
|
"""Test that it calls pull_inbox when properly configured."""
|
|
check_and_pull_mailbox(
|
|
mailbox_key="imap1",
|
|
host="imap.example.com",
|
|
port=993,
|
|
username="user",
|
|
password=_TEST_CREDENTIAL,
|
|
use_ssl=True,
|
|
delete_after_process=False,
|
|
)
|
|
mock_pull.assert_called_once()
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestFetchAttachmentsAndEnqueue:
|
|
"""Tests for fetch_attachments_and_enqueue function."""
|
|
|
|
@patch("app.tasks.imap_tasks.process_document")
|
|
@patch("app.tasks.imap_tasks.convert_to_pdf")
|
|
def test_skips_non_allowed_mime_types(self, mock_convert, mock_process):
|
|
"""Test that non-allowed MIME types are skipped."""
|
|
msg = EmailMessage()
|
|
msg["Subject"] = "Test"
|
|
msg.add_attachment(b"data", maintype="application", subtype="octet-stream", filename="test.exe")
|
|
|
|
result = fetch_attachments_and_enqueue(msg)
|
|
assert result is False
|
|
mock_process.delay.assert_not_called()
|
|
mock_convert.delay.assert_not_called()
|
|
|
|
@patch("app.tasks.imap_tasks.process_document")
|
|
@patch("app.tasks.imap_tasks.convert_to_pdf")
|
|
def test_processes_pdf_attachment(self, mock_convert, mock_process, tmp_path):
|
|
"""Test that PDF attachments are processed directly."""
|
|
msg = EmailMessage()
|
|
msg["Subject"] = "Test PDF"
|
|
msg.add_attachment(b"%PDF-1.4", maintype="application", subtype="pdf", filename="test.pdf")
|
|
|
|
with patch("app.tasks.imap_tasks.settings") as mock_settings:
|
|
mock_settings.workdir = str(tmp_path)
|
|
result = fetch_attachments_and_enqueue(msg)
|
|
|
|
assert result is True
|
|
mock_process.delay.assert_called_once()
|
|
|
|
@patch("app.tasks.imap_tasks.process_document")
|
|
@patch("app.tasks.imap_tasks.convert_to_pdf")
|
|
def test_converts_docx_attachment(self, mock_convert, mock_process, tmp_path):
|
|
"""Test that DOCX attachments are sent for conversion."""
|
|
msg = EmailMessage()
|
|
msg["Subject"] = "Test DOCX"
|
|
msg.add_attachment(
|
|
b"docx content",
|
|
maintype="application",
|
|
subtype="vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
filename="test.docx",
|
|
)
|
|
|
|
with patch("app.tasks.imap_tasks.settings") as mock_settings:
|
|
mock_settings.workdir = str(tmp_path)
|
|
result = fetch_attachments_and_enqueue(msg)
|
|
|
|
assert result is True
|
|
mock_convert.delay.assert_called_once()
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestEmailAlreadyHasLabel:
|
|
"""Tests for email_already_has_label function."""
|
|
|
|
def test_returns_true_when_label_found(self):
|
|
"""Test returns True when label is found."""
|
|
mock_mail = MagicMock()
|
|
mock_mail.fetch.return_value = ("OK", [(None, b'"Ingested" "INBOX"')])
|
|
|
|
result = email_already_has_label(mock_mail, b"1", "Ingested")
|
|
assert result is True
|
|
|
|
def test_returns_false_when_label_not_found(self):
|
|
"""Test returns False when label is not found."""
|
|
mock_mail = MagicMock()
|
|
mock_mail.fetch.return_value = ("OK", [(None, b'"INBOX" "Sent"')])
|
|
|
|
result = email_already_has_label(mock_mail, b"1", "Ingested")
|
|
assert result is False
|
|
|
|
def test_handles_exception(self):
|
|
"""Test handles exception gracefully."""
|
|
mock_mail = MagicMock()
|
|
mock_mail.fetch.side_effect = Exception("IMAP error")
|
|
|
|
result = email_already_has_label(mock_mail, b"1", "Ingested")
|
|
assert result is False
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestMarkAsProcessed:
|
|
"""Tests for mark_as_processed functions."""
|
|
|
|
def test_mark_with_star(self):
|
|
"""Test marking email with star."""
|
|
mock_mail = MagicMock()
|
|
mark_as_processed_with_star(mock_mail, b"1")
|
|
mock_mail.store.assert_called_once_with(b"1", "+FLAGS", "\\Flagged")
|
|
|
|
def test_mark_with_label(self):
|
|
"""Test marking email with label."""
|
|
mock_mail = MagicMock()
|
|
mark_as_processed_with_label(mock_mail, b"1", "Ingested")
|
|
mock_mail.store.assert_called_once_with(b"1", "+X-GM-LABELS", "Ingested")
|
|
|
|
def test_mark_with_star_handles_exception(self):
|
|
"""Test mark_as_processed_with_star handles exception."""
|
|
mock_mail = MagicMock()
|
|
mock_mail.store.side_effect = Exception("IMAP error")
|
|
# Should not raise
|
|
mark_as_processed_with_star(mock_mail, b"1")
|
|
|
|
def test_mark_with_label_handles_exception(self):
|
|
"""Test mark_as_processed_with_label handles exception."""
|
|
mock_mail = MagicMock()
|
|
mock_mail.store.side_effect = Exception("IMAP error")
|
|
# Should not raise
|
|
mark_as_processed_with_label(mock_mail, b"1", "Ingested")
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestGetCapabilities:
|
|
"""Tests for get_capabilities function."""
|
|
|
|
def test_returns_capabilities(self):
|
|
"""Test returns list of capabilities."""
|
|
mock_mail = MagicMock()
|
|
mock_mail.capability.return_value = ("OK", [b"IMAP4REV1 IDLE XLIST"])
|
|
|
|
result = get_capabilities(mock_mail)
|
|
assert "IMAP4REV1" in result
|
|
assert "XLIST" in result
|
|
|
|
def test_returns_empty_on_failure(self):
|
|
"""Test returns empty list on failure."""
|
|
mock_mail = MagicMock()
|
|
mock_mail.capability.return_value = ("NO", None)
|
|
|
|
result = get_capabilities(mock_mail)
|
|
assert result == []
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestFindAllMailFolder:
|
|
"""Tests for find_all_mail_folder function."""
|
|
|
|
def test_finds_english_all_mail(self):
|
|
"""Test finding English All Mail folder."""
|
|
mock_mail = MagicMock()
|
|
# First attempt fails, second succeeds
|
|
mock_mail.select.side_effect = [
|
|
("NO", None), # German
|
|
("OK", None), # English
|
|
]
|
|
|
|
result = find_all_mail_folder(mock_mail)
|
|
assert result == "[Gmail]/All Mail"
|
|
|
|
def test_returns_none_when_not_found(self):
|
|
"""Test returns None when All Mail folder is not found."""
|
|
mock_mail = MagicMock()
|
|
mock_mail.select.return_value = ("NO", None)
|
|
mock_mail.capability.return_value = ("OK", [b"IMAP4REV1"])
|
|
|
|
result = find_all_mail_folder(mock_mail)
|
|
assert result is None
|
|
|
|
@patch("app.tasks.imap_tasks.find_all_mail_xlist")
|
|
@patch("app.tasks.imap_tasks.get_capabilities")
|
|
def test_uses_xlist_when_available(self, mock_get_caps, mock_xlist):
|
|
"""Test that it uses XLIST when available and common names fail."""
|
|
mock_mail = MagicMock()
|
|
mock_mail.select.return_value = ("NO", None) # All common names fail
|
|
mock_get_caps.return_value = ["XLIST", "IMAP4REV1"]
|
|
mock_xlist.return_value = "[Gmail]/All Mail"
|
|
|
|
result = find_all_mail_folder(mock_mail)
|
|
assert result == "[Gmail]/All Mail"
|
|
mock_xlist.assert_called_once_with(mock_mail)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestFindAllMailXlist:
|
|
"""Tests for find_all_mail_xlist function."""
|
|
|
|
def test_finds_all_mail_via_xlist(self):
|
|
"""Test finding All Mail folder via XLIST."""
|
|
mock_mail = MagicMock()
|
|
mock_mail._new_tag.return_value = b"A001"
|
|
|
|
# Mock the readline responses
|
|
responses = [
|
|
b'* XLIST (\\HasNoChildren \\AllMail) "/" "[Gmail]/All Mail"\r\n',
|
|
b"A001 OK XLIST completed\r\n",
|
|
]
|
|
mock_mail.readline.side_effect = responses
|
|
|
|
result = find_all_mail_xlist(mock_mail)
|
|
assert result == "[Gmail]/All Mail"
|
|
|
|
def test_returns_none_when_no_allmail_flag(self):
|
|
"""Test returns None when XLIST doesn't have AllMail flag."""
|
|
mock_mail = MagicMock()
|
|
mock_mail._new_tag.return_value = b"A001"
|
|
|
|
# Mock responses without AllMail flag
|
|
responses = [
|
|
b'* XLIST (\\HasNoChildren) "/" "INBOX"\r\n',
|
|
b"A001 OK XLIST completed\r\n",
|
|
]
|
|
mock_mail.readline.side_effect = responses
|
|
|
|
result = find_all_mail_xlist(mock_mail)
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestLockingMechanism:
|
|
"""Tests for Redis-based locking functions."""
|
|
|
|
@patch("app.tasks.imap_tasks.redis_client")
|
|
def test_acquire_lock_success(self, mock_redis):
|
|
"""Test successfully acquiring the lock."""
|
|
mock_redis.setnx.return_value = True
|
|
|
|
result = acquire_lock()
|
|
assert result is True
|
|
mock_redis.setnx.assert_called_once_with("imap_lock", "locked")
|
|
mock_redis.expire.assert_called_once_with("imap_lock", 300)
|
|
|
|
@patch("app.tasks.imap_tasks.redis_client")
|
|
def test_acquire_lock_failure(self, mock_redis):
|
|
"""Test failing to acquire the lock when already held."""
|
|
mock_redis.setnx.return_value = False
|
|
|
|
result = acquire_lock()
|
|
assert result is False
|
|
mock_redis.expire.assert_not_called()
|
|
|
|
@patch("app.tasks.imap_tasks.redis_client")
|
|
def test_release_lock(self, mock_redis):
|
|
"""Test releasing the lock."""
|
|
release_lock()
|
|
mock_redis.delete.assert_called_once_with("imap_lock")
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestPullAllInboxes:
|
|
"""Tests for pull_all_inboxes task."""
|
|
|
|
@patch("app.tasks.imap_tasks.check_and_pull_mailbox")
|
|
@patch("app.tasks.imap_tasks.release_lock")
|
|
@patch("app.tasks.imap_tasks.acquire_lock")
|
|
@patch("app.tasks.imap_tasks.settings")
|
|
def test_pulls_both_mailboxes(self, mock_settings, mock_acquire, mock_release, mock_check):
|
|
"""Test that both mailboxes are checked when lock is acquired."""
|
|
mock_acquire.return_value = True
|
|
mock_settings.imap1_host = "imap1.example.com"
|
|
mock_settings.imap1_port = 993
|
|
mock_settings.imap1_username = "user1"
|
|
mock_settings.imap1_password = _TEST_CREDENTIAL
|
|
mock_settings.imap1_ssl = True
|
|
mock_settings.imap1_delete_after_process = False
|
|
|
|
mock_settings.imap2_host = "imap.gmail.com"
|
|
mock_settings.imap2_port = 993
|
|
mock_settings.imap2_username = "user2@gmail.com"
|
|
mock_settings.imap2_password = _TEST_CREDENTIAL
|
|
mock_settings.imap2_ssl = True
|
|
mock_settings.imap2_delete_after_process = False
|
|
|
|
pull_all_inboxes()
|
|
|
|
assert mock_check.call_count == 2
|
|
mock_release.assert_called_once()
|
|
|
|
@patch("app.tasks.imap_tasks.acquire_lock")
|
|
def test_skips_when_lock_held(self, mock_acquire):
|
|
"""Test that execution is skipped when lock cannot be acquired."""
|
|
mock_acquire.return_value = False
|
|
|
|
pull_all_inboxes()
|
|
|
|
mock_acquire.assert_called_once()
|
|
|
|
@patch("app.tasks.imap_tasks.check_and_pull_mailbox")
|
|
@patch("app.tasks.imap_tasks.release_lock")
|
|
@patch("app.tasks.imap_tasks.acquire_lock")
|
|
def test_releases_lock_on_exception(self, mock_acquire, mock_release, mock_check):
|
|
"""Test that lock is released even when exception occurs."""
|
|
mock_acquire.return_value = True
|
|
mock_check.side_effect = Exception("Test error")
|
|
|
|
with pytest.raises(Exception):
|
|
pull_all_inboxes()
|
|
|
|
mock_release.assert_called_once()
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestPullInbox:
|
|
"""Tests for pull_inbox function."""
|
|
|
|
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
|
@patch("app.tasks.imap_tasks.load_processed_emails")
|
|
@patch("app.tasks.imap_tasks.save_processed_emails")
|
|
def test_non_gmail_inbox_fetch(self, mock_save, mock_load, mock_imap_class):
|
|
"""Test fetching from a non-Gmail inbox."""
|
|
mock_load.return_value = {}
|
|
mock_mail = MagicMock()
|
|
mock_imap_class.return_value = mock_mail
|
|
|
|
# Mock successful login and folder selection
|
|
mock_mail.login.return_value = ("OK", [])
|
|
mock_mail.select.return_value = ("OK", [])
|
|
mock_mail.search.return_value = ("OK", [b""]) # No messages
|
|
|
|
pull_inbox(
|
|
mailbox_key="imap1",
|
|
host="imap.example.com",
|
|
port=993,
|
|
username="user",
|
|
password=_TEST_CREDENTIAL,
|
|
use_ssl=True,
|
|
delete_after_process=False,
|
|
)
|
|
|
|
mock_mail.login.assert_called_once_with("user", _TEST_CREDENTIAL)
|
|
mock_mail.select.assert_called_once_with("INBOX")
|
|
mock_mail.close.assert_called_once()
|
|
mock_mail.logout.assert_called_once()
|
|
|
|
@patch("app.tasks.imap_tasks.imaplib.IMAP4")
|
|
@patch("app.tasks.imap_tasks.load_processed_emails")
|
|
def test_non_ssl_connection(self, mock_load, mock_imap_class):
|
|
"""Test connecting without SSL."""
|
|
mock_load.return_value = {}
|
|
mock_mail = MagicMock()
|
|
mock_imap_class.return_value = mock_mail
|
|
|
|
mock_mail.login.return_value = ("OK", [])
|
|
mock_mail.select.return_value = ("OK", [])
|
|
mock_mail.search.return_value = ("OK", [b""])
|
|
|
|
pull_inbox(
|
|
mailbox_key="imap1",
|
|
host="imap.example.com",
|
|
port=143,
|
|
username="user",
|
|
password=_TEST_CREDENTIAL,
|
|
use_ssl=False,
|
|
delete_after_process=False,
|
|
)
|
|
|
|
mock_imap_class.assert_called_once_with("imap.example.com", 143)
|
|
|
|
@patch("app.tasks.imap_tasks.find_all_mail_folder")
|
|
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
|
@patch("app.tasks.imap_tasks.load_processed_emails")
|
|
def test_gmail_uses_all_mail_folder(self, mock_load, mock_imap_class, mock_find_all):
|
|
"""Test that Gmail uses All Mail folder when found."""
|
|
mock_load.return_value = {}
|
|
mock_mail = MagicMock()
|
|
mock_imap_class.return_value = mock_mail
|
|
mock_find_all.return_value = "[Gmail]/All Mail"
|
|
|
|
mock_mail.login.return_value = ("OK", [])
|
|
mock_mail.select.return_value = ("OK", [])
|
|
mock_mail.search.return_value = ("OK", [b""])
|
|
|
|
pull_inbox(
|
|
mailbox_key="imap2",
|
|
host="imap.gmail.com",
|
|
port=993,
|
|
username="user@gmail.com",
|
|
password=_TEST_CREDENTIAL,
|
|
use_ssl=True,
|
|
delete_after_process=False,
|
|
)
|
|
|
|
mock_find_all.assert_called_once_with(mock_mail)
|
|
mock_mail.select.assert_called_once_with('"[Gmail]/All Mail"')
|
|
|
|
@patch("app.tasks.imap_tasks.find_all_mail_folder")
|
|
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
|
@patch("app.tasks.imap_tasks.load_processed_emails")
|
|
def test_gmail_fallback_to_inbox(self, mock_load, mock_imap_class, mock_find_all):
|
|
"""Test that Gmail falls back to INBOX when All Mail not found."""
|
|
mock_load.return_value = {}
|
|
mock_mail = MagicMock()
|
|
mock_imap_class.return_value = mock_mail
|
|
mock_find_all.return_value = None # All Mail not found
|
|
|
|
mock_mail.login.return_value = ("OK", [])
|
|
mock_mail.select.return_value = ("OK", [])
|
|
mock_mail.search.return_value = ("OK", [b""])
|
|
|
|
pull_inbox(
|
|
mailbox_key="imap2",
|
|
host="imap.gmail.com",
|
|
port=993,
|
|
username="user@gmail.com",
|
|
password=_TEST_CREDENTIAL,
|
|
use_ssl=True,
|
|
delete_after_process=False,
|
|
)
|
|
|
|
# Should select INBOX as fallback
|
|
assert any(call_args[0][0] == "INBOX" for call_args in mock_mail.select.call_args_list)
|
|
|
|
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
|
@patch("app.tasks.imap_tasks.load_processed_emails")
|
|
def test_search_failure_handling(self, mock_load, mock_imap_class):
|
|
"""Test handling of search failure."""
|
|
mock_load.return_value = {}
|
|
mock_mail = MagicMock()
|
|
mock_imap_class.return_value = mock_mail
|
|
|
|
mock_mail.login.return_value = ("OK", [])
|
|
mock_mail.select.return_value = ("OK", [])
|
|
mock_mail.search.return_value = ("NO", []) # Search failed
|
|
|
|
pull_inbox(
|
|
mailbox_key="imap1",
|
|
host="imap.example.com",
|
|
port=993,
|
|
username="user",
|
|
password=_TEST_CREDENTIAL,
|
|
use_ssl=True,
|
|
delete_after_process=False,
|
|
)
|
|
|
|
# Should close and logout despite search failure
|
|
mock_mail.close.assert_called_once()
|
|
mock_mail.logout.assert_called_once()
|
|
|
|
@patch("app.tasks.imap_tasks.fetch_attachments_and_enqueue")
|
|
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
|
@patch("app.tasks.imap_tasks.load_processed_emails")
|
|
@patch("app.tasks.imap_tasks.save_processed_emails")
|
|
@patch("app.tasks.imap_tasks.settings")
|
|
def test_processes_messages_and_marks_as_read(
|
|
self, mock_settings, mock_save, mock_load, mock_imap_class, mock_fetch
|
|
):
|
|
"""Test processing messages and marking them as read."""
|
|
mock_settings.workdir = "/tmp"
|
|
mock_settings.imap_readonly_mode = False
|
|
mock_load.return_value = {}
|
|
mock_mail = MagicMock()
|
|
mock_imap_class.return_value = mock_mail
|
|
|
|
# Create a simple email message
|
|
import email
|
|
|
|
msg = email.message.EmailMessage()
|
|
msg["Message-ID"] = "<test@example.com>"
|
|
msg["Subject"] = "Test"
|
|
raw_email = msg.as_bytes()
|
|
|
|
mock_mail.login.return_value = ("OK", [])
|
|
mock_mail.select.return_value = ("OK", [])
|
|
mock_mail.search.return_value = ("OK", [b"1"])
|
|
mock_mail.fetch.return_value = ("OK", [[None, raw_email]])
|
|
|
|
pull_inbox(
|
|
mailbox_key="imap1",
|
|
host="imap.example.com",
|
|
port=993,
|
|
username="user",
|
|
password=_TEST_CREDENTIAL,
|
|
use_ssl=True,
|
|
delete_after_process=False,
|
|
)
|
|
|
|
# Should mark as unread (remove Seen flag)
|
|
mock_mail.store.assert_called_with(b"1", "-FLAGS", "\\Seen")
|
|
mock_save.assert_called()
|
|
|
|
@patch("app.tasks.imap_tasks.fetch_attachments_and_enqueue")
|
|
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
|
@patch("app.tasks.imap_tasks.load_processed_emails")
|
|
@patch("app.tasks.imap_tasks.save_processed_emails")
|
|
@patch("app.tasks.imap_tasks.settings")
|
|
def test_delete_after_process(self, mock_settings, mock_save, mock_load, mock_imap_class, mock_fetch):
|
|
"""Test deleting messages after processing."""
|
|
mock_settings.workdir = "/tmp"
|
|
mock_settings.imap_readonly_mode = False
|
|
mock_load.return_value = {}
|
|
mock_mail = MagicMock()
|
|
mock_imap_class.return_value = mock_mail
|
|
|
|
import email
|
|
|
|
msg = email.message.EmailMessage()
|
|
msg["Message-ID"] = "<test@example.com>"
|
|
raw_email = msg.as_bytes()
|
|
|
|
mock_mail.login.return_value = ("OK", [])
|
|
mock_mail.select.return_value = ("OK", [])
|
|
mock_mail.search.return_value = ("OK", [b"1"])
|
|
mock_mail.fetch.return_value = ("OK", [[None, raw_email]])
|
|
|
|
pull_inbox(
|
|
mailbox_key="imap1",
|
|
host="imap.example.com",
|
|
port=993,
|
|
username="user",
|
|
password=_TEST_CREDENTIAL,
|
|
use_ssl=True,
|
|
delete_after_process=True,
|
|
)
|
|
|
|
# Should mark for deletion and expunge
|
|
mock_mail.store.assert_called_with(b"1", "+FLAGS", "\\Deleted")
|
|
mock_mail.expunge.assert_called_once()
|
|
|
|
@patch("app.tasks.imap_tasks.email_already_has_label")
|
|
@patch("app.tasks.imap_tasks.mark_as_processed_with_label")
|
|
@patch("app.tasks.imap_tasks.mark_as_processed_with_star")
|
|
@patch("app.tasks.imap_tasks.fetch_attachments_and_enqueue")
|
|
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
|
@patch("app.tasks.imap_tasks.load_processed_emails")
|
|
@patch("app.tasks.imap_tasks.save_processed_emails")
|
|
@patch("app.tasks.imap_tasks.settings")
|
|
def test_gmail_labels_and_star(
|
|
self,
|
|
mock_settings,
|
|
mock_save,
|
|
mock_load,
|
|
mock_imap_class,
|
|
mock_fetch,
|
|
mock_star,
|
|
mock_label,
|
|
mock_has_label,
|
|
):
|
|
"""Test that Gmail messages are starred and labeled."""
|
|
mock_settings.workdir = "/tmp"
|
|
mock_settings.imap_readonly_mode = False
|
|
mock_load.return_value = {}
|
|
mock_has_label.return_value = False
|
|
mock_mail = MagicMock()
|
|
mock_imap_class.return_value = mock_mail
|
|
|
|
import email
|
|
|
|
msg = email.message.EmailMessage()
|
|
msg["Message-ID"] = "<test@gmail.com>"
|
|
raw_email = msg.as_bytes()
|
|
|
|
mock_mail.login.return_value = ("OK", [])
|
|
mock_mail.select.return_value = ("OK", [])
|
|
mock_mail.search.return_value = ("OK", [b"1"])
|
|
mock_mail.fetch.return_value = ("OK", [[None, raw_email]])
|
|
|
|
pull_inbox(
|
|
mailbox_key="imap2",
|
|
host="imap.gmail.com",
|
|
port=993,
|
|
username="user@gmail.com",
|
|
password=_TEST_CREDENTIAL,
|
|
use_ssl=True,
|
|
delete_after_process=False,
|
|
)
|
|
|
|
mock_star.assert_called_once_with(mock_mail, b"1")
|
|
mock_label.assert_called_once_with(mock_mail, b"1", label="Ingested")
|
|
|
|
@patch("app.tasks.imap_tasks.email_already_has_label")
|
|
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
|
@patch("app.tasks.imap_tasks.load_processed_emails")
|
|
@patch("app.tasks.imap_tasks.settings")
|
|
def test_skips_already_labeled_gmail_messages(self, mock_settings, mock_load, mock_imap_class, mock_has_label):
|
|
"""Test that already labeled Gmail messages are skipped."""
|
|
mock_settings.workdir = "/tmp"
|
|
mock_load.return_value = {}
|
|
mock_has_label.return_value = True # Already labeled
|
|
mock_mail = MagicMock()
|
|
mock_imap_class.return_value = mock_mail
|
|
|
|
import email
|
|
|
|
msg = email.message.EmailMessage()
|
|
msg["Message-ID"] = "<test@gmail.com>"
|
|
raw_email = msg.as_bytes()
|
|
|
|
mock_mail.login.return_value = ("OK", [])
|
|
mock_mail.select.return_value = ("OK", [])
|
|
mock_mail.search.return_value = ("OK", [b"1"])
|
|
mock_mail.fetch.return_value = ("OK", [[None, raw_email]])
|
|
|
|
pull_inbox(
|
|
mailbox_key="imap2",
|
|
host="imap.gmail.com",
|
|
port=993,
|
|
username="user@gmail.com",
|
|
password=_TEST_CREDENTIAL,
|
|
use_ssl=True,
|
|
delete_after_process=False,
|
|
)
|
|
|
|
# Message should be skipped, so no store operations
|
|
mock_mail.store.assert_not_called()
|
|
|
|
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
|
@patch("app.tasks.imap_tasks.load_processed_emails")
|
|
def test_skips_message_without_message_id(self, mock_load, mock_imap_class):
|
|
"""Test that messages without Message-ID are skipped."""
|
|
mock_load.return_value = {}
|
|
mock_mail = MagicMock()
|
|
mock_imap_class.return_value = mock_mail
|
|
|
|
import email
|
|
|
|
msg = email.message.EmailMessage()
|
|
# No Message-ID
|
|
msg["Subject"] = "Test"
|
|
raw_email = msg.as_bytes()
|
|
|
|
mock_mail.login.return_value = ("OK", [])
|
|
mock_mail.select.return_value = ("OK", [])
|
|
mock_mail.search.return_value = ("OK", [b"1"])
|
|
mock_mail.fetch.return_value = ("OK", [[None, raw_email]])
|
|
|
|
pull_inbox(
|
|
mailbox_key="imap1",
|
|
host="imap.example.com",
|
|
port=993,
|
|
username="user",
|
|
password=_TEST_CREDENTIAL,
|
|
use_ssl=True,
|
|
delete_after_process=False,
|
|
)
|
|
|
|
# Should not process the message
|
|
mock_mail.store.assert_not_called()
|
|
|
|
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
|
@patch("app.tasks.imap_tasks.load_processed_emails")
|
|
def test_skips_already_processed_messages(self, mock_load, mock_imap_class):
|
|
"""Test that already processed messages are skipped."""
|
|
mock_load.return_value = {"<test@example.com>": "2024-01-01T00:00:00"}
|
|
mock_mail = MagicMock()
|
|
mock_imap_class.return_value = mock_mail
|
|
|
|
import email
|
|
|
|
msg = email.message.EmailMessage()
|
|
msg["Message-ID"] = "<test@example.com>"
|
|
raw_email = msg.as_bytes()
|
|
|
|
mock_mail.login.return_value = ("OK", [])
|
|
mock_mail.select.return_value = ("OK", [])
|
|
mock_mail.search.return_value = ("OK", [b"1"])
|
|
mock_mail.fetch.return_value = ("OK", [[None, raw_email]])
|
|
|
|
pull_inbox(
|
|
mailbox_key="imap1",
|
|
host="imap.example.com",
|
|
port=993,
|
|
username="user",
|
|
password=_TEST_CREDENTIAL,
|
|
use_ssl=True,
|
|
delete_after_process=False,
|
|
)
|
|
|
|
# Should not process the message
|
|
mock_mail.store.assert_not_called()
|
|
|
|
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
|
@patch("app.tasks.imap_tasks.load_processed_emails")
|
|
def test_handles_fetch_failure(self, mock_load, mock_imap_class):
|
|
"""Test handling of message fetch failure."""
|
|
mock_load.return_value = {}
|
|
mock_mail = MagicMock()
|
|
mock_imap_class.return_value = mock_mail
|
|
|
|
mock_mail.login.return_value = ("OK", [])
|
|
mock_mail.select.return_value = ("OK", [])
|
|
mock_mail.search.return_value = ("OK", [b"1"])
|
|
mock_mail.fetch.return_value = ("NO", []) # Fetch failed
|
|
|
|
pull_inbox(
|
|
mailbox_key="imap1",
|
|
host="imap.example.com",
|
|
port=993,
|
|
username="user",
|
|
password=_TEST_CREDENTIAL,
|
|
use_ssl=True,
|
|
delete_after_process=False,
|
|
)
|
|
|
|
# Should close and logout despite fetch failure
|
|
mock_mail.close.assert_called_once()
|
|
mock_mail.logout.assert_called_once()
|
|
|
|
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
|
@patch("app.tasks.imap_tasks.load_processed_emails")
|
|
def test_handles_connection_exception(self, mock_load, mock_imap_class):
|
|
"""Test handling of connection exceptions."""
|
|
mock_load.return_value = {}
|
|
mock_imap_class.side_effect = Exception("Connection error")
|
|
|
|
# Should not raise, just log
|
|
pull_inbox(
|
|
mailbox_key="imap1",
|
|
host="imap.example.com",
|
|
port=993,
|
|
username="user",
|
|
password=_TEST_CREDENTIAL,
|
|
use_ssl=True,
|
|
delete_after_process=False,
|
|
)
|
|
|
|
@patch("app.tasks.imap_tasks.email_already_has_label")
|
|
@patch("app.tasks.imap_tasks.mark_as_processed_with_label")
|
|
@patch("app.tasks.imap_tasks.mark_as_processed_with_star")
|
|
@patch("app.tasks.imap_tasks.fetch_attachments_and_enqueue")
|
|
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
|
@patch("app.tasks.imap_tasks.load_processed_emails")
|
|
@patch("app.tasks.imap_tasks.save_processed_emails")
|
|
@patch("app.tasks.imap_tasks.settings")
|
|
def test_readonly_mode_skips_gmail_modifications(
|
|
self,
|
|
mock_settings,
|
|
mock_save,
|
|
mock_load,
|
|
mock_imap_class,
|
|
mock_fetch,
|
|
mock_star,
|
|
mock_label,
|
|
mock_has_label,
|
|
):
|
|
"""Test that readonly mode skips starring and labeling Gmail messages."""
|
|
mock_settings.workdir = "/tmp"
|
|
mock_settings.imap_readonly_mode = True
|
|
mock_load.return_value = {}
|
|
mock_has_label.return_value = False
|
|
mock_mail = MagicMock()
|
|
mock_imap_class.return_value = mock_mail
|
|
|
|
import email
|
|
|
|
msg = email.message.EmailMessage()
|
|
msg["Message-ID"] = "<test@gmail.com>"
|
|
raw_email = msg.as_bytes()
|
|
|
|
mock_mail.login.return_value = ("OK", [])
|
|
mock_mail.select.return_value = ("OK", [])
|
|
mock_mail.search.return_value = ("OK", [b"1"])
|
|
mock_mail.fetch.return_value = ("OK", [[None, raw_email]])
|
|
|
|
pull_inbox(
|
|
mailbox_key="imap2",
|
|
host="imap.gmail.com",
|
|
port=993,
|
|
username="user@gmail.com",
|
|
password=_TEST_CREDENTIAL,
|
|
use_ssl=True,
|
|
delete_after_process=False,
|
|
)
|
|
|
|
# Attachments should still be processed
|
|
mock_fetch.assert_called_once()
|
|
# But no mailbox modifications
|
|
mock_star.assert_not_called()
|
|
mock_label.assert_not_called()
|
|
mock_mail.store.assert_not_called()
|
|
# Processed emails cache should still be updated
|
|
mock_save.assert_called()
|
|
|
|
@patch("app.tasks.imap_tasks.fetch_attachments_and_enqueue")
|
|
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
|
@patch("app.tasks.imap_tasks.load_processed_emails")
|
|
@patch("app.tasks.imap_tasks.save_processed_emails")
|
|
@patch("app.tasks.imap_tasks.settings")
|
|
def test_readonly_mode_skips_delete(self, mock_settings, mock_save, mock_load, mock_imap_class, mock_fetch):
|
|
"""Test that readonly mode skips deletion even when delete_after_process is True."""
|
|
mock_settings.workdir = "/tmp"
|
|
mock_settings.imap_readonly_mode = True
|
|
mock_load.return_value = {}
|
|
mock_mail = MagicMock()
|
|
mock_imap_class.return_value = mock_mail
|
|
|
|
import email
|
|
|
|
msg = email.message.EmailMessage()
|
|
msg["Message-ID"] = "<test@example.com>"
|
|
raw_email = msg.as_bytes()
|
|
|
|
mock_mail.login.return_value = ("OK", [])
|
|
mock_mail.select.return_value = ("OK", [])
|
|
mock_mail.search.return_value = ("OK", [b"1"])
|
|
mock_mail.fetch.return_value = ("OK", [[None, raw_email]])
|
|
|
|
pull_inbox(
|
|
mailbox_key="imap1",
|
|
host="imap.example.com",
|
|
port=993,
|
|
username="user",
|
|
password=_TEST_CREDENTIAL,
|
|
use_ssl=True,
|
|
delete_after_process=True,
|
|
)
|
|
|
|
# Attachments should still be processed
|
|
mock_fetch.assert_called_once()
|
|
# But no mailbox modifications (no delete, no flag changes)
|
|
mock_mail.store.assert_not_called()
|
|
mock_mail.expunge.assert_not_called()
|
|
# Processed emails cache should still be updated
|
|
mock_save.assert_called()
|
|
|
|
@patch("app.tasks.imap_tasks.fetch_attachments_and_enqueue")
|
|
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
|
@patch("app.tasks.imap_tasks.load_processed_emails")
|
|
@patch("app.tasks.imap_tasks.save_processed_emails")
|
|
@patch("app.tasks.imap_tasks.settings")
|
|
def test_readonly_mode_skips_unseen_flag(self, mock_settings, mock_save, mock_load, mock_imap_class, mock_fetch):
|
|
"""Test that readonly mode skips removing Seen flag on non-Gmail."""
|
|
mock_settings.workdir = "/tmp"
|
|
mock_settings.imap_readonly_mode = True
|
|
mock_load.return_value = {}
|
|
mock_mail = MagicMock()
|
|
mock_imap_class.return_value = mock_mail
|
|
|
|
import email
|
|
|
|
msg = email.message.EmailMessage()
|
|
msg["Message-ID"] = "<test@example.com>"
|
|
raw_email = msg.as_bytes()
|
|
|
|
mock_mail.login.return_value = ("OK", [])
|
|
mock_mail.select.return_value = ("OK", [])
|
|
mock_mail.search.return_value = ("OK", [b"1"])
|
|
mock_mail.fetch.return_value = ("OK", [[None, raw_email]])
|
|
|
|
pull_inbox(
|
|
mailbox_key="imap1",
|
|
host="imap.example.com",
|
|
port=993,
|
|
username="user",
|
|
password=_TEST_CREDENTIAL,
|
|
use_ssl=True,
|
|
delete_after_process=False,
|
|
)
|
|
|
|
# No flag changes in readonly mode
|
|
mock_mail.store.assert_not_called()
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestFetchAttachmentsExtended:
|
|
"""Extended tests for fetch_attachments_and_enqueue function."""
|
|
|
|
@patch("app.tasks.imap_tasks.process_document")
|
|
@patch("app.tasks.imap_tasks.convert_to_pdf")
|
|
def test_handles_multipart_messages(self, mock_convert, mock_process):
|
|
"""Test that multipart messages are skipped correctly."""
|
|
msg = EmailMessage()
|
|
msg["Subject"] = "Test"
|
|
msg.set_content("Body text")
|
|
|
|
result = fetch_attachments_and_enqueue(msg)
|
|
|
|
# No attachments, should return False
|
|
assert result is False
|
|
|
|
@patch("app.tasks.imap_tasks.process_document")
|
|
@patch("app.tasks.imap_tasks.convert_to_pdf")
|
|
def test_pdf_by_extension_with_wrong_mime(self, mock_convert, mock_process, tmp_path):
|
|
"""Test that PDFs are accepted by extension even with wrong MIME type."""
|
|
msg = EmailMessage()
|
|
msg["Subject"] = "Test"
|
|
msg.add_attachment(
|
|
b"%PDF-1.4",
|
|
maintype="application",
|
|
subtype="octet-stream", # Wrong MIME type
|
|
filename="document.pdf", # But correct extension
|
|
)
|
|
|
|
with patch("app.tasks.imap_tasks.settings") as mock_settings:
|
|
mock_settings.workdir = str(tmp_path)
|
|
result = fetch_attachments_and_enqueue(msg)
|
|
|
|
assert result is True
|
|
mock_process.delay.assert_called_once()
|
|
mock_convert.delay.assert_not_called()
|
|
|
|
@patch("app.tasks.imap_tasks.process_document")
|
|
@patch("app.tasks.imap_tasks.convert_to_pdf")
|
|
def test_processes_excel_file(self, mock_convert, mock_process, tmp_path):
|
|
"""Test that Excel files are sent for conversion."""
|
|
msg = EmailMessage()
|
|
msg["Subject"] = "Test"
|
|
msg.add_attachment(
|
|
b"excel content",
|
|
maintype="application",
|
|
subtype="vnd.ms-excel",
|
|
filename="spreadsheet.xls",
|
|
)
|
|
|
|
with patch("app.tasks.imap_tasks.settings") as mock_settings:
|
|
mock_settings.workdir = str(tmp_path)
|
|
result = fetch_attachments_and_enqueue(msg)
|
|
|
|
assert result is True
|
|
mock_convert.delay.assert_called_once()
|
|
mock_process.delay.assert_not_called()
|
|
|
|
@patch("app.tasks.imap_tasks.process_document")
|
|
@patch("app.tasks.imap_tasks.convert_to_pdf")
|
|
def test_processes_powerpoint_file(self, mock_convert, mock_process, tmp_path):
|
|
"""Test that PowerPoint files are sent for conversion."""
|
|
msg = EmailMessage()
|
|
msg["Subject"] = "Test"
|
|
msg.add_attachment(
|
|
b"ppt content",
|
|
maintype="application",
|
|
subtype="vnd.ms-powerpoint",
|
|
filename="presentation.ppt",
|
|
)
|
|
|
|
with patch("app.tasks.imap_tasks.settings") as mock_settings:
|
|
mock_settings.workdir = str(tmp_path)
|
|
result = fetch_attachments_and_enqueue(msg)
|
|
|
|
assert result is True
|
|
mock_convert.delay.assert_called_once()
|
|
|
|
@patch("app.tasks.imap_tasks.process_document")
|
|
@patch("app.tasks.imap_tasks.convert_to_pdf")
|
|
def test_processes_text_file(self, mock_convert, mock_process, tmp_path):
|
|
"""Test that text files are sent for conversion."""
|
|
msg = EmailMessage()
|
|
msg["Subject"] = "Test"
|
|
msg.add_attachment(
|
|
b"plain text content",
|
|
maintype="text",
|
|
subtype="plain",
|
|
filename="document.txt",
|
|
)
|
|
|
|
with patch("app.tasks.imap_tasks.settings") as mock_settings:
|
|
mock_settings.workdir = str(tmp_path)
|
|
result = fetch_attachments_and_enqueue(msg)
|
|
|
|
assert result is True
|
|
mock_convert.delay.assert_called_once()
|
|
|
|
@patch("app.tasks.imap_tasks.process_document")
|
|
@patch("app.tasks.imap_tasks.convert_to_pdf")
|
|
def test_processes_csv_file(self, mock_convert, mock_process, tmp_path):
|
|
"""Test that CSV files are sent for conversion."""
|
|
msg = EmailMessage()
|
|
msg["Subject"] = "Test"
|
|
msg.add_attachment(
|
|
b"col1,col2\nval1,val2",
|
|
maintype="text",
|
|
subtype="csv",
|
|
filename="data.csv",
|
|
)
|
|
|
|
with patch("app.tasks.imap_tasks.settings") as mock_settings:
|
|
mock_settings.workdir = str(tmp_path)
|
|
result = fetch_attachments_and_enqueue(msg)
|
|
|
|
assert result is True
|
|
mock_convert.delay.assert_called_once()
|
|
|
|
@patch("app.tasks.imap_tasks.process_document")
|
|
@patch("app.tasks.imap_tasks.convert_to_pdf")
|
|
def test_processes_rtf_file(self, mock_convert, mock_process, tmp_path):
|
|
"""Test that RTF files are sent for conversion."""
|
|
msg = EmailMessage()
|
|
msg["Subject"] = "Test"
|
|
msg.add_attachment(
|
|
b"{\\rtf1 content}",
|
|
maintype="application",
|
|
subtype="rtf",
|
|
filename="document.rtf",
|
|
)
|
|
|
|
with patch("app.tasks.imap_tasks.settings") as mock_settings:
|
|
mock_settings.workdir = str(tmp_path)
|
|
result = fetch_attachments_and_enqueue(msg)
|
|
|
|
assert result is True
|
|
mock_convert.delay.assert_called_once()
|
|
|
|
@patch("app.tasks.imap_tasks.process_document")
|
|
@patch("app.tasks.imap_tasks.convert_to_pdf")
|
|
def test_attachment_without_filename(self, mock_convert, mock_process):
|
|
"""Test that attachments without filename are skipped."""
|
|
msg = EmailMessage()
|
|
msg["Subject"] = "Test"
|
|
# Add a part without filename
|
|
msg.add_attachment(b"content", maintype="application", subtype="pdf")
|
|
# Remove the filename header
|
|
for part in msg.iter_parts():
|
|
if part.get_filename():
|
|
part.del_param("filename", header="content-disposition")
|
|
|
|
result = fetch_attachments_and_enqueue(msg)
|
|
|
|
assert result is False
|
|
mock_process.delay.assert_not_called()
|
|
mock_convert.delay.assert_not_called()
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestLoadProcessedEmailsEdgeCases:
|
|
"""Extended tests for load_processed_emails edge cases."""
|
|
|
|
@patch("app.tasks.imap_tasks.CACHE_FILE", "/tmp/test_invalid.json")
|
|
def test_handles_invalid_json(self):
|
|
"""Test that invalid JSON is handled gracefully."""
|
|
# Write invalid JSON to the file
|
|
with open("/tmp/test_invalid.json", "w") as f:
|
|
f.write("{ invalid json")
|
|
|
|
result = load_processed_emails()
|
|
assert result == {}
|
|
|
|
# Clean up
|
|
if os.path.exists("/tmp/test_invalid.json"):
|
|
os.remove("/tmp/test_invalid.json")
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestEmailAlreadyHasLabelExtended:
|
|
"""Extended tests for email_already_has_label."""
|
|
|
|
def test_handles_integer_msg_id(self):
|
|
"""Test that integer msg_id is converted to bytes."""
|
|
mock_mail = MagicMock()
|
|
mock_mail.fetch.return_value = ("OK", [(None, b'"Ingested"')])
|
|
|
|
result = email_already_has_label(mock_mail, 123, "Ingested")
|
|
|
|
# Should convert int to bytes
|
|
mock_mail.fetch.assert_called_once()
|
|
assert result is True
|
|
|
|
def test_handles_empty_label_data(self):
|
|
"""Test handling when label data is empty."""
|
|
mock_mail = MagicMock()
|
|
mock_mail.fetch.return_value = ("OK", [])
|
|
|
|
result = email_already_has_label(mock_mail, b"1", "Ingested")
|
|
assert result is False
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestGetCapabilitiesEdgeCases:
|
|
"""Test edge cases for get_capabilities function."""
|
|
|
|
def test_get_capabilities_with_failed_response(self):
|
|
"""Test get_capabilities when server returns error."""
|
|
mock_mail = MagicMock()
|
|
mock_mail.capability.return_value = ("NO", None)
|
|
|
|
result = get_capabilities(mock_mail)
|
|
assert result == []
|
|
|
|
def test_get_capabilities_with_empty_data(self):
|
|
"""Test get_capabilities with empty capability data."""
|
|
mock_mail = MagicMock()
|
|
mock_mail.capability.return_value = ("OK", [b""])
|
|
|
|
result = get_capabilities(mock_mail)
|
|
assert isinstance(result, list)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestFindAllMailXlistEdgeCases:
|
|
"""Test edge cases for find_all_mail_xlist function."""
|
|
|
|
def test_find_all_mail_xlist_no_allmail_flag(self):
|
|
"""Test XLIST when no folder has ALLMAIL flag."""
|
|
mock_mail = MagicMock()
|
|
mock_mail._new_tag.return_value = b"A001"
|
|
# Simulate responses without ALLMAIL flag
|
|
mock_mail.readline.side_effect = [
|
|
b'* XLIST (\\HasNoChildren) "/" "INBOX"\r\n',
|
|
b"A001 OK XLIST completed\r\n",
|
|
]
|
|
|
|
result = find_all_mail_xlist(mock_mail)
|
|
assert result is None
|
|
|
|
def test_find_all_mail_xlist_with_valid_allmail(self):
|
|
"""Test XLIST when folder has ALLMAIL flag."""
|
|
mock_mail = MagicMock()
|
|
mock_mail._new_tag.return_value = b"A001"
|
|
# Simulate response with ALLMAIL flag
|
|
mock_mail.readline.side_effect = [
|
|
b'* XLIST (\\AllMail \\HasNoChildren) "/" "[Gmail]/All Mail"\r\n',
|
|
b"A001 OK XLIST completed\r\n",
|
|
]
|
|
|
|
result = find_all_mail_xlist(mock_mail)
|
|
assert result == "[Gmail]/All Mail"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestAcquireReleaseLockEdgeCases:
|
|
"""Test edge cases for lock acquisition and release."""
|
|
|
|
@patch("app.tasks.imap_tasks.redis_client")
|
|
def test_acquire_lock_when_already_held(self, mock_redis):
|
|
"""Test lock acquisition when lock is already held."""
|
|
mock_redis.setnx.return_value = False
|
|
|
|
result = acquire_lock()
|
|
assert result is False
|
|
# Should not call expire if lock not acquired
|
|
mock_redis.expire.assert_not_called()
|
|
|
|
@patch("app.tasks.imap_tasks.redis_client")
|
|
def test_release_lock_always_attempts_delete(self, mock_redis):
|
|
"""Test that release_lock always attempts to delete the key."""
|
|
release_lock()
|
|
mock_redis.delete.assert_called_once()
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestPullInboxEdgeCases:
|
|
"""Test edge cases for pull_inbox function."""
|
|
|
|
@patch("app.tasks.imap_tasks.load_processed_emails")
|
|
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
|
@patch("app.tasks.imap_tasks.settings")
|
|
def test_pull_inbox_search_failed_status(self, mock_settings, mock_imap_class, mock_load):
|
|
"""Test pull_inbox when search returns non-OK status."""
|
|
mock_settings.workdir = "/tmp"
|
|
mock_load.return_value = {}
|
|
|
|
mock_mail = MagicMock()
|
|
mock_mail.login.return_value = ("OK", [])
|
|
mock_mail.select.return_value = ("OK", [])
|
|
mock_mail.search.return_value = ("NO", []) # Search failed
|
|
mock_imap_class.return_value = mock_mail
|
|
|
|
# Should handle gracefully and not raise
|
|
pull_inbox("test", "imap.example.com", 993, "user", "pass", True, False)
|
|
|
|
mock_mail.close.assert_called_once()
|
|
mock_mail.logout.assert_called_once()
|
|
|
|
@patch("app.tasks.imap_tasks.load_processed_emails")
|
|
@patch("app.tasks.imap_tasks.save_processed_emails")
|
|
@patch("app.tasks.imap_tasks.fetch_attachments_and_enqueue")
|
|
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
|
@patch("app.tasks.imap_tasks.settings")
|
|
def test_pull_inbox_email_without_message_id(
|
|
self, mock_settings, mock_imap_class, mock_fetch, mock_save, mock_load
|
|
):
|
|
"""Test that emails without Message-ID are skipped."""
|
|
mock_settings.workdir = "/tmp"
|
|
mock_load.return_value = {}
|
|
|
|
mock_mail = MagicMock()
|
|
mock_mail.login.return_value = ("OK", [])
|
|
mock_mail.select.return_value = ("OK", [])
|
|
mock_mail.search.return_value = ("OK", [b"1"])
|
|
|
|
# Create email without Message-ID header
|
|
email_without_id = EmailMessage()
|
|
email_without_id["Subject"] = "Test"
|
|
raw_email = email_without_id.as_bytes()
|
|
|
|
mock_mail.fetch.return_value = ("OK", [(None, raw_email)])
|
|
mock_imap_class.return_value = mock_mail
|
|
|
|
pull_inbox("test", "imap.example.com", 993, "user", "pass", True, False)
|
|
|
|
# Should skip processing since no Message-ID
|
|
mock_fetch.assert_not_called()
|
|
|
|
@patch("app.tasks.imap_tasks.load_processed_emails")
|
|
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
|
@patch("app.tasks.imap_tasks.settings")
|
|
def test_pull_inbox_fetch_failed_status(self, mock_settings, mock_imap_class, mock_load):
|
|
"""Test pull_inbox when fetch returns non-OK status."""
|
|
mock_settings.workdir = "/tmp"
|
|
mock_load.return_value = {}
|
|
|
|
mock_mail = MagicMock()
|
|
mock_mail.login.return_value = ("OK", [])
|
|
mock_mail.select.return_value = ("OK", [])
|
|
mock_mail.search.return_value = ("OK", [b"1"])
|
|
mock_mail.fetch.return_value = ("NO", []) # Fetch failed
|
|
mock_imap_class.return_value = mock_mail
|
|
|
|
# Should handle gracefully
|
|
pull_inbox("test", "imap.example.com", 993, "user", "pass", True, False)
|
|
|
|
mock_mail.close.assert_called_once()
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestMarkAsProcessedFunctions:
|
|
"""Test mark_as_processed_with_star and mark_as_processed_with_label functions."""
|
|
|
|
def test_mark_as_processed_with_star_handles_exception(self):
|
|
"""Test that star marking handles exceptions gracefully."""
|
|
mock_mail = MagicMock()
|
|
mock_mail.store.side_effect = Exception("Connection error")
|
|
|
|
# Should not raise, just log error
|
|
mark_as_processed_with_star(mock_mail, b"1")
|
|
|
|
def test_mark_as_processed_with_label_handles_exception(self):
|
|
"""Test that label marking handles exceptions gracefully."""
|
|
mock_mail = MagicMock()
|
|
mock_mail.store.side_effect = Exception("Connection error")
|
|
|
|
# Should not raise, just log error
|
|
mark_as_processed_with_label(mock_mail, b"1", "Ingested")
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestEmailAlreadyHasLabelExceptions:
|
|
"""Test exception handling in email_already_has_label."""
|
|
|
|
def test_email_already_has_label_fetch_exception(self):
|
|
"""Test that fetch exceptions are handled gracefully."""
|
|
mock_mail = MagicMock()
|
|
mock_mail.fetch.side_effect = Exception("Fetch failed")
|
|
|
|
result = email_already_has_label(mock_mail, b"1", "Ingested")
|
|
|
|
# Should return False on error
|
|
assert result is False
|