From 9fb1fbe32cd2d7652f4dee9df29002a36b9ff38c Mon Sep 17 00:00:00 2001 From: Kevin Morris Date: Mon, 22 Nov 2021 15:00:22 -0800 Subject: [PATCH] feat(testing): add email testing utilities Changes: - util/sendmail now populates email files in the 'test-emails' directory. - util/sendmail does this in a serialized fashion based off of the test suite and name retrieved from PYTEST_CURRENT_TEST in the format: `_.n.txt` where n is increased by one every time sendmail is run. - pytest conftest fixtures have been added for test email setup; it wipes out old emails for the particular test function being run. - New aurweb.testing.email.Email class allows developers to test against emails stored by util/sendmail. Simple pass the serial you want to test against, starting at serial = 1; e.g. Email(serial). Signed-off-by: Kevin Morris --- aurweb/testing/email.py | 120 ++++++++++++++++++++++++++++++++++++++++ test/conftest.py | 20 ++++++- util/sendmail | 23 ++++++++ 3 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 aurweb/testing/email.py diff --git a/aurweb/testing/email.py b/aurweb/testing/email.py new file mode 100644 index 00000000..6ff9df99 --- /dev/null +++ b/aurweb/testing/email.py @@ -0,0 +1,120 @@ +import base64 +import copy +import email +import os +import re + + +class Email: + """ + An email class used for testing. + + This class targets a specific serial of emails for PYTEST_CURRENT_TEST. + As emails are sent out with util/sendmail, the serial number increases, + starting at 1. + + Email content sent out by aurweb is always base64-encoded. Email.parse() + decodes that for us and puts it into Email.body. + + Example: + + # Get the {test_suite}_{test_function}.1.txt email. + email = Email(1).parse() + print(email.body) + print(email.headers) + + """ + TEST_DIR = "test-emails" + + def __init__(self, serial: int = 1): + self.serial = serial + self.content = self._get() + + @staticmethod + def email_prefix(suite: bool = False) -> str: + """ + Get the email prefix. + + We find the email prefix by reducing PYTEST_CURRENT_TEST to + either {test_suite}_{test_function}. If `suite` is set, we + reduce it to {test_suite} only. + + :param suite: Reduce PYTEST_CURRENT_TEST to {test_suite} + :return: Email prefix with '/', '.', ',', and ':' chars replaced by '_' + """ + value = os.environ.get("PYTEST_CURRENT_TEST", "email").split(" ")[0] + if suite: + value = value.split(":")[0] + return re.sub(r'(\/|\.|,|:)', "_", value) + + @staticmethod + def count() -> int: + """ + Count the current number of emails sent from the test. + + This function is **only** supported inside of pytest functions. + Do not use it elsewhere as data races will occur. + + :return: Number of emails sent by the current test + """ + files = os.listdir(Email.TEST_DIR) + prefix = Email.email_prefix() + expr = "^" + prefix + r"\.\d+\.txt$" + subset = filter(lambda e: re.match(expr, e), files) + return len(list(subset)) + + def _email_path(self) -> str: + filename = self.email_prefix() + f".{self.serial}.txt" + return os.path.join(Email.TEST_DIR, filename) + + def _get(self) -> str: + """ + Get this email's content by reading its file. + + :return: Email content + """ + path = self._email_path() + with open(path) as f: + return f.read() + + def parse(self) -> "Email": + """ + Parse this email and base64-decode the body. + + This function populates Email.message, Email.headers and Email.body. + + Additionally, after parsing, we write over our email file with + self.glue()'d content (base64-decoded). This is done for ease + of inspection by users. + + :return: self + """ + self.message = email.message_from_string(self.content) + self.headers = dict(self.message) + + # aurweb email notifications always have base64 encoded content. + # Decode it here so self.body is human readable. + self.body = base64.b64decode(self.message.get_payload()).decode() + + path = self._email_path() + with open(path, "w") as f: + f.write(self.glue()) + + return self + + def glue(self) -> str: + """ + Glue parsed content back into a complete email document, but + base64-decoded this time. + + :return: Email document as a string + """ + headers = copy.copy(self.headers) + del headers["Content-Transfer-Encoding"] + + output = [] + for k, v in headers.items(): + output.append(f"{k}: {v}") + output.append("") + output.append(self.body) + return "\n".join(output) diff --git a/test/conftest.py b/test/conftest.py index db2e5997..01131109 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -37,6 +37,8 @@ then clears the database for each test function run in that module. It is done this way because migration has a large cost; migrating ahead of each function takes too long when compared to this method. """ +import os + import pytest from filelock import FileLock @@ -50,6 +52,7 @@ import aurweb.config import aurweb.db from aurweb import initdb, logging, testing +from aurweb.testing.email import Email logger = logging.get_logger(__name__) @@ -120,6 +123,18 @@ def _drop_database(engine: Engine, dbname: str) -> None: conn.close() +def setup_email(): + if not os.path.exists(Email.TEST_DIR): + os.makedirs(Email.TEST_DIR) + + # Cleanup all email files for this test suite. + prefix = Email.email_prefix(suite=True) + files = os.listdir(Email.TEST_DIR) + for file in files: + if file.startswith(prefix): + os.remove(os.path.join(Email.TEST_DIR, file)) + + @pytest.fixture(scope="module") def setup_database(tmp_path_factory: pytest.fixture, worker_id: pytest.fixture) -> None: @@ -129,6 +144,7 @@ def setup_database(tmp_path_factory: pytest.fixture, if worker_id == "master": # pragma: no cover # If we're not running tests through multiproc pytest-xdist. + setup_email() yield _create_database(engine, dbname) _drop_database(engine, dbname) return @@ -143,12 +159,13 @@ def setup_database(tmp_path_factory: pytest.fixture, else: # Otherwise, create the data file and create the database. fn.write_text("1") + setup_email() yield _create_database(engine, dbname) _drop_database(engine, dbname) @pytest.fixture(scope="module") -def db_session(setup_database: pytest.fixture) -> scoped_session: +def db_session(setup_database: None) -> scoped_session: """ Yield a database session based on aurweb.db.name(). @@ -158,6 +175,7 @@ def db_session(setup_database: pytest.fixture) -> scoped_session: # configured database, because PYTEST_CURRENT_TEST is removed. dbname = aurweb.db.name() session = aurweb.db.get_session() + yield session # Close the session and pop it. diff --git a/util/sendmail b/util/sendmail index 06bd9865..9356851a 100755 --- a/util/sendmail +++ b/util/sendmail @@ -1,2 +1,25 @@ #!/bin/bash +# Send email to temporary filesystem for tests. +dir='test-emails' +filename='email.txt' +if [ ! -z ${PYTEST_CURRENT_TEST+x} ]; then + filename="$(echo $PYTEST_CURRENT_TEST | cut -d ' ' -f 1 | sed -r 's/(\/|\.|,|:)/_/g')" +fi +mkdir -p "$dir" + +path="${dir}/${filename}" +serial_file="${path}.serial" +if [ ! -f $serial_file ]; then + echo 0 > $serial_file +fi + +# Increment and update $serial_file. +serial=$(($(cat $serial_file) + 1)) +echo $serial > $serial_file + +# Use the serial we're on to mark the email file. +# Emails have the format: PYTEST_CURRENT_TEST.s.txt +# where s is the current serial for PYTEST_CURRENT_TEST. +cat > "${path}.${serial}.txt" + exit 0