From 941a0ec0feac7cb64d258d1bbcc7afd656a58a17 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:06:20 -0400 Subject: [PATCH] Add pytest suite and enable tests in CI (#95) 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. --- .github/workflows/ci.yaml | 3 +- pytest.ini | 5 ++ tests/conftest.py | 60 +++++++++++++++++++++++ tests/test_pad_zip.py | 29 +++++++++++ tests/test_parse_raw_email.py | 91 +++++++++++++++++++++++++++++++++++ 5 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 pytest.ini create mode 100644 tests/conftest.py create mode 100644 tests/test_pad_zip.py create mode 100644 tests/test_parse_raw_email.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a282a19..f517699 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -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 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..2c22d60 --- /dev/null +++ b/pytest.ini @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8bb84e3 --- /dev/null +++ b/tests/conftest.py @@ -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) diff --git a/tests/test_pad_zip.py b/tests/test_pad_zip.py new file mode 100644 index 0000000..3fca35b --- /dev/null +++ b/tests/test_pad_zip.py @@ -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 diff --git a/tests/test_parse_raw_email.py b/tests/test_parse_raw_email.py new file mode 100644 index 0000000..02aa704 --- /dev/null +++ b/tests/test_parse_raw_email.py @@ -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 = "

PO 2D-18206023 issued.

" + + +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 "" 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 "" 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"] == ""