test(notifier): ✅ add tests for notifier module

This commit is contained in:
2026-09-16 14:46:35 +02:00
parent eb4ef2339e
commit cf09ab0c6a
6 changed files with 84 additions and 33 deletions
+2 -2
View File
@@ -12,8 +12,8 @@ N2_PATTERNS = {
"CLOTHES_SIZE_RANGE_ORDER": r"Erreur : (?P<group_number>GRP_\d+) / (?P<store_code>\d{5}/\d{3}) (?P<sap_code>\d{4})_(?P<order_number>\w+) ERREUR (?P<model_code>(?P<nosymag_model_code>\w+ (?P<color_code>\w+))(?P<brand_code>\w{3})?) / SetTailleTrav -> SizeRange (?P<size_range>\d+\.\d+) / Dépassement du nombre de tailles travaillées \(28 tailles maximum\)", "CLOTHES_SIZE_RANGE_ORDER": r"Erreur : (?P<group_number>GRP_\d+) / (?P<store_code>\d{5}/\d{3}) (?P<sap_code>\d{4})_(?P<order_number>\w+) ERREUR (?P<model_code>(?P<nosymag_model_code>\w+ (?P<color_code>\w+))(?P<brand_code>\w{3})?) / SetTailleTrav -> SizeRange (?P<size_range>\d+\.\d+) / Dépassement du nombre de tailles travaillées \(28 tailles maximum\)",
"CLOTHES_SIZE_RANGE_BL": r"Erreur : (?P<bl_number>(?P<sap_code>\d{4})_(?P<order_number>[\w-]+)_DESADV) : / (?P<store_code>\d{5}/\d{3}) .*? SizeRange (?P<size_range>\d+\.\d+) .*?Dépassement du nombre de tailles travaillées \(28 tailles maximum\)", "CLOTHES_SIZE_RANGE_BL": r"Erreur : (?P<bl_number>(?P<sap_code>\d{4})_(?P<order_number>[\w-]+)_DESADV) : / (?P<store_code>\d{5}/\d{3}) .*? SizeRange (?P<size_range>\d+\.\d+) .*?Dépassement du nombre de tailles travaillées \(28 tailles maximum\)",
"CLOTHES_SIZE_NOT_FOUND": r"(?P<group_number>GRP_\d+) / (?P<store_code>\d{5}/\d{3}) (?P<sap_code>\d{4})_(?P<order_number>\w+) WARNING \d+ \w+ / SizeRange (?P<size_range>\d+\.\d+) - Columnx \d+ / Taille In[ée]xistante", "CLOTHES_SIZE_NOT_FOUND": r"(?P<group_number>GRP_\d+) / (?P<store_code>\d{5}/\d{3}) (?P<sap_code>\d{4})_(?P<order_number>\w+) WARNING \d+ \w+ / SizeRange (?P<size_range>\d+\.\d+) - Columnx \d+ / Taille In[ée]xistante",
"BL_AUTO_BESTSELLER_REJECTED": r"Erreur : (?P<bl_number>\d{4}_\d{10}_DESADV) : / (?P<store_code>\d{5}/\d{3}) (\d{4}_\d{10}_DESADV) ERREUR [\w\_]+ / GETINFOLIGNE_FOU -> BL AUTO : \w+ / Package : / EAN : (?P<ean_number>\w+) / Aucune ligne de la commande (?P<command_number>\w+) ne correspond", "BL_AUTO_BESTSELLER_REJECTED": r"Erreur : (?P<bl_number>\d{4}_\d{8,}_DESADV) : / (?P<store_code>\d{5}/\d{3}) (\d{4}_\d{10}_DESADV) ERREUR [\w\_]+ / GETINFOLIGNE_FOU -> BL AUTO : \w+ / Package : / EAN : (?P<ean_number>\w+) / Aucune ligne de la commande (?P<command_number>\w+) ne correspond",
"BL_COMMAND_NOT_FOUND": r"(?P<bl_number>\d{4}_\d{10}_DESADV) : / (?P<store_code>\d{5}/\d{3}) (\d{4}_\d{10}_DESADV) ERREUR [\w\_]+ / GETINFOLIGNE_FOU -> BL AUTO : \w+ / Package : / EAN : (?P<ean_number>\w+) / La commande (?P<command_number>\w+) n'a pas été trouvé", "BL_COMMAND_NOT_FOUND": r"(?P<bl_number>(?P<sap_code>\d{4})_\d{8,}_DESADV) .+ / La commande (?P<command_number>\w+) n'a pas été trouvé",
"FEDAS_CODE_NOT_FOUND": r"Exception : Code Fedas non trouvé : (?P<fedas_code>\d+)", "FEDAS_CODE_NOT_FOUND": r"Exception : Code Fedas non trouvé : (?P<fedas_code>\d+)",
"IMPOSSIBLE_FUSION_DIFFERENT_MODEL_SIZE": r"Erreur : (?P<group_number>GRP_\d+) / (?P<store_code>\d{5}/\d{3}) (?P<sap_code>\d{4})_(?P<order_number>\w+) WARNING \w+ \w+ / Art_id : \w+ - Fusart_id : (?P<fusart_id>\w+) / Impossible de créer un lien", "IMPOSSIBLE_FUSION_DIFFERENT_MODEL_SIZE": r"Erreur : (?P<group_number>GRP_\d+) / (?P<store_code>\d{5}/\d{3}) (?P<sap_code>\d{4})_(?P<order_number>\w+) WARNING \w+ \w+ / Art_id : \w+ - Fusart_id : (?P<fusart_id>\w+) / Impossible de créer un lien",
"MODEL_CODE_DOES_NOT_MATCH_EAN_CODE": r"Erreur : (?P<bl_number>(?P<sap_code>\d{4})_(?P<order_number>[\w-]+)_DESADV) : / (?P<store_code>\d{5}/\d{3}) \d{4}_\w+ ERREUR (?P<model_code>\w+ \w+) / Article -> BL AUTO : (?P<bl_auto_code>\w+) / Package : (?P<package_number>\w+) / EAN : (?P<ean_number>\d+) / Article création erreur", "MODEL_CODE_DOES_NOT_MATCH_EAN_CODE": r"Erreur : (?P<bl_number>(?P<sap_code>\d{4})_(?P<order_number>[\w-]+)_DESADV) : / (?P<store_code>\d{5}/\d{3}) \d{4}_\w+ ERREUR (?P<model_code>\w+ \w+) / Article -> BL AUTO : (?P<bl_auto_code>\w+) / Package : (?P<package_number>\w+) / EAN : (?P<ean_number>\d+) / Article création erreur",
+2 -2
View File
@@ -19,7 +19,7 @@ def send_n2_report(account: Account, summary: str, n2_log_file: Path) -> None:
""" """
try: try:
to = os.environ["N2_REPORT_RECIPIENTS"].split(",") to = os.environ["N2_REPORT_RECIPIENTS"].replace(" ", "").split(",")
subject = os.environ["N2_REPORT_SUBJECT"] subject = os.environ["N2_REPORT_SUBJECT"]
except KeyError as error: except KeyError as error:
logger.error(f"{error.args[0]} was not found in environment variable.") logger.error(f"{error.args[0]} was not found in environment variable.")
@@ -43,4 +43,4 @@ def send_n2_report(account: Account, summary: str, n2_log_file: Path) -> None:
logger.info(f"Attempt to send mail '{mail.subject}'...") logger.info(f"Attempt to send mail '{mail.subject}'...")
mail.send(copy_to_folder=sent_folder) mail.send(copy_to_folder=sent_folder)
logger.info(f"Mail '{subject}' was successfully sent to '{to}'.") logger.info(f"Mail '{subject}' was successfully sent to '{to}'.")
logger.info(f"Mail is saved in '{sent_folder}' folder.") logger.info(f"Mail is saved in '{sent_folder}' folder.")
+3 -3
View File
@@ -5,11 +5,11 @@ from exchangelib import Account, Folder
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def get_or_create_folder(account: Account, folder_name: str) -> Folder: def get_or_create_folder(account: Account, folder_name: str) -> Folder:
""" """Return the 'Analyzed' folder, creating it if it doesn't exist.
Return the 'Analyzed' folder, creating it if it doesn't exist.
Args: Args:
account: The mailbox account owning the Logs folder. account: The mailbox account owning the Logs folder
folder_name: Name of the account folder to find
Returns: Returns:
The existing or newly created 'Analyzed' folder. The existing or newly created 'Analyzed' folder.
+23 -15
View File
@@ -4,7 +4,7 @@ from pathlib import Path
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest import pytest
from exchangelib import Message from exchangelib import Account, Message
from logwatcher.config import FIXTURE_PATH from logwatcher.config import FIXTURE_PATH
from logwatcher.logging_config import setup_logging from logwatcher.logging_config import setup_logging
@@ -23,7 +23,8 @@ os.environ["PYTEST_DEBUG_TEMPROOT"] = str(Path("output/test").absolute())
@pytest.fixture @pytest.fixture
def tmp_log_file(tmp_path): def tmp_log_file(tmp_path):
return tmp_path / "logwatcher_test.log" """Return path of logs logwatcher related."""
return tmp_path / "logwatcher_test.log"
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def tmp_setup_logging(tmp_log_file): def tmp_setup_logging(tmp_log_file):
@@ -32,39 +33,46 @@ def tmp_setup_logging(tmp_log_file):
@pytest.fixture(name="original_log_dir") @pytest.fixture(name="original_log_dir")
def original_log_dir_fixture() -> Path: def original_log_dir_fixture() -> Path:
""" """Return log directory containing real logs.
All real log files provided by N2 technicians.
Used for integration tests: these files contain both valid lines All real log files provided by N2 technicians.
and lines to be ignored (system logs, empty lines, directory headers). Used for integration tests: these files contain both valid lines
and lines to be ignored (system logs, empty lines, directory headers).
""" """
return FIXTURE_PATH / "original_logs" return FIXTURE_PATH / "original_logs"
@pytest.fixture(name="valid_log_dir") @pytest.fixture(name="valid_log_dir")
def valid_log_dir_fixture() -> Path: def valid_log_dir_fixture() -> Path:
""" """Return log directory containing only valid logs.
All valid log files provided by N2 technicians.
Used for integration tests: these files contain both relevant and irrelevant logs All valid log files provided by N2 technicians.
for N2 technicians. Used for integration tests: these files contain both relevant and irrelevant logs
for N2 technicians.
""" """
return FIXTURE_PATH / "valid_logs" return FIXTURE_PATH / "valid_logs"
@pytest.fixture(name="invalid_log_dir") @pytest.fixture(name="invalid_log_dir")
def invalid_log_dir_fixture() -> Path: def invalid_log_dir_fixture() -> Path:
""" """Return log directory containing invalid logs.
All invalid log files created for testing only.
Used for integration tests of parsing. All invalid log files created for testing only.
Used for integration tests of parsing.
""" """
return FIXTURE_PATH / "invalid_logs" return FIXTURE_PATH / "invalid_logs"
@pytest.fixture(scope="package") @pytest.fixture(scope="package")
def make_mock_account(): def make_mock_account():
"""A fake Account with a msg_folder_root returning a fake Logs folder.""" """Create a fake Account.
A fake Account with a msg_folder_root returning a fake Logs folder.
"""
def _make_mock_account( def _make_mock_account(
messages: list[Message] | None = None, analyzed_folder: MagicMock | None = None messages: list[Message] | None = None, analyzed_folder: MagicMock | None = None
): ):
account = MagicMock() account = MagicMock(spec=Account)
account.fullname = "fake"
logs_folder = account.msg_folder_root.__truediv__.return_value logs_folder = account.msg_folder_root.__truediv__.return_value
logs_folder.all.return_value = messages or [] logs_folder.all.return_value = messages or []
logs_folder.glob.return_value = [analyzed_folder] if analyzed_folder else [] logs_folder.glob.return_value = [analyzed_folder] if analyzed_folder else []
+8 -11
View File
@@ -24,13 +24,13 @@ from logwatcher.mail_reader import (
@pytest.fixture(name="empty_mail") @pytest.fixture(name="empty_mail")
def get_empty_mail_fixture() -> Message: def get_empty_mail_fixture() -> Message:
"""A fake Message""" """Return a fake Message."""
return Message(subject="test without attachment", text_body="test") return Message(subject="test without attachment", text_body="test")
@pytest.fixture(name="log_mail_with_attachment") @pytest.fixture(name="log_mail_with_attachment")
def get_log_mail_with_attachment(original_log_dir: Path) -> Message: def get_log_mail_with_attachment(original_log_dir: Path) -> Message:
"""A fake Message with a .txt file attachment.""" """Return a fake Message with a .txt file attachment."""
file = original_log_dir / "CR_20260727110008.txt" file = original_log_dir / "CR_20260727110008.txt"
return Message( return Message(
@@ -49,7 +49,7 @@ def get_log_mail_with_attachment(original_log_dir: Path) -> Message:
@pytest.fixture(name="log_mail_with_body") @pytest.fixture(name="log_mail_with_body")
def get_log_mail_with_body() -> Message: def get_log_mail_with_body() -> Message:
"""A fake Message with logs in text body.""" """Return a fake Message with logs in text body."""
return Message( return Message(
subject="test with logs in text body", 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" text_body="Compte-rendu du suivi des imports NOSYMAG du 02/09/2026 à 15:00:10\n\n\n"
@@ -88,14 +88,14 @@ class FakeProtocol:
"""Protocol whose .version access raises UnauthorizedError.""" """Protocol whose .version access raises UnauthorizedError."""
@property @property
def version(self): def version(self): # noqa: D102
raise UnauthorizedError("invalid credentials") raise UnauthorizedError("invalid credentials")
class FakeAccount: class FakeAccount:
"""Account with a protocol that raises on version access.""" """Account with a protocol that raises on version access."""
def __init__(self): def __init__(self): # noqa: D107
self.protocol = FakeProtocol() self.protocol = FakeProtocol()
@@ -103,14 +103,14 @@ class OkProtocol:
"""Protocol whose .version succeeds.""" """Protocol whose .version succeeds."""
@property @property
def version(self): def version(self): # noqa: D102
return MagicMock() return MagicMock()
class OkAccount: class OkAccount:
"""Account with a protocol that returns a version successfully.""" """Account with a protocol that returns a version successfully."""
def __init__(self): def __init__(self): # noqa: D102
self.protocol = OkProtocol() self.protocol = OkProtocol()
@@ -154,10 +154,7 @@ def test_find_log_attachment_accepts_no_extension(empty_mail: Message):
def test_find_log_attachment_rejects_other_names( def test_find_log_attachment_rejects_other_names(
empty_mail: Message, empty_mail: Message,
): ):
""" """Rejects attachments not starting with CR_ even with .txt or .log extension or extensionless."""
Rejects attachments not starting with CR_ even with .txt
or .log extension or extensionless.
"""
attachments = ( attachments = (
FileAttachment( FileAttachment(
name="attachment.pdf", name="attachment.pdf",
+46
View File
@@ -0,0 +1,46 @@
import os
from unittest.mock import MagicMock
import pytest
from logwatcher.notifier import send_n2_report
def mock_get_or_create_folder(account, folder_name): # noqa: D103
return folder_name
@pytest.mark.parametrize("vars", [("N2_REPORT_SUBJECT", "N2_REPORT_RECIPIENTS"), ("N2_REPORT_RECIPIENTS", "N2_REPORT_SUBJECT")])
def test_send_n2_report_missing_recipients_env(vars, monkeypatch, ):
"""Assert that missing values in environmnent file raises KeyError."""
monkeypatch.delenv("N2_REPORT_RECIPIENTS")
monkeypatch.delenv("N2_REPORT_SUBJECT")
monkeypatch.setenv(vars[0], "test")
with pytest.raises(KeyError) as err:
send_n2_report(account=MagicMock(), summary="summary", n2_log_file=MagicMock())
assert vars[1] in err.value.args
@pytest.mark.parametrize("recipients", ["mail@client.fr", "mail@ client.fr ", "mail1@client.fr,mail2@client.fr,mail3@client.fr", "mail1@client.fr , mail2@client.fr , mail3@client.fr"])
def test_send_n2_report_multiple_recipients(recipients, make_mock_account, monkeypatch):
"""Assert gathering multiple account as recipients from env file is valid.
Tests with value having space characters.
Mails must be seperated by a comma.
Args:
recipients: e-mail recipients in one string
make_mock_account: return a fake account used for tests
monkeypatch: used to change environment variables and functions accross the script
"""
monkeypatch.setenv("N2_REPORT_SUBJECT", "test")
monkeypatch.setenv("N2_REPORT_RECIPIENTS", "mail@client.fr")
with monkeypatch.context() as m:
m.setattr("logwatcher.notifier.get_or_create_folder", mock_get_or_create_folder)
m.setattr("logwatcher.notifier.Message.send", MagicMock(id="1", return_value=None))
send_n2_report(account=make_mock_account(), summary="summary", n2_log_file=MagicMock())