"""Tests for MessageDeduplicator TTL enforcement (#10306). Previously, is_duplicate() returned True for any previously seen ID without checking its age — expired entries were only purged when cache size exceeded max_size. Normal workloads never overflowed, so messages stayed "duplicate" forever. The fix checks TTL at query time: if the entry's timestamp plus TTL is in the past, the entry is treated as expired and the message is allowed through. """ import time from unittest.mock import patch from gateway.platforms.helpers import MessageDeduplicator class TestMessageDeduplicatorTTL: """TTL-based expiration must work regardless of cache size.""" def test_duplicate_within_ttl(self): """Same message within TTL window is duplicate.""" dedup = MessageDeduplicator(ttl_seconds=60) assert dedup.is_duplicate("msg-1") is False assert dedup.is_duplicate("msg-1") is True def test_not_duplicate_after_ttl_expires(self): """Same message AFTER TTL expires should NOT be duplicate.""" dedup = MessageDeduplicator(ttl_seconds=5) assert dedup.is_duplicate("msg-1") is False # Fast-forward time past TTL dedup._seen["msg-1"] = time.time() - 10 # 10s ago, TTL is 5s assert dedup.is_duplicate("msg-1") is False, \ "Expired entry should not be treated as duplicate" def test_expired_entry_gets_refreshed(self): """After an expired entry is allowed through, it should be re-tracked.""" dedup = MessageDeduplicator(ttl_seconds=5) assert dedup.is_duplicate("msg-1") is False # Expire the entry dedup._seen["msg-1"] = time.time() - 10 # Should be allowed through (expired) assert dedup.is_duplicate("msg-1") is False # Now should be duplicate again (freshly tracked) assert dedup.is_duplicate("msg-1") is True def test_different_messages_not_confused(self): """Different message IDs are independent.""" dedup = MessageDeduplicator(ttl_seconds=60) assert dedup.is_duplicate("msg-1") is False assert dedup.is_duplicate("msg-2") is False assert dedup.is_duplicate("msg-1") is True assert dedup.is_duplicate("msg-2") is True def test_empty_id_never_duplicate(self): """Empty/None message IDs are never treated as duplicate.""" dedup = MessageDeduplicator(ttl_seconds=60) assert dedup.is_duplicate("") is False assert dedup.is_duplicate("") is False def test_max_size_eviction_prunes_expired(self): """Cache pruning on overflow removes expired entries.""" dedup = MessageDeduplicator(max_size=5, ttl_seconds=60) # Add 6 entries, with the first 3 expired now = time.time() for i in range(3): dedup._seen[f"old-{i}"] = now - 120 # expired (2 min ago, TTL 60s) for i in range(3): dedup.is_duplicate(f"new-{i}") # Now we have 6 entries. Next insert triggers pruning. dedup.is_duplicate("trigger") # The 3 expired entries should be gone, leaving 4 fresh ones assert len(dedup._seen) == 4 assert "old-0" not in dedup._seen assert "new-0" in dedup._seen def test_ttl_zero_means_no_dedup(self): """With TTL=0, all entries expire immediately.""" dedup = MessageDeduplicator(ttl_seconds=0) assert dedup.is_duplicate("msg-1") is False # Entry was just added at time.time(), and TTL is 0, # so now - seen_time >= 0 = ttl, meaning it's expired # But time.time() might be the exact same float, so # the check is `now - ts < ttl` which is `0 < 0` = False # This means TTL=0 effectively disables dedup assert dedup.is_duplicate("msg-1") is False