mirror of
https://github.com/Sea-Haven-Industries/procurement-ingest.git
synced 2026-09-30 07:13:13 +00:00
Add pytest suite and enable tests in CI (#95)
Some checks are pending
Deploy / deploy (push) Waiting to run
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:
parent
7b90f54815
commit
941a0ec0fe
5 changed files with 187 additions and 1 deletions
3
.github/workflows/ci.yaml
vendored
3
.github/workflows/ci.yaml
vendored
|
|
@ -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
5
pytest.ini
Normal 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
60
tests/conftest.py
Normal 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
29
tests/test_pad_zip.py
Normal 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
|
||||
91
tests/test_parse_raw_email.py
Normal file
91
tests/test_parse_raw_email.py
Normal 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"] == ""
|
||||
Loading…
Add table
Reference in a new issue