返回 last30days-skill
test_hackernews.py
根目录 / tests / test_hackernews.py
1 """Tests for hackernews.py - HN search via Algolia API."""
2
3 import json
4 from datetime import datetime, timezone
5 from unittest.mock import Mock, patch
6
7 import pytest
8
9 from lib import hackernews
10
11 # === Helper Functions ===
12
13
14 def create_mock_hit(
15 object_id="12345",
16 title="Test HN Story",
17 points=100,
18 num_comments=50,
19 created_at_i=None,
20 author="testuser",
21 url="https://example.com",
22 ):
23 """Create a mock Algolia hit object."""
24 if created_at_i is None:
25 # Default to 30 days ago
26 dt = datetime.now(timezone.utc)
27 created_at_i = int(dt.timestamp()) - (30 * 86400)
28
29 return {
30 "objectID": object_id,
31 "title": title,
32 "points": points,
33 "num_comments": num_comments,
34 "created_at_i": created_at_i,
35 "author": author,
36 "url": url,
37 }
38
39 # === Tests for _date_to_unix() ===
40
41
42 def test_date_to_unix_basic():
43 """Test converting YYYY-MM-DD to Unix timestamp."""
44 result = hackernews._date_to_unix("2026-01-01")
45
46 # Should be midnight UTC on Jan 1, 2026
47 expected = datetime(2026, 1, 1, tzinfo=timezone.utc).timestamp()
48 assert result == int(expected)
49
50
51 def test_date_to_unix_leap_day():
52 """Test date conversion with leap day."""
53 result = hackernews._date_to_unix("2024-02-29")
54
55 expected = datetime(2024, 2, 29, tzinfo=timezone.utc).timestamp()
56 assert result == int(expected)
57
58 # === Tests for _unix_to_date() ===
59
60
61 def test_unix_to_date_basic():
62 """Test converting Unix timestamp to YYYY-MM-DD."""
63 ts = int(datetime(2026, 1, 15, tzinfo=timezone.utc).timestamp())
64 result = hackernews._unix_to_date(ts)
65
66 assert result == "2026-01-15"
67
68
69 def test_unix_to_date_with_time():
70 """Test that time component is stripped."""
71 ts = int(datetime(2026, 1, 15, 14, 30, 45, tzinfo=timezone.utc).timestamp())
72 result = hackernews._unix_to_date(ts)
73
74 assert result == "2026-01-15"
75
76 # === Tests for _strip_html() ===
77
78
79 def test_strip_html_basic():
80 """Test HTML stripping and entity decoding."""
81 html_text = "<p>Hello &amp; goodbye</p>"
82 result = hackernews._strip_html(html_text)
83
84 assert result == "Hello & goodbye"
85
86
87 def test_strip_html_paragraph_tags():
88 """Test that <p> tags are converted to newlines."""
89 html_text = "First<p>Second<p>Third"
90 result = hackernews._strip_html(html_text)
91
92 assert "First\n" in result
93 assert "Second\n" in result
94
95
96 def test_strip_html_nested_tags():
97 """Test stripping nested HTML tags."""
98 html_text = "<div><a href='test'>Link</a> text <b>bold</b></div>"
99 result = hackernews._strip_html(html_text)
100
101 assert result == "Link text bold"
102
103
104 def test_strip_html_entities():
105 """Test HTML entity decoding and tag stripping."""
106 html_text = "Text &amp; &quot;test&quot;"
107 result = hackernews._strip_html(html_text)
108
109 # Entities are decoded
110 assert "&" in result or "test" in result
111
112 # === Tests for _title_matches_query() ===
113
114
115 def test_title_matches_query_basic():
116 """Test basic query matching."""
117 title = "New AI framework for developers"
118 query = "AI framework"
119
120 assert hackernews._title_matches_query(title, query) is True
121
122
123 def test_title_matches_query_case_insensitive():
124 """Test that matching is case-insensitive."""
125 title = "NEW AI FRAMEWORK"
126 query = "ai framework"
127
128 assert hackernews._title_matches_query(title, query) is True
129
130
131 def test_title_matches_query_with_prefix():
132 """Test matching with HN prefix stripped."""
133 title = "Show HN: My new AI framework"
134 query = "AI framework"
135
136 # Should match "AI framework" in the content, not the "Show HN:" prefix
137 assert hackernews._title_matches_query(title, query) is True
138
139
140 def test_title_matches_query_prefix_only():
141 """Test that matching prefix-only returns False."""
142 title = "Show HN: Something else entirely"
143 query = "Show HN"
144
145 # "Show HN" is a prefix, not real content
146 # After stripping, "Show HN" won't be in the stripped title
147 assert hackernews._title_matches_query(title, query) is False
148
149
150 def test_title_matches_query_empty_query():
151 """Test that empty query always matches."""
152 title = "Any title"
153 query = ""
154
155 assert hackernews._title_matches_query(title, query) is True
156
157
158 def test_title_matches_query_partial_match():
159 """Any-word matching: at least one query token in title is enough.
160
161 Previously required *all* tokens, which killed every hit on multi-keyword
162 theme queries like 'claude, personal agents, agentic infra' since no real
163 HN title contains all 5 tokens verbatim. Token-overlap relevance at parse
164 time still demotes weak matches, so the loosened gate is safe.
165 """
166 title = "New AI framework"
167 query = "AI blockchain"
168
169 # "AI" matches as a whole word, even though "blockchain" doesn't appear
170 assert hackernews._title_matches_query(title, query) is True
171
172
173 def test_title_matches_query_no_token_in_title():
174 """If no query token appears in the title at all, reject."""
175 assert hackernews._title_matches_query("New rust compiler", "AI blockchain") is False
176
177
178 def test_title_matches_query_word_boundary_not_substring():
179 """Short tokens must match on word boundaries, not as substrings.
180
181 Without word-boundary matching, 'ai' would falsely match 'email',
182 'rail', 'artists', etc.
183 """
184 # 'ai' as a substring of 'email' must not match
185 assert hackernews._title_matches_query("New email service", "ai blockchain") is False
186 # 'ai' as a whole word does match
187 assert hackernews._title_matches_query("Cool AI tool launched", "ai blockchain") is True
188
189
190 def test_title_matches_query_flattens_hyphens_and_commas():
191 """Query tokens split on hyphens/commas the same way search_hackernews
192 flattens them, so the post-filter stays aligned with what Algolia saw."""
193 # query 'ts-bun-node' flattens to ['ts', 'bun', 'node']; title contains 'bun'
194 assert hackernews._title_matches_query("Bun 1.2 released", "ts-bun-node") is True
195 # query 'rust, go, zig' flattens; title contains 'go'
196 assert hackernews._title_matches_query("Go 1.24 generics update", "rust, go, zig") is True
197
198 # === Tests for search_hackernews() ===
199
200 @patch('lib.hackernews.http.request')
201
202
203 def test_search_hackernews_basic(mock_request):
204 """Test basic HN search."""
205 mock_request.return_value = {
206 "hits": [create_mock_hit()],
207 "nbHits": 1,
208 }
209
210 result = hackernews.search_hackernews(
211 "AI framework",
212 "2026-01-01",
213 "2026-01-31",
214 depth="quick"
215 )
216
217 assert "hits" in result
218 assert len(result["hits"]) == 1
219 assert mock_request.called
220
221 @patch('lib.hackernews.http.request')
222
223
224 def test_search_hackernews_depth_config(mock_request):
225 """Test that depth parameter controls hit count."""
226 mock_request.return_value = {"hits": [], "nbHits": 0}
227
228 # Quick mode returns up to 15 hits, but overfetches before client-side
229 # engagement filtering so low-point stories do not shrink result depth.
230 hackernews.search_hackernews("test", "2026-01-01", "2026-01-31", depth="quick")
231
232 call_args = mock_request.call_args[0]
233 url = call_args[1]
234
235 expected_hits_per_page = (
236 hackernews.DEPTH_CONFIG["quick"] * hackernews.HN_OVERFETCH_MULTIPLIER
237 )
238 assert f"hitsPerPage={expected_hits_per_page}" in url
239
240 @patch('lib.hackernews.http.request')
241
242
243 def test_search_hackernews_date_filtering(mock_request):
244 """Test that date range is applied correctly."""
245 mock_request.return_value = {"hits": [], "nbHits": 0}
246
247 hackernews.search_hackernews("test", "2026-01-01", "2026-01-31", depth="quick")
248
249 call_args = mock_request.call_args[0]
250 url = call_args[1]
251
252 # Should have numeric filters for date range
253 assert "numericFilters" in url
254 assert "created_at_i" in url
255
256 @patch('lib.hackernews.http.request')
257
258
259 def test_search_hackernews_http_error_handling(mock_request):
260 """Test graceful handling of HTTP errors."""
261 from lib.http import HTTPError
262 mock_request.side_effect = HTTPError("HTTP 429: Too Many Requests")
263
264 result = hackernews.search_hackernews("test", "2026-01-01", "2026-01-31")
265
266 # Should return empty hits with error
267 assert result["hits"] == []
268 assert "error" in result
269
270 @patch('lib.hackernews.http.request')
271
272
273 def test_search_hackernews_engagement_filter(mock_request):
274 """Test that low-engagement stories are filtered client-side."""
275 mock_request.return_value = {
276 "hits": [
277 create_mock_hit(object_id="low", points=2),
278 create_mock_hit(object_id="high", points=3),
279 ],
280 "nbHits": 2,
281 }
282
283 result = hackernews.search_hackernews("test", "2026-01-01", "2026-01-31")
284
285 call_args = mock_request.call_args[0]
286 url = call_args[1]
287
288 # Algolia rejects points in numericFilters; keep only supported date filters.
289 assert "points" not in url
290 assert [hit["objectID"] for hit in result["hits"]] == ["high"]
291
292
293 @patch('lib.hackernews.http.request')
294 def test_search_hackernews_no_points_numericfilter(mock_request):
295 """numericFilters must NOT include a `points` clause.
296
297 `points` is not in the HN Algolia index's `numericAttributesForFiltering`,
298 so a `points>2` clause returns HTTP 400 ("invalid numeric attribute(points)")
299 and zero stories. Engagement is filtered client-side after overfetching
300 instead. This guards against the invalid filter being reintroduced.
301 """
302 mock_request.return_value = {"hits": [], "nbHits": 0}
303
304 hackernews.search_hackernews("test", "2026-01-01", "2026-01-31")
305
306 url = mock_request.call_args[0][1]
307
308 # Date filter stays; the invalid points filter must be gone.
309 assert "created_at_i" in url
310 assert "points" not in url
311
312
313 @patch('lib.hackernews.http.request')
314 def test_search_hackernews_truncates_after_overfetch(mock_request):
315 """Test that overfetching does not return more than the requested depth."""
316 mock_request.return_value = {
317 "hits": [
318 create_mock_hit(object_id=str(i), points=10)
319 for i in range(20)
320 ],
321 "nbHits": 20,
322 }
323
324 result = hackernews.search_hackernews("test", "2026-01-01", "2026-01-31", depth="quick")
325
326 assert len(result["hits"]) == 15
327 assert [hit["objectID"] for hit in result["hits"]] == [str(i) for i in range(15)]
328
329 # === Tests for parse_hackernews_response() ===
330
331
332 def test_parse_hackernews_response_basic():
333 """Test parsing basic Algolia response."""
334 response = {
335 "hits": [create_mock_hit(
336 object_id="123",
337 title="Test Story",
338 points=100,
339 num_comments=50
340 )]
341 }
342
343 items = hackernews.parse_hackernews_response(response)
344
345 assert len(items) == 1
346 assert items[0]["id"] == "123"
347 assert items[0]["title"] == "Test Story"
348 assert items[0]["engagement"]["points"] == 100
349 assert items[0]["engagement"]["comments"] == 50
350
351
352 def test_parse_hackernews_response_hn_url():
353 """Test that HN discussion URL is generated correctly."""
354 response = {
355 "hits": [create_mock_hit(object_id="12345")]
356 }
357
358 items = hackernews.parse_hackernews_response(response)
359
360 assert items[0]["hn_url"] == "https://news.ycombinator.com/item?id=12345"
361
362
363 def test_parse_hackernews_response_date_conversion():
364 """Test that Unix timestamp is converted to YYYY-MM-DD."""
365 ts = int(datetime(2026, 1, 15, tzinfo=timezone.utc).timestamp())
366 response = {
367 "hits": [create_mock_hit(created_at_i=ts)]
368 }
369
370 items = hackernews.parse_hackernews_response(response)
371
372 assert items[0]["date"] == "2026-01-15"
373
374
375 def test_parse_hackernews_response_missing_fields():
376 """Test handling of hits with missing optional fields."""
377 response = {
378 "hits": [{
379 "objectID": "123",
380 "title": "Test",
381 # Missing points, num_comments, created_at_i
382 }]
383 }
384
385 items = hackernews.parse_hackernews_response(response)
386
387 assert len(items) == 1
388 assert items[0]["engagement"]["points"] == 0
389 assert items[0]["engagement"]["comments"] == 0
390 assert items[0]["date"] is None
391
392
393 def test_parse_hackernews_response_relevance_scoring():
394 """Test that relevance scores are calculated."""
395 response = {
396 "hits": [
397 create_mock_hit(object_id="1", points=100),
398 create_mock_hit(object_id="2", points=50),
399 create_mock_hit(object_id="3", points=10),
400 ]
401 }
402
403 items = hackernews.parse_hackernews_response(response, query="test")
404
405 # Should have relevance scores
406 for item in items:
407 assert "relevance" in item
408 assert 0 <= item["relevance"] <= 1.0
409
410 # First item should generally have higher relevance (better rank)
411 assert items[0]["relevance"] >= items[2]["relevance"]
412
413
414 def test_parse_hackernews_response_engagement_boost():
415 """Test that high-engagement items get relevance boost."""
416 response = {
417 "hits": [
418 create_mock_hit(object_id="1", points=500, num_comments=200), # High engagement
419 create_mock_hit(object_id="2", points=10, num_comments=5), # Low engagement
420 ]
421 }
422
423 items = hackernews.parse_hackernews_response(response, query="test")
424
425 # Verify engagement is captured
426 assert items[0]["engagement"]["points"] == 500
427 assert items[1]["engagement"]["points"] == 10
428
429
430 def test_parse_hackernews_response_prefix_filtering():
431 """Test that items matching only HN prefixes are filtered."""
432 response = {
433 "hits": [
434 create_mock_hit(title="Show HN: My AI Project", object_id="1"),
435 create_mock_hit(title="Show HN: Unrelated Project", object_id="2"),
436 ]
437 }
438
439 # Query for "AI" should keep first, filter second
440 items = hackernews.parse_hackernews_response(response, query="AI")
441
442 assert len(items) == 1
443 assert items[0]["id"] == "1"
444
445
446 def test_parse_hackernews_response_empty_response():
447 """Test handling of empty response."""
448 response = {"hits": []}
449
450 items = hackernews.parse_hackernews_response(response)
451
452 assert items == []
453
454 # === Tests for engagement scoring ===
455
456
457 def test_engagement_score_calculation():
458 """Test that engagement dict contains points and comments."""
459 response = {
460 "hits": [create_mock_hit(points=150, num_comments=75)]
461 }
462
463 items = hackernews.parse_hackernews_response(response)
464
465 engagement = items[0]["engagement"]
466 assert engagement["points"] == 150
467 assert engagement["comments"] == 75
468
469
470 def test_engagement_score_zero_values():
471 """Test handling of zero engagement values."""
472 response = {
473 "hits": [{
474 "objectID": "123",
475 "title": "Test",
476 "points": None,
477 "num_comments": None,
478 }]
479 }
480
481 items = hackernews.parse_hackernews_response(response)
482
483 engagement = items[0]["engagement"]
484 assert engagement["points"] == 0
485 assert engagement["comments"] == 0
486
487
488 @pytest.mark.parametrize("points", [None, 0])
489 @patch("lib.hackernews.http.request")
490 def test_fetch_item_comments_preserves_absent_and_zero_points(mock_request, points):
491 """The adapter must distinguish an unmeasured score from a measured zero."""
492 mock_request.return_value = {
493 "children": [
494 {
495 "author": "alice",
496 "text": "A useful comment.",
497 "points": points,
498 }
499 ]
500 }
501
502 result = hackernews._fetch_item_comments("123")
503
504 assert result["comments"][0]["points"] is points
505
506
507 if __name__ == "__main__":
508 pytest.main([__file__, "-v"])
509
509 lines PYTHON