531 lines
18 KiB
Python
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()
|