Add pytest suite and enable tests in CI (#95)
Some checks are pending
Deploy / deploy (push) Waiting to run

The repo had no automated tests, so regressions in the email
parsing pipeline could only be caught manually. The reusable
ci-python-sam workflow already supports a run-tests input at the
pinned SHA; enable it and add a first suite covering the pure
functions pad_zip and parse_raw_email.

tests/conftest.py sets AWS_DEFAULT_REGION and dummy credentials
before any handler import because the handlers create boto3
clients at module import time, and loads each handler.py via
importlib under a unique module name since the files share a
basename. pytest.ini scopes discovery to tests/ so the manual
test_local.py script at the repo root is not collected.
This commit is contained in:
Adam Moussa 2026-07-15 19:06:20 -04:00 • committed by GitHub
parent 7b90f54815
commit 941a0ec0fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 187 additions and 1 deletions

View file

@ -7,6 +7,7 @@ jobs:
ci:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@fd60e4c9041784f666ac0fdefb9bec3c7fbf5143 # main
with:
source-dirs: "lambdas cdk"
source-dirs: "lambdas cdk tests"
run-tests: true
run-cdk-synth: true
run-sam-validate: false

5
pytest.ini Normal file
View file

@ -0,0 +1,5 @@
[pytest]
; Restrict discovery to tests/. test_local.py at the repo root matches the
; default test_*.py glob but is a manual script that imports a handler (and
; therefore creates boto3 clients) at collection time.
testpaths = tests

60
tests/conftest.py Normal file
View file

@ -0,0 +1,60 @@
"""Shared pytest configuration for the procurement-ingest test suite.
The Lambda handlers create boto3 clients at module import time, so a
region and dummy credentials must be present in the environment before
any handler module is imported. Setting them here at conftest import
time guarantees they exist before test collection touches a handler.
"""
import importlib.util
import os
import sys
from pathlib import Path
import pytest
os.environ.setdefault("AWS_DEFAULT_REGION", "us-east-1")
os.environ.setdefault("AWS_ACCESS_KEY_ID", "testing")
os.environ.setdefault("AWS_SECRET_ACCESS_KEY", "testing")
os.environ.setdefault("AWS_SESSION_TOKEN", "testing")
REPO_ROOT = Path(__file__).resolve().parents[1]
def load_handler(relative_path, module_name):
"""Load a Lambda handler module by file path under a unique name.
The handler files all share the basename ``handler.py`` and are not
importable as packages, so a plain ``import handler`` would collide
across Lambdas.
"""
path = REPO_ROOT / relative_path
spec = importlib.util.spec_from_file_location(module_name, path)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
@pytest.fixture(scope="session")
def po_handler():
"""The PO email processor handler module."""
return load_handler(
"lambdas/po/email_processor/handler.py",
"po_email_processor_handler",
)
@pytest.fixture(scope="session")
def wo_handler():
"""The WO email processor handler module."""
return load_handler(
"lambdas/wo/email_processor/handler.py",
"wo_email_processor_handler",
)
@pytest.fixture(params=["po_handler", "wo_handler"])
def email_handler(request):
"""Parametrized fixture yielding each email processor handler module."""
return request.getfixturevalue(request.param)

29
tests/test_pad_zip.py Normal file
View file

@ -0,0 +1,29 @@
"""Tests for pad_zip in the PO email processor."""
import pytest
@pytest.mark.parametrize(
("raw", "expected"),
[
# Missing values pass through untouched
(None, None),
("", ""),
# Leading zeros restored (Coupa drops them: "MA 2149" -> "02149")
("2149", "02149"),
("7001", "07001"),
("501", "00501"),
# Already-valid 5-digit zips are unchanged
("85704", "85704"),
("02149", "02149"),
# Zip+4: leading zeros restored on the base, suffix preserved
("2149-1234", "02149-1234"),
("85704-1234", "85704-1234"),
# Surrounding whitespace is handled
(" 2149 ", "02149"),
# Non-numeric values are left alone
("ABC12", "ABC12"),
],
)
def test_pad_zip(po_handler, raw, expected):
assert po_handler.pad_zip(raw) == expected

View file

@ -0,0 +1,91 @@
"""Tests for parse_raw_email in the PO and WO email processors.
Synthetic MIME messages are built with email.message.EmailMessage so the
tests exercise real multipart parsing without shipping .eml fixtures.
"""
from email.message import EmailMessage
SUBJECT = "Amazon.com Services LLC Purchase Order 2D-18206023"
SENDER = "do_not_reply@coupahost.com"
TO = "orders@seahavenind.com"
CC = "ap@seahavenind.com"
DATE = "Tue, 14 Jul 2026 09:30:00 -0400"
PLAIN_BODY = "PO 2D-18206023 issued to Sea Haven Industries."
HTML_BODY = "<html><body><p>PO 2D-18206023 issued.</p></body></html>"
def _base_message():
msg = EmailMessage()
msg["Subject"] = SUBJECT
msg["From"] = SENDER
msg["To"] = TO
msg["Cc"] = CC
msg["Date"] = DATE
return msg
def make_plain_email():
msg = _base_message()
msg.set_content(PLAIN_BODY)
return msg.as_bytes()
def make_multipart_alternative_email():
msg = _base_message()
msg.set_content(PLAIN_BODY)
msg.add_alternative(HTML_BODY, subtype="html")
return msg.as_bytes()
def make_multipart_html_only_email():
msg = _base_message()
msg.set_content(HTML_BODY, subtype="html")
# An attachment forces the message to multipart/mixed
msg.add_attachment(
b"%PDF-1.4 fake",
maintype="application",
subtype="pdf",
filename="po.pdf",
)
return msg.as_bytes()
def test_headers_extracted(email_handler):
result = email_handler.parse_raw_email(make_plain_email())
assert result["subject"] == SUBJECT
assert result["sender"] == SENDER
assert result["to"] == TO
assert result["date"] == DATE
def test_simple_plain_body(email_handler):
result = email_handler.parse_raw_email(make_plain_email())
assert result["body"].strip() == PLAIN_BODY
def test_multipart_prefers_text_plain(email_handler):
result = email_handler.parse_raw_email(make_multipart_alternative_email())
assert result["body"].strip() == PLAIN_BODY
assert "<html>" not in result["body"]
def test_multipart_falls_back_to_html(email_handler):
result = email_handler.parse_raw_email(make_multipart_html_only_email())
assert "PO 2D-18206023 issued." in result["body"]
assert "<html>" in result["body"]
def test_wo_handler_extracts_cc(wo_handler):
result = wo_handler.parse_raw_email(make_plain_email())
assert result["cc"] == CC
def test_missing_headers_default_to_empty(email_handler):
msg = EmailMessage()
msg.set_content(PLAIN_BODY)
result = email_handler.parse_raw_email(msg.as_bytes())
assert result["subject"] == ""
assert result["sender"] == ""
assert result["to"] == ""