"""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