48503cdd42
remove also comments in `test_mail_reader.py` that didn't follow package conventions
273 lines
9.8 KiB
Python
273 lines
9.8 KiB
Python
import datetime
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock
|
|
from zipfile import ZipFile
|
|
|
|
import pytest
|
|
from exchangelib import Account, Message
|
|
|
|
from logwatcher.cleaner import (
|
|
_get_retention_days,
|
|
archive_logs,
|
|
get_logs_to_archive,
|
|
prune_logs,
|
|
prune_mails,
|
|
)
|
|
from logwatcher.config import DATE_FILE_FORMAT, FRENCH_TIMEZONE
|
|
from logwatcher.utils import ANALYZED_FOLDER, SENT_FOLDER
|
|
|
|
# fixed date so retention cut-offs do not depend on the machine clock
|
|
TODAY = datetime.datetime(2026, 9, 25, 12, 0, 0, tzinfo=FRENCH_TIMEZONE)
|
|
|
|
|
|
@pytest.fixture(name="log_dir")
|
|
def log_dir_fixture(tmp_path: Path, monkeypatch) -> Path:
|
|
"""Redirect `LOGGING_PATH` to a temporary log directory.
|
|
|
|
Args:
|
|
tmp_path: Temporary root path for testing results
|
|
monkeypatch: MonkeyPatch used to fake paths
|
|
|
|
Returns:
|
|
the temporary log directory, created on disk
|
|
|
|
"""
|
|
logs_dir = tmp_path / "logs"
|
|
logs_dir.mkdir()
|
|
monkeypatch.setattr("logwatcher.cleaner.LOGGING_PATH", logs_dir)
|
|
return logs_dir
|
|
|
|
|
|
@pytest.fixture(name="archive_dir")
|
|
def archive_dir_fixture(tmp_path: Path, monkeypatch) -> Path:
|
|
"""Redirect `ARCHIVE_DIR` to a temporary directory.
|
|
|
|
The directory is left uncreated so tests can also check the missing directory case.
|
|
|
|
Args:
|
|
tmp_path: Temporary root path for testing results
|
|
monkeypatch: MonkeyPatch used to fake paths
|
|
|
|
Returns:
|
|
the temporary archive directory path, not created on disk
|
|
|
|
"""
|
|
archives_dir = tmp_path / "archives"
|
|
monkeypatch.setattr("logwatcher.cleaner.ARCHIVE_DIR", archives_dir)
|
|
return archives_dir
|
|
|
|
|
|
def _create_file(path: Path, age_in_days: int | None = None) -> Path:
|
|
"""Create a file and optionally backdate its `st_mtime`.
|
|
|
|
Args:
|
|
path: path of the file to create
|
|
age_in_days: number of days between `TODAY` and the file modification time.
|
|
When None, the modification time is left untouched.
|
|
|
|
Returns:
|
|
path of the created file
|
|
|
|
"""
|
|
path.write_text("log content", encoding="utf-8")
|
|
if age_in_days is not None:
|
|
timestamp = (TODAY - datetime.timedelta(days=age_in_days)).timestamp()
|
|
os.utime(path, (timestamp, timestamp))
|
|
return path
|
|
|
|
|
|
def test_get_retention_days_returns_integer(monkeypatch):
|
|
"""Return the number of days found in the environment variable."""
|
|
monkeypatch.setenv("RETENTION_DAYS_TEST", "42")
|
|
|
|
assert _get_retention_days("RETENTION_DAYS_TEST") == 42
|
|
|
|
|
|
def test_get_retention_days_missing_variable(monkeypatch, caplog):
|
|
"""Raise KeyError and log an error when the variable is not defined."""
|
|
monkeypatch.delenv("RETENTION_DAYS_TEST", raising=False)
|
|
|
|
with caplog.at_level(logging.ERROR, logger="logwatcher"):
|
|
with pytest.raises(KeyError):
|
|
_get_retention_days("RETENTION_DAYS_TEST")
|
|
|
|
assert (
|
|
"'RETENTION_DAYS_TEST' is not defined in environment variables."
|
|
in caplog.messages
|
|
)
|
|
|
|
|
|
def test_get_retention_days_not_an_integer(monkeypatch, caplog):
|
|
"""Raise ValueError and log an error when the variable is not a number."""
|
|
monkeypatch.setenv("RETENTION_DAYS_TEST", "thirty")
|
|
|
|
with caplog.at_level(logging.ERROR, logger="logwatcher"):
|
|
with pytest.raises(ValueError):
|
|
_get_retention_days("RETENTION_DAYS_TEST")
|
|
|
|
assert "thirty must be an integer." in caplog.messages
|
|
|
|
|
|
def test_get_logs_to_archive_selects_old_logs_only(log_dir: Path, monkeypatch):
|
|
"""Return only the log files older than `LOG_RETENTION_DAYS`."""
|
|
monkeypatch.setenv("LOG_RETENTION_DAYS", "30")
|
|
old_log = _create_file(log_dir / "old.log", age_in_days=40)
|
|
_create_file(log_dir / "recent.log", age_in_days=1)
|
|
|
|
assert get_logs_to_archive(TODAY) == [old_log]
|
|
|
|
|
|
def test_get_logs_to_archive_ignores_everything_but_logs(log_dir: Path, monkeypatch):
|
|
"""Ignore archives, foreign extensions and directories."""
|
|
monkeypatch.setenv("LOG_RETENTION_DAYS", "30")
|
|
(log_dir / "archives").mkdir()
|
|
_create_file(log_dir / "old.zip", age_in_days=40)
|
|
_create_file(log_dir / "old.csv", age_in_days=40)
|
|
expected_log = _create_file(log_dir / "old.txt", age_in_days=40)
|
|
|
|
assert get_logs_to_archive(TODAY) == [expected_log]
|
|
|
|
|
|
def test_get_logs_to_archive_warns_when_nothing_is_old_enough(
|
|
log_dir: Path, monkeypatch, caplog
|
|
):
|
|
"""Warn and return an empty list when no log reached the retention duration."""
|
|
monkeypatch.setenv("LOG_RETENTION_DAYS", "30")
|
|
_create_file(log_dir / "recent.log", age_in_days=1)
|
|
|
|
with caplog.at_level(logging.WARNING, logger="logwatcher"):
|
|
logs = get_logs_to_archive(TODAY)
|
|
|
|
assert logs == []
|
|
assert "No log files old enough to be archived." in caplog.messages
|
|
|
|
|
|
def test_archive_logs_creates_zip_and_deletes_originals(
|
|
log_dir: Path, archive_dir: Path
|
|
):
|
|
"""Zip the given logs in `ARCHIVE_DIR` then delete them from the log directory."""
|
|
first_log = _create_file(log_dir / "first.log")
|
|
second_log = _create_file(log_dir / "second.log")
|
|
|
|
archive_logs(TODAY, [first_log, second_log])
|
|
|
|
archived_files = list(archive_dir.iterdir())
|
|
assert len(archived_files) == 1
|
|
assert archived_files[0].name == f"archive_{TODAY.strftime(DATE_FILE_FORMAT)}.zip"
|
|
with ZipFile(archived_files[0]) as archive_file:
|
|
assert sorted(archive_file.namelist()) == ["first.log", "second.log"]
|
|
assert not first_log.exists()
|
|
assert not second_log.exists()
|
|
|
|
|
|
def test_archive_logs_does_nothing_without_file(
|
|
log_dir: Path, archive_dir: Path, caplog
|
|
):
|
|
"""Do not create the archive directory when there is no log to archive."""
|
|
with caplog.at_level(logging.INFO, logger="logwatcher"):
|
|
archive_logs(TODAY, [])
|
|
|
|
assert not archive_dir.exists()
|
|
assert "No file to archive. Skip." in caplog.messages
|
|
|
|
|
|
def test_prune_logs_deletes_old_archives_only(archive_dir: Path, monkeypatch):
|
|
"""Delete archives older than `ARCHIVED_LOG_RETENTION_DAYS` and keep recent ones."""
|
|
monkeypatch.setenv("ARCHIVED_LOG_RETENTION_DAYS", "60")
|
|
archive_dir.mkdir()
|
|
old_archive = _create_file(archive_dir / "archive_old.zip", age_in_days=90)
|
|
recent_archive = _create_file(archive_dir / "archive_recent.zip", age_in_days=1)
|
|
|
|
prune_logs(TODAY)
|
|
|
|
assert not old_archive.exists()
|
|
assert recent_archive.exists()
|
|
|
|
|
|
def test_prune_logs_creates_missing_directory(archive_dir: Path, monkeypatch, caplog):
|
|
"""Create the archive directory then warn when it holds no archive."""
|
|
monkeypatch.setenv("ARCHIVED_LOG_RETENTION_DAYS", "60")
|
|
|
|
with caplog.at_level(logging.WARNING, logger="logwatcher"):
|
|
prune_logs(TODAY)
|
|
|
|
assert archive_dir.is_dir()
|
|
assert "No archive found." in caplog.messages
|
|
|
|
|
|
def test_prune_logs_warns_on_empty_directory(archive_dir: Path, monkeypatch, caplog):
|
|
"""Warn when the archive directory exists but holds no archive."""
|
|
monkeypatch.setenv("ARCHIVED_LOG_RETENTION_DAYS", "60")
|
|
archive_dir.mkdir()
|
|
|
|
with caplog.at_level(logging.WARNING, logger="logwatcher"):
|
|
prune_logs(TODAY)
|
|
|
|
assert "No archive found." in caplog.messages
|
|
|
|
|
|
@pytest.fixture(name="mails_account")
|
|
def mails_account_fixture() -> tuple[MagicMock, MagicMock, MagicMock]:
|
|
"""Return a fake account exposing distinct 'Analyzed' and 'Sent' folders.
|
|
|
|
`account.msg_folder_root / LOG_FOLDER / <folder>` resolves to one mock per
|
|
folder, so each folder can be asserted independently.
|
|
|
|
Returns:
|
|
the fake account, the 'Analyzed' folder mock and the 'Sent' folder mock
|
|
|
|
"""
|
|
account = MagicMock(spec=Account)
|
|
folders = {ANALYZED_FOLDER: MagicMock(), SENT_FOLDER: MagicMock()}
|
|
logs_folder = account.msg_folder_root.__truediv__.return_value
|
|
logs_folder.__truediv__.side_effect = lambda folder_name: folders[folder_name]
|
|
return account, folders[ANALYZED_FOLDER], folders[SENT_FOLDER]
|
|
|
|
|
|
def test_prune_mails_filters_with_retention_cutoff(mails_account, monkeypatch):
|
|
"""Filter both mail folders with `MAIL_RETENTION_DAYS` applied to the current day."""
|
|
account, analyzed_folder, sent_folder = mails_account
|
|
monkeypatch.setenv("MAIL_RETENTION_DAYS", "30")
|
|
analyzed_folder.filter.return_value = []
|
|
sent_folder.filter.return_value = []
|
|
expected_cutoff = TODAY - datetime.timedelta(days=30)
|
|
|
|
prune_mails(TODAY, account)
|
|
|
|
analyzed_folder.filter.assert_called_once_with(datetime_sent__lt=expected_cutoff)
|
|
sent_folder.filter.assert_called_once_with(datetime_sent__lt=expected_cutoff)
|
|
|
|
|
|
def test_prune_mails_deletes_old_mails(mails_account, monkeypatch):
|
|
"""Delete every old mail of both folders with a single hard delete."""
|
|
account, analyzed_folder, sent_folder = mails_account
|
|
monkeypatch.setenv("MAIL_RETENTION_DAYS", "30")
|
|
analyzed_mail = Message(subject="analyzed", text_body="analyzed")
|
|
analyzed_mail.id, analyzed_mail.changekey = "id1", "ck1"
|
|
sent_mail = Message(subject="sent", text_body="sent")
|
|
sent_mail.id, sent_mail.changekey = "id2", "ck2"
|
|
analyzed_folder.filter.return_value = [analyzed_mail]
|
|
sent_folder.filter.return_value = [sent_mail]
|
|
|
|
prune_mails(TODAY, account)
|
|
|
|
account.bulk_delete.assert_called_once_with(ids=[("id1", "ck1"), ("id2", "ck2")])
|
|
# `bulk_delete` must stay a hard delete: no `delete_type` nor `to_folder` argument
|
|
assert set(account.bulk_delete.call_args.kwargs) == {"ids"}
|
|
|
|
|
|
def test_prune_mails_warns_when_no_old_mail(mails_account, monkeypatch, caplog):
|
|
"""Warn and delete nothing when both folders only hold recent mails."""
|
|
account, analyzed_folder, sent_folder = mails_account
|
|
monkeypatch.setenv("MAIL_RETENTION_DAYS", "30")
|
|
analyzed_folder.filter.return_value = []
|
|
sent_folder.filter.return_value = []
|
|
|
|
with caplog.at_level(logging.WARNING, logger="logwatcher"):
|
|
prune_mails(TODAY, account)
|
|
|
|
assert "No mail older than '30' days found." in caplog.messages
|
|
account.bulk_delete.assert_not_called()
|