mirror of
https://github.com/Sea-Haven-Industries/procurement-ingest.git
synced 2026-10-01 12:23:13 +00:00
172 lines
5.7 KiB
Python
172 lines
5.7 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
|
||
|
|
|
||
|
|
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()
|
||
|
|
handler.save_event(
|
||
|
|
parsed, "s3://bucket/inbound/obj-a", "inbound/obj-a", "template", DATE_HEADER
|
||
|
|
)
|
||
|
|
handler.save_event(
|
||
|
|
parsed, "s3://bucket/inbound/obj-a", "inbound/obj-a", "template", DATE_HEADER
|
||
|
|
)
|
||
|
|
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", "template", DATE_HEADER
|
||
|
|
)
|
||
|
|
handler.save_event(
|
||
|
|
parsed, "s3://bucket/inbound/obj-b", "inbound/obj-b", "template", DATE_HEADER
|
||
|
|
)
|
||
|
|
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", "template", DATE_HEADER
|
||
|
|
)
|
||
|
|
handler.save_event(
|
||
|
|
parsed, "s3://bucket/inbound/obj-c", "inbound/obj-c", "template", DATE_HEADER
|
||
|
|
)
|
||
|
|
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", "template", DATE_HEADER
|
||
|
|
)
|
||
|
|
FrozenDT.offset = 999999 # simulate a later retry
|
||
|
|
handler.save_event(
|
||
|
|
parsed, "s3://b/inbound/obj-d", "inbound/obj-d", "template", DATE_HEADER
|
||
|
|
)
|
||
|
|
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", "template", DATE_HEADER
|
||
|
|
)
|
||
|
|
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)
|
||
|
|
|
||
|
|
|
||
|
|
# --- 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)."""
|
||
|
|
handler.save_event(
|
||
|
|
_parsed(comment_time="2026-04-27T23:51:48"),
|
||
|
|
"s3://bucket/inbound/obj-f",
|
||
|
|
"inbound/obj-f",
|
||
|
|
"ai_fallback",
|
||
|
|
DATE_HEADER,
|
||
|
|
)
|
||
|
|
handler.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[handler.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):
|
||
|
|
handler.save_event(
|
||
|
|
_parsed(),
|
||
|
|
"s3://bucket/inbound/obj-g",
|
||
|
|
"inbound/obj-g",
|
||
|
|
"ai_fallback",
|
||
|
|
DATE_HEADER,
|
||
|
|
)
|
||
|
|
table = fake_dynamo.tables[handler.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"):
|
||
|
|
handler.save_event(
|
||
|
|
_parsed(),
|
||
|
|
"s3://bucket/inbound/obj-h",
|
||
|
|
"inbound/obj-h",
|
||
|
|
"ai_fallback",
|
||
|
|
header,
|
||
|
|
)
|
||
|
|
table = fake_dynamo.tables[handler.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
|