"""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) # This suite exercises parse dispatch, not the fail-closed SES sender-auth # gate (INFRA-107) that now runs first in handler(); the scrubbed .eml # fixtures carry no SES-stamped Authentication-Results header, so bypass it # here. Authentication itself is covered by tests/test_ses_auth.py. monkeypatch.setattr(handler, "authenticate_inbound_email", lambda *a: True) 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) # See note in test_template_path_skips_bedrock: bypass the INFRA-107 sender # auth gate so this dispatch test reaches the parse path. monkeypatch.setattr(handler, "authenticate_inbound_email", lambda *a: True) 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 # Advisory A1: greedy decoding so retries reproduce the same extraction. assert body["temperature"] == 0 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_ai_path_non_numeric_wo_id_is_skipped(fake_dynamo, metric_spy, monkeypatch): """A prompt-injected model result whose work_order_id is not digits-only must fail closed before any DynamoDB write (WO-INJ-01/02 guard).""" injected = dict(AI_17_KEY, work_order_id="123#spoofed#deadbeef") monkeypatch.setattr(handler, "s3", FakeS3(_raw("ai-fallback", "unknown-subject"))) monkeypatch.setattr(handler, "bedrock", FakeBedrock(injected)) monkeypatch.setattr(handler, "authenticate_inbound_email", lambda *a: True) handler.handler(_event(), None) wo_table = fake_dynamo.tables.get(handler.WORK_ORDERS_TABLE) comments = fake_dynamo.tables.get(handler.COMMENTS_TABLE) assert wo_table is None or not wo_table.updates assert comments is None or not comments.puts 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"] # Two dimension sets must be published: the ParseMethod-only aggregate that # the fallback-rate alarm queries, AND the per-template breakdown. Without # the ["ParseMethod"] set the alarm's single-dimension series never receives # data and can never fire (regression guard for the coverage-collapse alarm). assert ["ParseMethod"] in dims assert ["ParseMethod", "TemplateId"] in dims assert ( emf["_aws"]["CloudWatchMetrics"][0]["Namespace"] == "Seahaven/WorkorderIngest" ) # Advisory A2: EMF requires _aws.Timestamp (epoch ms) for the datapoint to # be extracted from the log event. assert isinstance(emf["_aws"]["Timestamp"], int) assert emf["_aws"]["Timestamp"] > 1_500_000_000_000 # ms, not seconds