procurement-ingest/lambdas/wo/email_processor/tests/test_comment_id.py
Adam Moussa ca4f43a2cc
Some checks are pending
Deploy / deploy (push) Waiting to run
feat: decompose email-processor handlers into flat siblings + lazy boto3 clients (refactor phase 5) (#113)
Both email-processor God-handlers split along the seams that already
work in the flat-sibling pattern established by lambdas/shared/, so
bare-name imports keep working under the existing bundling glob.

PO (5-way split): handler.py keeps only the event loop, fail-closed
auth, and email_type routing. extraction.py holds extract_with_claude
and _EMAIL_TAG_RE, importing EXTRACTION_PROMPT from prompts.py and
parse_raw_email from shared/email_parsing.py rather than recreating a
PO-local copy. enrichment.py is a pure code move of enrich_parsed and
pad_zip (PO-only; WO has no enrichment stage) with zero behavior
change. telemetry.py holds the EMF ParseMethod emit wrappers.
persistence.py holds _write_fields/_merge_update/save_*, collapsing
the byte-identical save_new_po/save_revision bodies into one
_save_merge helper that both now call through, preserving the sticky
Cancelled ConditionExpression guard for both callers; save_cancellation
stays separate.

WO (5 concerns, no enrichment stage): the handler loop keeps
validate_ai_fallback and the re.fullmatch(r"[0-9]+", work_order_id)
key guard ahead of both save_work_order and save_event, since the
guard protects the DynamoDB partition key and the '#'-delimited
comment_id range-key segment. _header_date_iso and comment_id
determinism stay colocated with persistence.py's save_event for the
retry-idempotent event_id key.

EXTRACTION_PROMPT (PO) moves to prompts.py with cross-reference
headers to derived_fields.py's authoritative trade/site/fiscal rule
tables; handler.py re-exports it (from prompts import
EXTRACTION_PROMPT) since four tests dereference handler.EXTRACTION_
PROMPT directly. WO's prompt moves the same way.

I/O modules (extraction.py's bedrock client, persistence.py's
dynamodb resource, handler.py's s3 client) get lazy cached boto3
accessors; pure modules (enrichment.py, prompts.py, telemetry.py)
import no boto3. Test monkeypatch surfaces move to the module that
now owns the client (e.g. persistence.dynamodb) everywhere tests
patch it, and the moto-before-handler-import ordering in
_po_parser_support.py is preserved so the moto-backed suites don't
hit real AWS.

Behavior-preservation pins, verified with tests: PO still emits
ParseMethod=ai_fallback before the Bedrock call, with
ai_fallback_rejected as the additive second datapoint on rejection.
WO still emits after its gate with mutually-exclusive ai_fallback /
ai_fallback_rejected. Shadow DerivedFieldAgreement telemetry stays
ai_fallback-only. derived_fields.py is untouched (diff against
feature/phase-3-shared-extraction is empty). handler(event, context)
signatures and the save_* public contract are unchanged on both
pipelines; goldens unchanged.

PO_EXPECTED_TOP_LEVEL_MODULES and its WO equivalent in
tests/test_bundle_consistency.py are updated for the new sibling
modules so the AST bundle-consistency test still fails on an
unshipped or uncommented-out sibling.
2026-07-20 15:34:53 -04:00

172 lines
5.9 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.
Advisory A1: on the ai_fallback path the parsed comment_time is model output and
not retry-stable, so it must never enter the key -- the time segment derives
from the (deterministic) email Date header instead.
"""
import handler # noqa: F401 (loads WO siblings via _wo_parser_support path)
import persistence
DATE_HEADER = "Mon, 27 Apr 2026 23:57:49 +0000 (UTC)"
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()
persistence.save_event(
parsed, "s3://bucket/inbound/obj-a", "inbound/obj-a", "template", DATE_HEADER
)
persistence.save_event(
parsed, "s3://bucket/inbound/obj-a", "inbound/obj-a", "template", DATE_HEADER
)
table = fake_dynamo.tables[persistence.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()
persistence.save_event(
parsed, "s3://bucket/inbound/obj-a", "inbound/obj-a", "template", DATE_HEADER
)
persistence.save_event(
parsed, "s3://bucket/inbound/obj-b", "inbound/obj-b", "template", DATE_HEADER
)
table = fake_dynamo.tables[persistence.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)
persistence.save_event(
parsed, "s3://bucket/inbound/obj-c", "inbound/obj-c", "template", DATE_HEADER
)
persistence.save_event(
parsed, "s3://bucket/inbound/obj-c", "inbound/obj-c", "template", DATE_HEADER
)
table = fake_dynamo.tables[persistence.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(persistence, "datetime", FrozenDT)
persistence.save_event(
parsed, "s3://b/inbound/obj-d", "inbound/obj-d", "template", DATE_HEADER
)
FrozenDT.offset = 999999 # simulate a later retry
persistence.save_event(
parsed, "s3://b/inbound/obj-d", "inbound/obj-d", "template", DATE_HEADER
)
table = fake_dynamo.tables[persistence.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()
persistence.save_event(
parsed, "s3://bucket/inbound/obj-e", "inbound/obj-e", "template", DATE_HEADER
)
table = fake_dynamo.tables[persistence.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)
# --- Advisory A1: ai_fallback path ---
def test_ai_path_retry_with_drifted_comment_time_is_idempotent(fake_dynamo):
"""A retry where the model returns a DIFFERENT comment_time must still map
to the same range key (the model output never enters the key)."""
persistence.save_event(
_parsed(comment_time="2026-04-27T23:51:48"),
"s3://bucket/inbound/obj-f",
"inbound/obj-f",
"ai_fallback",
DATE_HEADER,
)
persistence.save_event(
_parsed(comment_time="2026-04-27T23:52:03"), # model drifted on retry
"s3://bucket/inbound/obj-f",
"inbound/obj-f",
"ai_fallback",
DATE_HEADER,
)
table = fake_dynamo.tables[persistence.COMMENTS_TABLE]
ids = _comment_ids(table)
assert ids[0] == ids[1]
assert len(table.store) == 1
def test_ai_path_key_uses_date_header_not_model_output(fake_dynamo):
persistence.save_event(
_parsed(),
"s3://bucket/inbound/obj-g",
"inbound/obj-g",
"ai_fallback",
DATE_HEADER,
)
table = fake_dynamo.tables[persistence.COMMENTS_TABLE]
cid = _comment_ids(table)[0]
_, time_part, _ = cid.split("#")
assert time_part == "2026-04-27T23:57:49+00:00" # Date header, UTC ISO
assert "23:51:48" not in cid # parsed comment_time kept out of the key
def test_ai_path_missing_date_header_falls_back_to_nocomment(fake_dynamo):
# 'Fri, 31 Dec 9999' parses but overflows on UTC conversion (OverflowError,
# not ValueError) -- _header_date_iso must swallow it, never raise.
for header in (None, "", "not a date", "Fri, 31 Dec 9999 23:59:59 -1400"):
persistence.save_event(
_parsed(),
"s3://bucket/inbound/obj-h",
"inbound/obj-h",
"ai_fallback",
header,
)
table = fake_dynamo.tables[persistence.COMMENTS_TABLE]
ids = _comment_ids(table)
assert all("#nocomment#" in cid for cid in ids)
assert len(set(ids)) == 1 # stable regardless of header garbage