Files
logwatcher/tests/test_mail_reader.py
T

531 lines
18 KiB
Python

import logging
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from exchangelib import FileAttachment, ItemAttachment, Message
from exchangelib.errors import UnauthorizedError
from pytest import MonkeyPatch
# noinspection protected-member
from logwatcher.mail_reader import (
LOG_IN_ATTACHMENT_PATTERN,
_find_log_attachment,
_get_attachment_content,
_get_mail_body,
_has_attachment_marker,
connect_to_mailbox,
extract_log_lines,
extract_logs_from_mails,
fetch_log_messages,
move_analyzed_mails,
)
@pytest.fixture(name="empty_mail")
def get_empty_mail_fixture() -> Message:
"""Return a fake Message."""
return Message(subject="test without attachment", text_body="test")
@pytest.fixture(name="log_mail_with_attachment")
def get_log_mail_with_attachment(original_log_dir: Path) -> Message:
"""Return a fake Message with a .txt file attachment."""
file = original_log_dir / "CR_20260727110008.txt"
return Message(
subject="test with logs in attachment",
text_body="inline attachment here",
attachments=[
FileAttachment(
name="image.png",
content=b"...",
is_inline=True,
),
FileAttachment(name=file.name, content=file.read_bytes(), is_inline=False),
],
)
@pytest.fixture(name="log_mail_with_body")
def get_log_mail_with_body() -> Message:
"""Return a fake Message with logs in text body."""
return Message(
subject="test with logs in text body",
text_body="Compte-rendu du suivi des imports NOSYMAG du 02/09/2026 à 15:00:10\n\n\n"
"Répertoire scanné : \\192.168.60.40\\e$\\MDC_1110\\Logs\r\n"
"\\192.168.60.41\\e$\\MDC_2110\\Logs\26\08\31\20260831234232.txt [31/08/2026 23:42:39] ExecuteProcess -> GetMasterDataFiles -> UnZipFile -> Erreur : le fichier [E:\\MDC_2110\\Master\\ATELIER_v20260806t233448.zip] n'existe pas !\r\n"
"Répertoire scanné : \\192.168.60.42\\e$\\MDC_3110\\Logs\r\n"
"\\192.168.60.42\\e$\\MDC_3110\\Logs\26\08\31\20260831235503.txt [31/08/2026 23:55:10] ExecuteProcess -> GetMasterDataFiles -> UnZipFile -> Erreur : le fichier [E:\\MDC_3110\\Master\\ATELIER_v20260806t233448.zip] n'existe pas !\r\n"
"Répertoire scanné : \\192.168.60.43\\e$\\MDC_4110\\Logs\r\n"
"\\192.168.60.43\\e$\\MDC_4110\\Logs\26\08\31\20260831233518.txt [31/08/2026 23:35:25] ExecuteProcess -> GetMasterDataFiles -> UnZipFile -> Erreur : le fichier [E:\\MDC_4110\\Master\\ATELIER_v20260806t233448.zip] n'existe pas !\r\n"
"Répertoire scanné : \\192.168.60.44\\e$\\MDC_5110\\Logs\r\n"
"\\192.168.60.44\\e$\\MDC_5110\\Logs\26\08\31\20260831233628.txt [31/08/2026 23:36:37] ExecuteProcess -> GetMasterDataFiles -> UnZipFile -> Erreur : le fichier [E:\\MDC_5110\\Master\\ATELIER_v20260806t233448.zip] n'existe pas !\r\n",
attachments=[
FileAttachment(
name="image.png",
content=b"...",
is_inline=True,
),
],
)
@pytest.fixture
def set_env(monkeypatch):
"""Set the mailbox environment variables."""
env = {
"EMAIL": "test@client.com",
"PASSWORD": "passtest",
"EWS_URL": "https://mail.client.com/EWS/Exchange.asmx",
}
for key, value in env.items():
monkeypatch.setenv(key, value)
class FakeProtocol:
"""Protocol whose .version access raises UnauthorizedError."""
@property
def version(self): # noqa: D102
raise UnauthorizedError("invalid credentials")
class FakeAccount:
"""Account with a protocol that raises on version access."""
def __init__(self): # noqa: D107
self.protocol = FakeProtocol()
class OkProtocol:
"""Protocol whose .version succeeds."""
@property
def version(self): # noqa: D102
return MagicMock()
class OkAccount:
"""Account with a protocol that returns a version successfully."""
def __init__(self): # noqa: D107
self.protocol = OkProtocol()
#########################################
# _find_log_attachment #
#########################################
def test_find_log_attachment_returns_log_file(
empty_mail: Message,
):
"""Returns the CR_*.txt attachment, not the inline image."""
image_att = FileAttachment(name="image.png", content=b"...", is_inline=True)
log_att = FileAttachment(name="CR_123.txt", content=b"...", is_inline=False)
empty_mail.attach((image_att, log_att))
attachment = _find_log_attachment(empty_mail)
assert attachment is not None
assert attachment.name == log_att.name
def test_find_log_attachment_ignores_inline_image(empty_mail: Message):
"""When the log attachment is inline, returns None."""
empty_mail.attach(FileAttachment(name="image.png", content=b"", is_inline=True))
attachment = _find_log_attachment(empty_mail)
assert attachment is None
def test_find_log_attachment_no_log_file(empty_mail):
"""Returns None when no CR_*.txt attachment exists."""
attachment = _find_log_attachment(empty_mail)
assert attachment is None
def test_find_log_attachment_accepts_no_extension(empty_mail: Message):
"""Accepts a CR_* attachment without extension."""
empty_mail.attach(FileAttachment(name="CR_123", content=b"...", is_inline=False))
attachment = _find_log_attachment(empty_mail)
assert attachment is not None
assert "." not in attachment.name
def test_find_log_attachment_rejects_other_names(
empty_mail: Message,
):
"""Rejects attachments not starting with CR_ even with .txt or .log extension or extensionless."""
attachments = (
FileAttachment(
name="attachment.pdf",
content=b"...",
is_inline=False,
),
FileAttachment(
name="attachment.xml",
content=b"...",
is_inline=False,
),
FileAttachment(
name="attachment.test",
content=b"...",
is_inline=False,
),
FileAttachment(
name="attachment.conf",
content=b"...",
is_inline=False,
),
FileAttachment(
name="attachment.txt",
content=b"...",
is_inline=False,
),
FileAttachment(
name="attachment.log",
content=b"...",
is_inline=False,
),
FileAttachment(
name="attachment",
content=b"...",
is_inline=False,
),
FileAttachment(
name="CR_attachment.pdf",
content=b"...",
is_inline=False,
),
FileAttachment(
name="CR_attachment.xml",
content=b"...",
is_inline=False,
),
)
empty_mail.attach(attachments)
assert len(empty_mail.attachments) == len(attachments)
attachment = _find_log_attachment(empty_mail)
assert attachment is None
def test_find_log_attachment_rejects_non_file_attachment(empty_mail: Message):
"""Skips ItemAttachment (not a FileAttachment)."""
empty_mail.attach(
ItemAttachment(
item=Message(subject="a sub message", text_body="this is a sub message")
)
)
assert len(empty_mail.attachments) == 1
attachment = _find_log_attachment(empty_mail)
assert attachment is None
############################
# _get_attachment_content #
############################
def test_get_attachment_content_decodes_windows_1252(original_log_dir: Path):
"""Decodes content with accents correctly."""
file_with_accents = original_log_dir / "CR_20260727110008.txt"
assert file_with_accents.is_file()
file_content = file_with_accents.read_bytes()
attachment = FileAttachment(content=file_content)
content = _get_attachment_content(attachment)
assert "�" not in content
def test_get_attachment_content_raises_when_none():
"""Raises ValueError when attachment.content is None."""
attachment = FileAttachment(content=None)
with pytest.raises(ValueError):
_get_attachment_content(attachment)
def test_get_attachment_content_empty_bytes():
"""Decodes empty bytes to empty string without error."""
attachment = FileAttachment(content=b"")
content = _get_attachment_content(attachment)
assert content == ""
########################
# _get_mail_body(mail) #
########################
def test_get_mail_body_from_marker_to_end(empty_mail: Message):
"""Returns body starting at 'Répertoire scanné'."""
empty_mail.text_body += "Répertoire scanné : \\192.168.60.40\\e$\\MDC_1110\\Logs"
body = _get_mail_body(empty_mail)
assert "test" not in body
assert "Répertoire scanné : \\192.168.60.40\\e$\\MDC_1110\\Logs" == body
def test_get_mail_body_no_marker_returns_empty(empty_mail: Message):
"""Returns empty string and logs warning when marker absent."""
body = _get_mail_body(empty_mail)
assert body == ""
def test_get_mail_body_marker_at_start(empty_mail: Message):
"""Returns whole body when marker is at position 0."""
initial_text_body = empty_mail.text_body
empty_mail.text_body = "Répertoire scanné : " + initial_text_body
content = _get_mail_body(empty_mail)
assert initial_text_body in content
assert content == "Répertoire scanné : " + initial_text_body
def test_get_mail_body_multiple_markers(empty_mail: Message):
"""Returns from the FIRST 'Répertoire scanné' occurrence."""
initial_text_body = empty_mail.text_body
empty_mail.text_body = (
initial_text_body
+ "Répertoire\nLorem Ipsum\nRépertoire scanné\nRépertoire scanné\nRépertoire scanné"
)
content = _get_mail_body(empty_mail)
assert content != ""
assert len(content) < len(empty_mail.text_body)
assert initial_text_body not in content
assert "Répertoire scanné\nRépertoire scanné\nRépertoire scanné" == content
################################
# _has_attachment_marker(body) #
################################
def test_has_attachment_marker_true():
"""Returns True when the exact pattern is present."""
body_with_pattern = "Compte-rendu du suivi des imports NOSYMAG du 02/09/2026 à 15:00:10\n\nLe compte-rendu contient plus de 100 lignes.\nSe reporter à la pièce jointe."
assert _has_attachment_marker(body_with_pattern)
def test_has_attachment_marker_false():
"""Returns False when pattern is absent."""
body_without_pattern = (
"Compte-rendu du suivi des imports NOSYMAG du 01/09/2026 à 03:00:10\n\nRépertoire scanné : "
"\\192.168.60.40\\e\\$\\MDC_1110\\Logs\n\nRépertoire scanné : \\192.168.60.41\\e$\\MDC_2110"
"\\Logs\\192.168.60.41\\e$\\MDC_2110\\Logs\\26\\08\\31\\20260831234232.txt [31/08/2026 "
"23:42:39] ExecuteProcess -> GetMasterDataFiles -> UnZipFile -> Erreur : le fichier "
"[E:\\MDC_2110\\Master\\ATELIER_v20260806t233448.zip] n'existe pas !"
)
assert not _has_attachment_marker(body_without_pattern)
def test_has_attachment_marker_empty_string():
"""Returns False on empty body."""
body_empty = ""
assert not _has_attachment_marker(body_empty)
########################
# connect_to_mailbox() #
########################
def test_connect_to_mailbox_success(monkeypatch: MonkeyPatch, caplog, set_env):
"""Returns an Account when credentials are valid."""
with monkeypatch.context() as m, caplog.at_level(logging.INFO, "logwatcher"):
m.setattr("logwatcher.mail_reader.Account", lambda *a, **k: OkAccount())
account = connect_to_mailbox()
assert account is not None
assert "Connection failed: invalid credentials." not in caplog.messages
assert "Connection established successfully." in caplog.messages
@pytest.mark.parametrize("missing", ["EMAIL", "PASSWORD", "EWS_URL"])
def test_connect_to_mailbox_missing_env(
monkeypatch: MonkeyPatch, missing: str, set_env
):
"""Raises KeyError when an env var is missing."""
monkeypatch.delenv(missing, raising=False)
with pytest.raises(KeyError):
connect_to_mailbox()
def test_connect_to_mailbox_invalid_credentials(
monkeypatch: MonkeyPatch, caplog, set_env
):
"""Logs error and re-raises on UnauthorizedError."""
with caplog.at_level(logging.ERROR, "logwatcher"):
with monkeypatch.context() as m:
m.setattr("logwatcher.mail_reader.Account", lambda *a, **k: FakeAccount())
with pytest.raises(UnauthorizedError):
connect_to_mailbox()
assert "Connection failed: invalid credentials." in caplog.messages
assert "Connection established successfully." not in caplog.messages
###############################
# fetch_log_messages(account) #
###############################
def test_fetch_log_messages_returns_messages(make_mock_account):
"""Returns the .all() of the Logs folder."""
mail_inline = Message(subject="mail1", text_body="text1")
mail_not_inline = Message(subject="mail2", text_body="text2")
account = make_mock_account([mail_inline, mail_not_inline])
results = fetch_log_messages(account)
assert results == [mail_inline, mail_not_inline]
def test_fetch_log_messages_folder_path(make_mock_account):
"""Accesses msg_folder_root / 'Logs'."""
account = make_mock_account()
fetch_log_messages(account)
account.msg_folder_root.__truediv__.assert_called_once_with("Logs")
##############################
# extract_log_lines(content) #
##############################
def test_extract_log_lines_split_and_clean(original_log_dir: Path):
r"""Split content, strips '\r\n', removes empty lines."""
file = original_log_dir / "CR_20260727110008.txt"
content = file.read_text(encoding="windows-1252")
lines = extract_log_lines(content)
assert len(lines) > 1
for line in lines:
assert "\n" not in line
assert "\r" not in line
assert line != ""
def test_extract_log_lines_empty_content():
"""Returns [] on empty string."""
content = ""
lines = extract_log_lines(content)
assert lines == []
def test_extract_log_lines_no_trailing_newline():
"""Handles content not ending with a newline."""
content = (
"Répertoire scanné : \\192.168.60.40\\e$\\MDC_1110\\Logs\n"
"\\192.168.60.40\\e$\\MDC_1110\\Logs\\26\\07\\27\\20260727083117.txt [27/07/2026 08:31:26] DOSSIER EN COURS : FLAMMIER [27/07/2026 08:31:53] Erreur FTP commande : Requested action not taken"
)
lines = extract_log_lines(content)
assert len(lines) > 1
def test_extract_log_lines_crlf():
r"""Handles Windows \r\n line endings."""
content = (
"Répertoire scanné : \\192.168.60.40\\e$\\MDC_1110\\Logs\n"
"\\192.168.60.40\\e$\\MDC_1110\\Logs\\26\\07\\27\\20260727083117.txt [27/07/2026 08:31:26] DOSSIER EN COURS : FLAMMIER [27/07/2026 08:31:53] Erreur FTP commande : Requested action not taken\r\n"
)
lines = extract_log_lines(content)
assert len(lines) > 1
for line in lines:
assert line != ""
assert "\n" not in line
assert "\r" not in line
#############################
# extract_logs_from_mails() #
#############################
def test_extract_logs_from_mails_with_attachment(original_log_dir: Path):
"""Accumulates log lines from attachment mails."""
# create mail with attachments
messages = []
for index, file in enumerate(original_log_dir.iterdir()):
file_content = file.read_bytes()
attachment = FileAttachment(
name=file.name, content=file_content, is_inline=False
)
message = Message(
subject=f"Compte-rendu du suivi des imports NOSYMAG du {index}",
text_body=f"Compte-rendu du suivi des imports NOSYMAG du {index}\n"
f"{LOG_IN_ATTACHMENT_PATTERN}\n"
f"Se reporter à la pièce jointe.",
attachments=[attachment],
)
messages.append(message)
content_list = extract_logs_from_mails(messages)
assert len(content_list) > 1
for content in content_list:
assert content != ""
def test_extract_logs_from_mails_inline(log_mail_with_body: Message):
"""Accumulates log lines from body-only mails."""
messages = [log_mail_with_body]
content_list = extract_logs_from_mails(messages)
assert len(content_list) > 1
for content in content_list:
assert content != ""
def test_extract_logs_from_mails_missing_attachment_logs_warning(caplog):
"""Warns and skips when marker says attachment but none found."""
subject = "Compte-rendu du suivi des imports NOSYMAG"
messages = [
Message(
subject=subject,
text_body=LOG_IN_ATTACHMENT_PATTERN,
attachments=[
FileAttachment(name="image.jpg", content=b"...", is_inline=True)
],
),
]
with caplog.at_level(logging.WARNING, logger="logwatcher"):
extract_logs_from_mails(messages)
assert (
f"Mail '{subject}' says attachment but none were found." in caplog.messages
)
def test_extract_logs_from_mails_combines_multiple(
log_mail_with_attachment: Message, log_mail_with_body: Message
):
"""Combines logs from several mails (attachment + inline)."""
messages = [log_mail_with_attachment, log_mail_with_body]
content_list = extract_logs_from_mails(messages)
assert len(content_list) > 1
for content in content_list:
assert content != ""
def test_extract_logs_from_mails_no_mails():
"""Returns [] when no mails in folder."""
content_list = extract_logs_from_mails([])
assert content_list == []
def test_move_analyzed_mails_moves_all(make_mock_account):
"""Calls bulk_move once with every (id, changekey) pair."""
account = make_mock_account(analyzed_folder=MagicMock())
mail_one = Message(subject="mail1", text_body="text1")
mail_two = Message(subject="mail2", text_body="text2")
mail_one.id, mail_one.changekey = "id1", "ck1"
mail_two.id, mail_two.changekey = "id2", "ck2"
move_analyzed_mails(account, [mail_one, mail_two])
account.bulk_move.assert_called_once()
assert account.bulk_move.call_args.kwargs["ids"] == [("id1", "ck1"), ("id2", "ck2")]
def test_move_analyzed_mails_empty_list(make_mock_account):
"""Does nothing when there is no mail to move."""
account = make_mock_account(analyzed_folder=MagicMock())
move_analyzed_mails(account, [])
account.bulk_move.assert_not_called()