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: `<test_suite>_<test_function>.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 <kevr@0cost.org>
This commit is contained in:
Kevin Morris 2021-11-22 15:00:22 -08:00
parent b72bd38f76
commit 9fb1fbe32c
No known key found for this signature in database
GPG key ID: F7E46DED420788F3
3 changed files with 162 additions and 1 deletions

120
aurweb/testing/email.py Normal file
View file

@ -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)

View file

@ -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 It is done this way because migration has a large cost; migrating
ahead of each function takes too long when compared to this method. ahead of each function takes too long when compared to this method.
""" """
import os
import pytest import pytest
from filelock import FileLock from filelock import FileLock
@ -50,6 +52,7 @@ import aurweb.config
import aurweb.db import aurweb.db
from aurweb import initdb, logging, testing from aurweb import initdb, logging, testing
from aurweb.testing.email import Email
logger = logging.get_logger(__name__) logger = logging.get_logger(__name__)
@ -120,6 +123,18 @@ def _drop_database(engine: Engine, dbname: str) -> None:
conn.close() 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") @pytest.fixture(scope="module")
def setup_database(tmp_path_factory: pytest.fixture, def setup_database(tmp_path_factory: pytest.fixture,
worker_id: pytest.fixture) -> None: 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 worker_id == "master": # pragma: no cover
# If we're not running tests through multiproc pytest-xdist. # If we're not running tests through multiproc pytest-xdist.
setup_email()
yield _create_database(engine, dbname) yield _create_database(engine, dbname)
_drop_database(engine, dbname) _drop_database(engine, dbname)
return return
@ -143,12 +159,13 @@ def setup_database(tmp_path_factory: pytest.fixture,
else: else:
# Otherwise, create the data file and create the database. # Otherwise, create the data file and create the database.
fn.write_text("1") fn.write_text("1")
setup_email()
yield _create_database(engine, dbname) yield _create_database(engine, dbname)
_drop_database(engine, dbname) _drop_database(engine, dbname)
@pytest.fixture(scope="module") @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(). 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. # configured database, because PYTEST_CURRENT_TEST is removed.
dbname = aurweb.db.name() dbname = aurweb.db.name()
session = aurweb.db.get_session() session = aurweb.db.get_session()
yield session yield session
# Close the session and pop it. # Close the session and pop it.

View file

@ -1,2 +1,25 @@
#!/bin/bash #!/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 exit 0