"""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 ``/fixtures//*.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])}