procurement-ingest/lambdas/wo/email_processor/tests/test_comment_id.py
Adam Moussa 89bf47c1bb
Add offline WO parser test suite
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
2026-07-15 20:11:04 -04:00

88 lines
3.1 KiB
Python

"""Issue #23: comment_id (WorkOrderComments range key) must be unique per source
email AND byte-identical across Lambda async retries of the same S3 object, with
wall-clock now() kept OUT of the key."""
import handler
def _parsed(wo="11144580730", comment_time="2026-04-27T23:51:48"):
return {
"work_order_id": wo,
"email_type": "comment",
"commenter": "jdoe",
"comment_text": "a comment",
"comment_time": comment_time,
}
def _comment_ids(table):
return [item["comment_id"] for item in table.puts]
def test_same_object_key_retry_is_idempotent(fake_dynamo):
parsed = _parsed()
handler.save_event(parsed, "s3://bucket/inbound/obj-a", "inbound/obj-a")
handler.save_event(parsed, "s3://bucket/inbound/obj-a", "inbound/obj-a")
table = fake_dynamo.tables[handler.COMMENTS_TABLE]
ids = _comment_ids(table)
# Two put_item calls with an identical key -> one logical row (overwrite).
assert ids[0] == ids[1]
assert len(table.store) == 1
def test_distinct_emails_same_wo_and_time_are_distinct_rows(fake_dynamo):
parsed = _parsed()
handler.save_event(parsed, "s3://bucket/inbound/obj-a", "inbound/obj-a")
handler.save_event(parsed, "s3://bucket/inbound/obj-b", "inbound/obj-b")
table = fake_dynamo.tables[handler.COMMENTS_TABLE]
ids = _comment_ids(table)
assert ids[0] != ids[1]
assert len(table.store) == 2
def test_absent_comment_time_is_stable_across_retries(fake_dynamo):
parsed = _parsed(comment_time=None)
handler.save_event(parsed, "s3://bucket/inbound/obj-c", "inbound/obj-c")
handler.save_event(parsed, "s3://bucket/inbound/obj-c", "inbound/obj-c")
table = fake_dynamo.tables[handler.COMMENTS_TABLE]
ids = _comment_ids(table)
assert ids[0] == ids[1] # 'nocomment' segment, not now()
assert "#nocomment#" in ids[0]
assert len(table.store) == 1
def test_event_id_independent_of_now(fake_dynamo, monkeypatch):
"""Freezing / advancing the clock must not change the range key."""
parsed = _parsed(comment_time=None)
class FrozenDT:
offset = 0
@classmethod
def now(cls, tz=None):
import datetime as _dt
return _dt.datetime(2020, 1, 1, tzinfo=tz) + _dt.timedelta(
seconds=cls.offset
)
monkeypatch.setattr(handler, "datetime", FrozenDT)
handler.save_event(parsed, "s3://b/inbound/obj-d", "inbound/obj-d")
FrozenDT.offset = 999999 # simulate a later retry
handler.save_event(parsed, "s3://b/inbound/obj-d", "inbound/obj-d")
table = fake_dynamo.tables[handler.COMMENTS_TABLE]
ids = _comment_ids(table)
assert ids[0] == ids[1]
# created_at (display) may differ, but the KEY must not.
assert len(table.store) == 1
def test_comment_id_format_shape(fake_dynamo):
parsed = _parsed()
handler.save_event(parsed, "s3://bucket/inbound/obj-e", "inbound/obj-e")
table = fake_dynamo.tables[handler.COMMENTS_TABLE]
cid = _comment_ids(table)[0]
wo, time_part, suffix = cid.split("#")
assert wo == "11144580730"
assert time_part == "2026-04-27T23:51:48"
assert len(suffix) == 12 and all(c in "0123456789abcdef" for c in suffix)