mirror of
https://github.com/Sea-Haven-Industries/procurement-ingest.git
synced 2026-09-30 17:43:14 +00:00
123 lines
4.2 KiB
Python
123 lines
4.2 KiB
Python
|
|
"""Shared offline test helpers for the procurement-ingest suite.
|
||
|
|
|
||
|
|
A real package (``tests.support``) resolved as a PEP-420 namespace member via
|
||
|
|
the repo-root conftest's sys.path insert. ``tests/`` itself gets NO __init__.py
|
||
|
|
(adding it would change pytest's module naming for the root suite).
|
||
|
|
|
||
|
|
This module imports NO moto, mutates NO environment, and imports NO handler at
|
||
|
|
module level -- the moto-before-handler ordering authority stays solely with the
|
||
|
|
repo-root conftest. The loader (which does exec handlers) is only invoked lazily
|
||
|
|
by ``load_email`` / by the pipeline shims, never at ``__init__`` import time.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import io
|
||
|
|
import json
|
||
|
|
from decimal import Decimal
|
||
|
|
|
||
|
|
from tests.support.loader import REPO_ROOT, load_lambda_module
|
||
|
|
|
||
|
|
__all__ = [
|
||
|
|
"REPO_ROOT",
|
||
|
|
"load_lambda_module",
|
||
|
|
"FIXTURE_DIRS",
|
||
|
|
"stems",
|
||
|
|
"load_raw",
|
||
|
|
"load_email",
|
||
|
|
"load_golden",
|
||
|
|
"FakeTable",
|
||
|
|
"FakeDynamoResource",
|
||
|
|
"FakeS3",
|
||
|
|
]
|
||
|
|
|
||
|
|
FIXTURE_DIRS = {
|
||
|
|
"po": REPO_ROOT / "lambdas/po/email_processor/tests/fixtures",
|
||
|
|
"wo": REPO_ROOT / "lambdas/wo/email_processor/tests/fixtures",
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def stems(pipeline, subdir):
|
||
|
|
"""Sorted list of fixture stems in ``<pipeline>/fixtures/<subdir>/*.eml``."""
|
||
|
|
return sorted(p.stem for p in (FIXTURE_DIRS[pipeline] / subdir).glob("*.eml"))
|
||
|
|
|
||
|
|
|
||
|
|
def load_raw(pipeline, subdir, stem):
|
||
|
|
"""Raw bytes of a fixture .eml."""
|
||
|
|
return (FIXTURE_DIRS[pipeline] / subdir / f"{stem}.eml").read_bytes()
|
||
|
|
|
||
|
|
|
||
|
|
def load_email(pipeline, subdir, stem):
|
||
|
|
"""Parse a fixture .eml into the email_data dict the parser consumes.
|
||
|
|
|
||
|
|
Uses the Phase-3 single-sourced ``email_parsing.parse_raw_email`` that BOTH
|
||
|
|
handlers re-export -- semantics identical to the old
|
||
|
|
``po_handler.parse_raw_email`` / bare ``from handler import parse_raw_email``
|
||
|
|
paths, now with one implementation.
|
||
|
|
"""
|
||
|
|
email_parsing = load_lambda_module("shared", "email_parsing")
|
||
|
|
return email_parsing.parse_raw_email(load_raw(pipeline, subdir, stem))
|
||
|
|
|
||
|
|
|
||
|
|
def load_golden(pipeline, stem):
|
||
|
|
"""Load the expected parser output for a positive fixture.
|
||
|
|
|
||
|
|
Goldens store money as JSON numbers; ``parse_float=Decimal`` mirrors the
|
||
|
|
handler's Bedrock decode and makes every golden money value compare exactly
|
||
|
|
equal to the parser's Decimal output (2-dp values at PO magnitudes round-trip
|
||
|
|
float repr losslessly). Applied UNCONDITIONALLY: WO goldens are integer-only
|
||
|
|
at the JSON-number level (verified: zero float-typed values across all 55 WO
|
||
|
|
goldens; decimal-looking strings occur only inside comment_text STRING
|
||
|
|
values, which parse_float never touches), so WO callers gain Decimal decoding
|
||
|
|
with no observable change.
|
||
|
|
"""
|
||
|
|
path = FIXTURE_DIRS[pipeline] / "expected" / f"{stem}.json"
|
||
|
|
with open(path) as fh:
|
||
|
|
return json.load(fh, parse_float=Decimal)
|
||
|
|
|
||
|
|
|
||
|
|
class FakeTable:
|
||
|
|
"""Superset offline DynamoDB table -- union of the PO and WO fakes.
|
||
|
|
|
||
|
|
PO callers only ever ``update_item`` (their ``puts``/``store`` stay
|
||
|
|
permanently empty); WO callers use ``put_item`` (recorded in ``puts`` and in
|
||
|
|
the keyed single-row ``store``) AND ``update_item``. Neither suite regresses:
|
||
|
|
each sees exactly the recorder it used before.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def __init__(self, name):
|
||
|
|
self.name = name
|
||
|
|
self.puts = []
|
||
|
|
self.updates = []
|
||
|
|
# Single-row-per-key store (WO): detects overwrite vs distinct rows.
|
||
|
|
self.store = {}
|
||
|
|
|
||
|
|
def put_item(self, Item=None, **kwargs): # noqa: N803 (boto3 kwarg name)
|
||
|
|
self.puts.append(Item)
|
||
|
|
key = (Item.get("work_order_id"), Item.get("comment_id"))
|
||
|
|
self.store[key] = Item
|
||
|
|
|
||
|
|
def update_item(self, **kwargs):
|
||
|
|
self.updates.append(kwargs)
|
||
|
|
|
||
|
|
|
||
|
|
class FakeDynamoResource:
|
||
|
|
def __init__(self):
|
||
|
|
self.tables = {}
|
||
|
|
|
||
|
|
def Table(self, name): # noqa: N802 (boto3 method name)
|
||
|
|
return self.tables.setdefault(name, FakeTable(name))
|
||
|
|
|
||
|
|
|
||
|
|
class FakeS3:
|
||
|
|
"""Offline S3 client for the handler-level seam tests.
|
||
|
|
|
||
|
|
The sender-auth gate sits AFTER ``get_object`` in both handlers, so a fake
|
||
|
|
S3 is mandatory to reach (and exercise) the gate. ``objects`` maps the S3
|
||
|
|
object Key to its raw bytes.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def __init__(self, objects):
|
||
|
|
self.objects = dict(objects)
|
||
|
|
|
||
|
|
def get_object(self, Bucket, Key): # noqa: N803 (boto3 kwarg names)
|
||
|
|
return {"Body": io.BytesIO(self.objects[Key])}
|