2860fb5d18
Security / Dependency review (push) Has been skipped
Scorecard / Scorecard analysis (push) Failing after 0s
Validate / eval (push) Failing after 0s
Security / Dependency audit (push) Failing after 1s
Security / Secret scan (push) Failing after 1s
Validate / tests (push) Failing after 0s
Validate / mcp-tests (push) Failing after 1s
GitHub Actions Security Analysis with zizmor 🌈 / zizmor (push) Failing after 1s
Security / SAST scan (push) Failing after 13m52s
489 lines
15 KiB
Python
489 lines
15 KiB
Python
"""Tests for hackernews.py - HN search via Algolia API."""
|
|
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import Mock, patch
|
|
|
|
import pytest
|
|
|
|
from lib import hackernews
|
|
|
|
# === Helper Functions ===
|
|
|
|
|
|
def create_mock_hit(
|
|
object_id="12345",
|
|
title="Test HN Story",
|
|
points=100,
|
|
num_comments=50,
|
|
created_at_i=None,
|
|
author="testuser",
|
|
url="https://example.com",
|
|
):
|
|
"""Create a mock Algolia hit object."""
|
|
if created_at_i is None:
|
|
# Default to 30 days ago
|
|
dt = datetime.now(timezone.utc)
|
|
created_at_i = int(dt.timestamp()) - (30 * 86400)
|
|
|
|
return {
|
|
"objectID": object_id,
|
|
"title": title,
|
|
"points": points,
|
|
"num_comments": num_comments,
|
|
"created_at_i": created_at_i,
|
|
"author": author,
|
|
"url": url,
|
|
}
|
|
|
|
# === Tests for _date_to_unix() ===
|
|
|
|
|
|
def test_date_to_unix_basic():
|
|
"""Test converting YYYY-MM-DD to Unix timestamp."""
|
|
result = hackernews._date_to_unix("2026-01-01")
|
|
|
|
# Should be midnight UTC on Jan 1, 2026
|
|
expected = datetime(2026, 1, 1, tzinfo=timezone.utc).timestamp()
|
|
assert result == int(expected)
|
|
|
|
|
|
def test_date_to_unix_leap_day():
|
|
"""Test date conversion with leap day."""
|
|
result = hackernews._date_to_unix("2024-02-29")
|
|
|
|
expected = datetime(2024, 2, 29, tzinfo=timezone.utc).timestamp()
|
|
assert result == int(expected)
|
|
|
|
# === Tests for _unix_to_date() ===
|
|
|
|
|
|
def test_unix_to_date_basic():
|
|
"""Test converting Unix timestamp to YYYY-MM-DD."""
|
|
ts = int(datetime(2026, 1, 15, tzinfo=timezone.utc).timestamp())
|
|
result = hackernews._unix_to_date(ts)
|
|
|
|
assert result == "2026-01-15"
|
|
|
|
|
|
def test_unix_to_date_with_time():
|
|
"""Test that time component is stripped."""
|
|
ts = int(datetime(2026, 1, 15, 14, 30, 45, tzinfo=timezone.utc).timestamp())
|
|
result = hackernews._unix_to_date(ts)
|
|
|
|
assert result == "2026-01-15"
|
|
|
|
# === Tests for _strip_html() ===
|
|
|
|
|
|
def test_strip_html_basic():
|
|
"""Test HTML stripping and entity decoding."""
|
|
html_text = "<p>Hello & goodbye</p>"
|
|
result = hackernews._strip_html(html_text)
|
|
|
|
assert result == "Hello & goodbye"
|
|
|
|
|
|
def test_strip_html_paragraph_tags():
|
|
"""Test that <p> tags are converted to newlines."""
|
|
html_text = "First<p>Second<p>Third"
|
|
result = hackernews._strip_html(html_text)
|
|
|
|
assert "First\n" in result
|
|
assert "Second\n" in result
|
|
|
|
|
|
def test_strip_html_nested_tags():
|
|
"""Test stripping nested HTML tags."""
|
|
html_text = "<div><a href='test'>Link</a> text <b>bold</b></div>"
|
|
result = hackernews._strip_html(html_text)
|
|
|
|
assert result == "Link text bold"
|
|
|
|
|
|
def test_strip_html_entities():
|
|
"""Test HTML entity decoding and tag stripping."""
|
|
html_text = "Text & "test""
|
|
result = hackernews._strip_html(html_text)
|
|
|
|
# Entities are decoded
|
|
assert "&" in result or "test" in result
|
|
|
|
# === Tests for _title_matches_query() ===
|
|
|
|
|
|
def test_title_matches_query_basic():
|
|
"""Test basic query matching."""
|
|
title = "New AI framework for developers"
|
|
query = "AI framework"
|
|
|
|
assert hackernews._title_matches_query(title, query) is True
|
|
|
|
|
|
def test_title_matches_query_case_insensitive():
|
|
"""Test that matching is case-insensitive."""
|
|
title = "NEW AI FRAMEWORK"
|
|
query = "ai framework"
|
|
|
|
assert hackernews._title_matches_query(title, query) is True
|
|
|
|
|
|
def test_title_matches_query_with_prefix():
|
|
"""Test matching with HN prefix stripped."""
|
|
title = "Show HN: My new AI framework"
|
|
query = "AI framework"
|
|
|
|
# Should match "AI framework" in the content, not the "Show HN:" prefix
|
|
assert hackernews._title_matches_query(title, query) is True
|
|
|
|
|
|
def test_title_matches_query_prefix_only():
|
|
"""Test that matching prefix-only returns False."""
|
|
title = "Show HN: Something else entirely"
|
|
query = "Show HN"
|
|
|
|
# "Show HN" is a prefix, not real content
|
|
# After stripping, "Show HN" won't be in the stripped title
|
|
assert hackernews._title_matches_query(title, query) is False
|
|
|
|
|
|
def test_title_matches_query_empty_query():
|
|
"""Test that empty query always matches."""
|
|
title = "Any title"
|
|
query = ""
|
|
|
|
assert hackernews._title_matches_query(title, query) is True
|
|
|
|
|
|
def test_title_matches_query_partial_match():
|
|
"""Any-word matching: at least one query token in title is enough.
|
|
|
|
Previously required *all* tokens, which killed every hit on multi-keyword
|
|
theme queries like 'claude, personal agents, agentic infra' since no real
|
|
HN title contains all 5 tokens verbatim. Token-overlap relevance at parse
|
|
time still demotes weak matches, so the loosened gate is safe.
|
|
"""
|
|
title = "New AI framework"
|
|
query = "AI blockchain"
|
|
|
|
# "AI" matches as a whole word, even though "blockchain" doesn't appear
|
|
assert hackernews._title_matches_query(title, query) is True
|
|
|
|
|
|
def test_title_matches_query_no_token_in_title():
|
|
"""If no query token appears in the title at all, reject."""
|
|
assert hackernews._title_matches_query("New rust compiler", "AI blockchain") is False
|
|
|
|
|
|
def test_title_matches_query_word_boundary_not_substring():
|
|
"""Short tokens must match on word boundaries, not as substrings.
|
|
|
|
Without word-boundary matching, 'ai' would falsely match 'email',
|
|
'rail', 'artists', etc.
|
|
"""
|
|
# 'ai' as a substring of 'email' must not match
|
|
assert hackernews._title_matches_query("New email service", "ai blockchain") is False
|
|
# 'ai' as a whole word does match
|
|
assert hackernews._title_matches_query("Cool AI tool launched", "ai blockchain") is True
|
|
|
|
|
|
def test_title_matches_query_flattens_hyphens_and_commas():
|
|
"""Query tokens split on hyphens/commas the same way search_hackernews
|
|
flattens them, so the post-filter stays aligned with what Algolia saw."""
|
|
# query 'ts-bun-node' flattens to ['ts', 'bun', 'node']; title contains 'bun'
|
|
assert hackernews._title_matches_query("Bun 1.2 released", "ts-bun-node") is True
|
|
# query 'rust, go, zig' flattens; title contains 'go'
|
|
assert hackernews._title_matches_query("Go 1.24 generics update", "rust, go, zig") is True
|
|
|
|
# === Tests for search_hackernews() ===
|
|
|
|
@patch('lib.hackernews.http.request')
|
|
|
|
|
|
def test_search_hackernews_basic(mock_request):
|
|
"""Test basic HN search."""
|
|
mock_request.return_value = {
|
|
"hits": [create_mock_hit()],
|
|
"nbHits": 1,
|
|
}
|
|
|
|
result = hackernews.search_hackernews(
|
|
"AI framework",
|
|
"2026-01-01",
|
|
"2026-01-31",
|
|
depth="quick"
|
|
)
|
|
|
|
assert "hits" in result
|
|
assert len(result["hits"]) == 1
|
|
assert mock_request.called
|
|
|
|
@patch('lib.hackernews.http.request')
|
|
|
|
|
|
def test_search_hackernews_depth_config(mock_request):
|
|
"""Test that depth parameter controls hit count."""
|
|
mock_request.return_value = {"hits": [], "nbHits": 0}
|
|
|
|
# Quick mode returns up to 15 hits, but overfetches before client-side
|
|
# engagement filtering so low-point stories do not shrink result depth.
|
|
hackernews.search_hackernews("test", "2026-01-01", "2026-01-31", depth="quick")
|
|
|
|
call_args = mock_request.call_args[0]
|
|
url = call_args[1]
|
|
|
|
expected_hits_per_page = (
|
|
hackernews.DEPTH_CONFIG["quick"] * hackernews.HN_OVERFETCH_MULTIPLIER
|
|
)
|
|
assert f"hitsPerPage={expected_hits_per_page}" in url
|
|
|
|
@patch('lib.hackernews.http.request')
|
|
|
|
|
|
def test_search_hackernews_date_filtering(mock_request):
|
|
"""Test that date range is applied correctly."""
|
|
mock_request.return_value = {"hits": [], "nbHits": 0}
|
|
|
|
hackernews.search_hackernews("test", "2026-01-01", "2026-01-31", depth="quick")
|
|
|
|
call_args = mock_request.call_args[0]
|
|
url = call_args[1]
|
|
|
|
# Should have numeric filters for date range
|
|
assert "numericFilters" in url
|
|
assert "created_at_i" in url
|
|
|
|
@patch('lib.hackernews.http.request')
|
|
|
|
|
|
def test_search_hackernews_http_error_handling(mock_request):
|
|
"""Test graceful handling of HTTP errors."""
|
|
from lib.http import HTTPError
|
|
mock_request.side_effect = HTTPError("HTTP 429: Too Many Requests")
|
|
|
|
result = hackernews.search_hackernews("test", "2026-01-01", "2026-01-31")
|
|
|
|
# Should return empty hits with error
|
|
assert result["hits"] == []
|
|
assert "error" in result
|
|
|
|
@patch('lib.hackernews.http.request')
|
|
|
|
|
|
def test_search_hackernews_engagement_filter(mock_request):
|
|
"""Test that low-engagement stories are filtered client-side."""
|
|
mock_request.return_value = {
|
|
"hits": [
|
|
create_mock_hit(object_id="low", points=2),
|
|
create_mock_hit(object_id="high", points=3),
|
|
],
|
|
"nbHits": 2,
|
|
}
|
|
|
|
result = hackernews.search_hackernews("test", "2026-01-01", "2026-01-31")
|
|
|
|
call_args = mock_request.call_args[0]
|
|
url = call_args[1]
|
|
|
|
# Algolia rejects points in numericFilters; keep only supported date filters.
|
|
assert "points" not in url
|
|
assert [hit["objectID"] for hit in result["hits"]] == ["high"]
|
|
|
|
|
|
@patch('lib.hackernews.http.request')
|
|
def test_search_hackernews_no_points_numericfilter(mock_request):
|
|
"""numericFilters must NOT include a `points` clause.
|
|
|
|
`points` is not in the HN Algolia index's `numericAttributesForFiltering`,
|
|
so a `points>2` clause returns HTTP 400 ("invalid numeric attribute(points)")
|
|
and zero stories. Engagement is filtered client-side after overfetching
|
|
instead. This guards against the invalid filter being reintroduced.
|
|
"""
|
|
mock_request.return_value = {"hits": [], "nbHits": 0}
|
|
|
|
hackernews.search_hackernews("test", "2026-01-01", "2026-01-31")
|
|
|
|
url = mock_request.call_args[0][1]
|
|
|
|
# Date filter stays; the invalid points filter must be gone.
|
|
assert "created_at_i" in url
|
|
assert "points" not in url
|
|
|
|
|
|
@patch('lib.hackernews.http.request')
|
|
def test_search_hackernews_truncates_after_overfetch(mock_request):
|
|
"""Test that overfetching does not return more than the requested depth."""
|
|
mock_request.return_value = {
|
|
"hits": [
|
|
create_mock_hit(object_id=str(i), points=10)
|
|
for i in range(20)
|
|
],
|
|
"nbHits": 20,
|
|
}
|
|
|
|
result = hackernews.search_hackernews("test", "2026-01-01", "2026-01-31", depth="quick")
|
|
|
|
assert len(result["hits"]) == 15
|
|
assert [hit["objectID"] for hit in result["hits"]] == [str(i) for i in range(15)]
|
|
|
|
# === Tests for parse_hackernews_response() ===
|
|
|
|
|
|
def test_parse_hackernews_response_basic():
|
|
"""Test parsing basic Algolia response."""
|
|
response = {
|
|
"hits": [create_mock_hit(
|
|
object_id="123",
|
|
title="Test Story",
|
|
points=100,
|
|
num_comments=50
|
|
)]
|
|
}
|
|
|
|
items = hackernews.parse_hackernews_response(response)
|
|
|
|
assert len(items) == 1
|
|
assert items[0]["id"] == "123"
|
|
assert items[0]["title"] == "Test Story"
|
|
assert items[0]["engagement"]["points"] == 100
|
|
assert items[0]["engagement"]["comments"] == 50
|
|
|
|
|
|
def test_parse_hackernews_response_hn_url():
|
|
"""Test that HN discussion URL is generated correctly."""
|
|
response = {
|
|
"hits": [create_mock_hit(object_id="12345")]
|
|
}
|
|
|
|
items = hackernews.parse_hackernews_response(response)
|
|
|
|
assert items[0]["hn_url"] == "https://news.ycombinator.com/item?id=12345"
|
|
|
|
|
|
def test_parse_hackernews_response_date_conversion():
|
|
"""Test that Unix timestamp is converted to YYYY-MM-DD."""
|
|
ts = int(datetime(2026, 1, 15, tzinfo=timezone.utc).timestamp())
|
|
response = {
|
|
"hits": [create_mock_hit(created_at_i=ts)]
|
|
}
|
|
|
|
items = hackernews.parse_hackernews_response(response)
|
|
|
|
assert items[0]["date"] == "2026-01-15"
|
|
|
|
|
|
def test_parse_hackernews_response_missing_fields():
|
|
"""Test handling of hits with missing optional fields."""
|
|
response = {
|
|
"hits": [{
|
|
"objectID": "123",
|
|
"title": "Test",
|
|
# Missing points, num_comments, created_at_i
|
|
}]
|
|
}
|
|
|
|
items = hackernews.parse_hackernews_response(response)
|
|
|
|
assert len(items) == 1
|
|
assert items[0]["engagement"]["points"] == 0
|
|
assert items[0]["engagement"]["comments"] == 0
|
|
assert items[0]["date"] is None
|
|
|
|
|
|
def test_parse_hackernews_response_relevance_scoring():
|
|
"""Test that relevance scores are calculated."""
|
|
response = {
|
|
"hits": [
|
|
create_mock_hit(object_id="1", points=100),
|
|
create_mock_hit(object_id="2", points=50),
|
|
create_mock_hit(object_id="3", points=10),
|
|
]
|
|
}
|
|
|
|
items = hackernews.parse_hackernews_response(response, query="test")
|
|
|
|
# Should have relevance scores
|
|
for item in items:
|
|
assert "relevance" in item
|
|
assert 0 <= item["relevance"] <= 1.0
|
|
|
|
# First item should generally have higher relevance (better rank)
|
|
assert items[0]["relevance"] >= items[2]["relevance"]
|
|
|
|
|
|
def test_parse_hackernews_response_engagement_boost():
|
|
"""Test that high-engagement items get relevance boost."""
|
|
response = {
|
|
"hits": [
|
|
create_mock_hit(object_id="1", points=500, num_comments=200), # High engagement
|
|
create_mock_hit(object_id="2", points=10, num_comments=5), # Low engagement
|
|
]
|
|
}
|
|
|
|
items = hackernews.parse_hackernews_response(response, query="test")
|
|
|
|
# Verify engagement is captured
|
|
assert items[0]["engagement"]["points"] == 500
|
|
assert items[1]["engagement"]["points"] == 10
|
|
|
|
|
|
def test_parse_hackernews_response_prefix_filtering():
|
|
"""Test that items matching only HN prefixes are filtered."""
|
|
response = {
|
|
"hits": [
|
|
create_mock_hit(title="Show HN: My AI Project", object_id="1"),
|
|
create_mock_hit(title="Show HN: Unrelated Project", object_id="2"),
|
|
]
|
|
}
|
|
|
|
# Query for "AI" should keep first, filter second
|
|
items = hackernews.parse_hackernews_response(response, query="AI")
|
|
|
|
assert len(items) == 1
|
|
assert items[0]["id"] == "1"
|
|
|
|
|
|
def test_parse_hackernews_response_empty_response():
|
|
"""Test handling of empty response."""
|
|
response = {"hits": []}
|
|
|
|
items = hackernews.parse_hackernews_response(response)
|
|
|
|
assert items == []
|
|
|
|
# === Tests for engagement scoring ===
|
|
|
|
|
|
def test_engagement_score_calculation():
|
|
"""Test that engagement dict contains points and comments."""
|
|
response = {
|
|
"hits": [create_mock_hit(points=150, num_comments=75)]
|
|
}
|
|
|
|
items = hackernews.parse_hackernews_response(response)
|
|
|
|
engagement = items[0]["engagement"]
|
|
assert engagement["points"] == 150
|
|
assert engagement["comments"] == 75
|
|
|
|
|
|
def test_engagement_score_zero_values():
|
|
"""Test handling of zero engagement values."""
|
|
response = {
|
|
"hits": [{
|
|
"objectID": "123",
|
|
"title": "Test",
|
|
"points": None,
|
|
"num_comments": None,
|
|
}]
|
|
}
|
|
|
|
items = hackernews.parse_hackernews_response(response)
|
|
|
|
engagement = items[0]["engagement"]
|
|
assert engagement["points"] == 0
|
|
assert engagement["comments"] == 0
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|