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, _get_or_create_analyzed_folder, _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: """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: """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: """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 make_mock_account(): """A fake Account with a msg_folder_root returning a fake Logs folder.""" def _make_mock_account( messages: list[Message] | None = None, analyzed_folder: MagicMock | None = None ): account = MagicMock() logs_folder = account.msg_folder_root.__truediv__.return_value logs_folder.all.return_value = messages or [] logs_folder.glob.return_value = [analyzed_folder] if analyzed_folder else [] return account return _make_mock_account @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): raise UnauthorizedError("invalid credentials") class FakeAccount: """Account with a protocol that raises on version access.""" def __init__(self): self.protocol = FakeProtocol() class OkProtocol: """Protocol whose .version succeeds.""" @property def version(self): return MagicMock() class OkAccount: """Account with a protocol that returns a version successfully.""" def __init__(self): 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): """Splits 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(): """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_get_or_create_analyzed_folder_exists(make_mock_account, monkeypatch): """ Returns the existing 'Analyzed' folder without creating it. Args: make_mock_account: Fake account owning the Logs folder. monkeypatch: MonkeyPatch to generate test context """ existing_folder = MagicMock() account = make_mock_account(analyzed_folder=existing_folder) fake_folder_cls = MagicMock() with monkeypatch.context() as m: m.setattr("logwatcher.mail_reader.Folder", fake_folder_cls) result = _get_or_create_analyzed_folder(account) assert result is existing_folder fake_folder_cls.assert_not_called() def test_get_or_create_analyzed_folder_creates(make_mock_account, monkeypatch): """Creates the 'Analyzed' folder when it doesn't exist.""" account = make_mock_account(analyzed_folder=None) fake_folder_instance = MagicMock() fake_folder_cls = MagicMock(return_value=fake_folder_instance) with monkeypatch.context() as m: m.setattr("logwatcher.mail_reader.Folder", fake_folder_cls) result = _get_or_create_analyzed_folder(account) assert result is fake_folder_instance fake_folder_instance.save.assert_called_once() 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()