mirror of
https://github.com/Sea-Haven-Industries/procurement-ingest.git
synced 2026-10-07 16:08:54 +00:00
Cover the deterministic parser with golden-file tests over 55 real scrubbed .eml fixtures (both comment sub-shapes, username Submitted-By, address present/absent, br+CRLF assign addresses), fail-closed validation-gate rules, adversarial and prompt-injection cases that must route to ai_fallback or parse without corrupting other fields, the issue #23 comment_id idempotency invariants, and the Bedrock-fallback dispatch plus EMF-metric emission with a mocked invoke_model. Extend pytest.ini testpaths to discover the co-located suite, and update tests/conftest.load_handler to put a handler's own directory on sys.path so the WO handler's new `from template_parser import ...` resolves under the existing shared handler tests. Point test_local.py at the new template-first + Bedrock flow. Refs: #23
172 lines
5 KiB
Python
172 lines
5 KiB
Python
"""End-to-end dispatch tests: the Bedrock AI extractor is invoked ONLY when the
|
|
deterministic parse misses, EMF metrics are emitted on both paths, and the
|
|
Bedrock response is decoded through the same contract."""
|
|
|
|
import json
|
|
import os
|
|
|
|
import pytest
|
|
|
|
import handler
|
|
from _wo_parser_support import FIXTURES
|
|
|
|
|
|
def _raw(subdir, stem):
|
|
with open(os.path.join(FIXTURES, subdir, f"{stem}.eml"), "rb") as fh:
|
|
return fh.read()
|
|
|
|
|
|
class FakeBody:
|
|
def __init__(self, data):
|
|
self._data = data
|
|
|
|
def read(self):
|
|
return self._data
|
|
|
|
|
|
class FakeS3:
|
|
def __init__(self, raw):
|
|
self._raw = raw
|
|
|
|
def get_object(self, Bucket, Key): # noqa: N803
|
|
return {"Body": FakeBody(self._raw)}
|
|
|
|
|
|
class FakeBedrock:
|
|
def __init__(self, payload):
|
|
self.calls = []
|
|
self._payload = payload
|
|
|
|
def invoke_model(self, modelId, body): # noqa: N803
|
|
self.calls.append({"modelId": modelId, "body": body})
|
|
text = json.dumps(self._payload)
|
|
return {"body": FakeBody(json.dumps({"content": [{"text": text}]}).encode())}
|
|
|
|
|
|
AI_17_KEY = {
|
|
"email_type": "comment",
|
|
"work_order_id": "77777777777",
|
|
"description": None,
|
|
"status": None,
|
|
"site_code": None,
|
|
"building": None,
|
|
"address": None,
|
|
"severity": None,
|
|
"priority": None,
|
|
"date_reported": None,
|
|
"scheduled_start": None,
|
|
"due_date": None,
|
|
"assigned_to": None,
|
|
"commenter": None,
|
|
"comment_text": "ai extracted",
|
|
"comment_time": None,
|
|
}
|
|
|
|
|
|
def _event():
|
|
return {
|
|
"Records": [
|
|
{
|
|
"s3": {
|
|
"bucket": {"name": "workorder-ingest-emails-x"},
|
|
"object": {"key": "inbound/o1"},
|
|
}
|
|
}
|
|
]
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def metric_spy(monkeypatch):
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
handler,
|
|
"emit_parse_metric",
|
|
lambda *a: calls.append(a),
|
|
)
|
|
return calls
|
|
|
|
|
|
def test_template_path_skips_bedrock(fake_dynamo, metric_spy, monkeypatch):
|
|
fake_bedrock = FakeBedrock(AI_17_KEY)
|
|
monkeypatch.setattr(
|
|
handler, "s3", FakeS3(_raw("update-plaintext", "update-plaintext-01"))
|
|
)
|
|
monkeypatch.setattr(handler, "bedrock", fake_bedrock)
|
|
|
|
handler.handler(_event(), None)
|
|
|
|
assert fake_bedrock.calls == [] # deterministic parse handled it
|
|
method, template_id, reason, wo = metric_spy[0]
|
|
assert method == "template"
|
|
assert template_id == "update_plaintext"
|
|
assert reason == "ok"
|
|
assert wo == "11144580730"
|
|
|
|
|
|
def test_fallback_path_invokes_bedrock(fake_dynamo, metric_spy, monkeypatch):
|
|
fake_bedrock = FakeBedrock(AI_17_KEY)
|
|
monkeypatch.setattr(handler, "s3", FakeS3(_raw("ai-fallback", "unknown-subject")))
|
|
monkeypatch.setattr(handler, "bedrock", fake_bedrock)
|
|
|
|
handler.handler(_event(), None)
|
|
|
|
assert len(fake_bedrock.calls) == 1
|
|
# Uses the configured inference profile and the bedrock message contract.
|
|
call = fake_bedrock.calls[0]
|
|
assert call["modelId"] == handler.BEDROCK_MODEL_ID
|
|
body = json.loads(call["body"])
|
|
assert body["anthropic_version"] == "bedrock-2023-05-31"
|
|
assert body["max_tokens"] == 1024
|
|
method, _template_id, _reason, wo = metric_spy[0]
|
|
assert method == "ai_fallback"
|
|
assert wo == "77777777777"
|
|
# The AI result was written through to DynamoDB.
|
|
wo_table = fake_dynamo.tables[handler.WORK_ORDERS_TABLE]
|
|
assert wo_table.updates, "expected a work-order upsert from the AI path"
|
|
|
|
|
|
def test_extract_with_bedrock_returns_full_contract(monkeypatch):
|
|
fake_bedrock = FakeBedrock(AI_17_KEY)
|
|
monkeypatch.setattr(handler, "bedrock", fake_bedrock)
|
|
email_data = {
|
|
"subject": "x",
|
|
"sender": "a",
|
|
"to": "b",
|
|
"cc": "",
|
|
"date": "d",
|
|
"body": "body",
|
|
}
|
|
out = handler.extract_with_bedrock(email_data)
|
|
assert set(out.keys()) == set(AI_17_KEY.keys())
|
|
assert len(fake_bedrock.calls) == 1
|
|
|
|
|
|
def test_extract_with_bedrock_strips_markdown_fence(monkeypatch):
|
|
class FenceBedrock:
|
|
def invoke_model(self, modelId, body): # noqa: N803
|
|
fenced = "```json\n" + json.dumps(AI_17_KEY) + "\n```"
|
|
return {
|
|
"body": FakeBody(json.dumps({"content": [{"text": fenced}]}).encode())
|
|
}
|
|
|
|
monkeypatch.setattr(handler, "bedrock", FenceBedrock())
|
|
out = handler.extract_with_bedrock(
|
|
{"subject": "", "sender": "", "to": "", "cc": "", "date": "", "body": ""}
|
|
)
|
|
assert out["work_order_id"] == "77777777777"
|
|
|
|
|
|
def test_emit_parse_metric_writes_emf(capsys):
|
|
handler.emit_parse_metric("template", "update_plaintext", "ok", "123")
|
|
line = capsys.readouterr().out.strip()
|
|
emf = json.loads(line)
|
|
assert emf["ParseMethod"] == "template"
|
|
assert emf["TemplateId"] == "update_plaintext"
|
|
assert emf["ReasonCode"] == "ok"
|
|
assert emf["ParseOutcome"] == 1
|
|
dims = emf["_aws"]["CloudWatchMetrics"][0]["Dimensions"][0]
|
|
assert dims == ["ParseMethod", "TemplateId"]
|
|
assert (
|
|
emf["_aws"]["CloudWatchMetrics"][0]["Namespace"] == "Seahaven/WorkorderIngest"
|
|
)
|