"""Unit tests for T03 (news clusterer) and T12 (CertStream match_brand).""" from __future__ import annotations from datetime import UTC, datetime, timedelta from app.domains.news.clusterer import NewsItem, cluster_items from app.domains.threat.certstream_listener import match_brand # ── T03: clusterer tests ─────────────────────────────────────────── def test_clusterer_groups_similar_stories(): """Two items with same/similar story should cluster together.""" base = datetime(2026, 6, 23, 12, 0, tzinfo=UTC) items = [ NewsItem( id="a1", title="Bitcoin hits new all-time high above 120000", body="BTC surged past 120000 today as ETF inflows hit record", source="coindesk", url="https://coindesk.com/1", published_at=base, ), NewsItem( id="a2", title="Bitcoin hits new all-time high above 120000", body="BTC surged past 120000 today as ETF inflows hit record", source="the block", url="https://theblock.co/2", published_at=base + timedelta(minutes=5), ), NewsItem( id="b1", title="Ethereum upgrade scheduled for next month", body="Core developers announce Pectra hard fork for July", source="decrypt", url="https://decrypt.co/3", published_at=base + timedelta(minutes=2), ), ] stories = cluster_items(items) # Should produce 2 stories (2 BTC items clustered + 1 ETH item singleton) assert len(stories) == 2, f"expected 2 stories, got {len(stories)}" btc_story = next(s for s in stories if s.item_count == 2) assert btc_story.item_count == 2 assert "coindesk" in btc_story.sources assert "the block" in btc_story.sources assert len(btc_story.item_ids) == 2 def test_clusterer_handles_singleton(): """Single item → single story (singleton).""" base = datetime(2026, 6, 23, 12, 0, tzinfo=UTC) items = [ NewsItem( id="x1", title="Unique story nobody else is covering", body="Something happened once", source="reddit", url="https://reddit.com/x", published_at=base, ), ] stories = cluster_items(items) assert len(stories) == 1 assert stories[0].item_count == 1 def test_clusterer_respects_time_window(): """Items in different time windows should not cluster together.""" base = datetime(2026, 6, 23, 12, 0, tzinfo=UTC) items = [ NewsItem( id="m1", title="Bitcoin hits new high", body="BTC surged past $120K", source="coindesk", url="", published_at=base, ), NewsItem( id="m2", title="Bitcoin hits new high", body="BTC surged past $120K", source="the block", url="", published_at=base + timedelta(hours=2), ), ] # With 30-min windows, these are in separate buckets → 2 singleton stories stories = cluster_items(items, window_minutes=30) assert len(stories) == 2 def test_clusterer_empty(): assert cluster_items([]) == [] # ── T12: match_brand tests ───────────────────────────────────────── def test_match_brand_flags_phishing_clone(): """'metamask-secure-claim.com' should flag as phishing of 'metamask'.""" brand = match_brand("metamask-secure-claim.com") assert brand == "metamask" def test_match_brand_passes_official_domain(): """'metamask.io' should NOT be flagged (it's the official domain).""" brand = match_brand("metamask.io") assert brand is None def test_match_brand_flags_subdomain_phish(): """'login-ledger.com' should flag as 'ledger' phishing.""" brand = match_brand("login-ledger.com") assert brand == "ledger" def test_match_brand_ignores_unrelated(): brand = match_brand("some-random-website.com") assert brand is None def test_match_brand_strips_wildcard(): """CertStream sometimes gives '*.example.com' - strip the wildcard.""" assert match_brand("*.metamask-secure.com") == "metamask" def test_match_brand_handles_empty(): assert match_brand("") is None assert match_brand(".") is None