返回 last30days-skill
test_store.py
根目录 / tests / test_store.py
1 """Tests for store.py - SQLite research accumulator and watchlist storage."""
2
3 import json
4 import sqlite3
5 import tempfile
6 from datetime import datetime, timedelta, timezone
7 from pathlib import Path
8
9 import pytest
10
11 # Import the module under test
12
13 import store
14 from lib import schema
15
16 @pytest.fixture
17
18
19 def temp_db():
20 """Create a temporary database for testing."""
21 with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
22 db_path = Path(f.name)
23
24 # Override the database path
25 original_override = store._db_override
26 store._db_override = db_path
27
28 # Initialize fresh database
29 store.init_db()
30
31 yield db_path
32
33 # Cleanup
34 store._db_override = original_override
35 if db_path.exists():
36 db_path.unlink()
37
38 @pytest.fixture
39
40
41 def sample_report():
42 """Create a sample Report with multiple sources including HN and Polymarket."""
43 return schema.report_from_dict({
44 "topic": "Test Topic",
45 "range_from": "2026-01-01",
46 "range_to": "2026-04-03",
47 "generated_at": "2026-04-03T00:00:00Z",
48 "provider_runtime": {
49 "reasoning_provider": "gemini",
50 "planner_model": "gemini-2.0-flash-exp",
51 "rerank_model": "gemini-2.0-flash-exp",
52 },
53 "query_plan": {
54 "intent": "test",
55 "freshness_mode": "recent",
56 "cluster_mode": "standard",
57 "raw_topic": "test",
58 "subqueries": [],
59 "source_weights": {},
60 },
61 "clusters": [],
62 "ranked_candidates": [
63 {
64 "candidate_id": "c-r1",
65 "item_id": "R1",
66 "source": "reddit",
67 "title": "Test Reddit Post",
68 "url": "https://reddit.com/r/test/1",
69 "snippet": "Reddit snippet",
70 "subquery_labels": ["primary"],
71 "native_ranks": {"reddit": 1},
72 "local_relevance": 0.8,
73 "freshness": 100,
74 "engagement": 50.0,
75 "source_quality": 0.8,
76 "rrf_score": 1.0,
77 "final_score": 0.8,
78 "explanation": "Reddit snippet",
79 "source_items": [
80 {
81 "item_id": "R1",
82 "source": "reddit",
83 "title": "Test Reddit Post",
84 "body": "Reddit discussion content",
85 "url": "https://reddit.com/r/test/1",
86 "author": "testuser",
87 "engagement_score": 50.0,
88 "local_relevance": 0.8,
89 "snippet": "Reddit snippet",
90 }
91 ],
92 },
93 {
94 "candidate_id": "c-x1",
95 "item_id": "X1",
96 "source": "x",
97 "title": "Test X Post",
98 "url": "https://x.com/test/status/1",
99 "snippet": "X snippet",
100 "subquery_labels": ["primary"],
101 "native_ranks": {"x": 1},
102 "local_relevance": 0.85,
103 "freshness": 100,
104 "engagement": 75.0,
105 "source_quality": 0.8,
106 "rrf_score": 1.0,
107 "final_score": 0.85,
108 "explanation": "X snippet",
109 "source_items": [
110 {
111 "item_id": "X1",
112 "source": "x",
113 "title": "Test X Post",
114 "body": "X post content",
115 "url": "https://x.com/test/status/1",
116 "author": "xuser",
117 "engagement_score": 75.0,
118 "local_relevance": 0.85,
119 "snippet": "X snippet",
120 }
121 ],
122 },
123 ],
124 "items_by_source": {
125 "reddit": [
126 {
127 "item_id": "R1",
128 "source": "reddit",
129 "title": "Test Reddit Post",
130 "body": "Reddit discussion content",
131 "url": "https://reddit.com/r/test/1",
132 "author": "testuser",
133 "engagement_score": 50.0,
134 "local_relevance": 0.8,
135 "snippet": "Reddit snippet",
136 }
137 ],
138 "x": [
139 {
140 "item_id": "X1",
141 "source": "x",
142 "title": "Test X Post",
143 "body": "X post content",
144 "url": "https://x.com/test/status/1",
145 "author": "xuser",
146 "engagement_score": 75.0,
147 "local_relevance": 0.85,
148 "snippet": "X snippet",
149 }
150 ],
151 "hackernews": [
152 {
153 "item_id": "HN1",
154 "source": "hackernews",
155 "title": "Test HN Story",
156 "body": "HN story content with comments",
157 "url": "https://news.ycombinator.com/item?id=12345",
158 "author": "hnuser",
159 "engagement_score": 120.0,
160 "local_relevance": 0.9,
161 "snippet": "HN snippet",
162 }
163 ],
164 "polymarket": [
165 {
166 "item_id": "PM1",
167 "source": "polymarket",
168 "title": "Will event happen?",
169 "body": "Yes: 64% / No: 36%",
170 "url": "https://polymarket.com/event/test-event",
171 "author": None,
172 "engagement_score": 342000.0,
173 "local_relevance": 0.7,
174 "snippet": "Prediction market",
175 }
176 ],
177 },
178 "errors_by_source": {},
179 "warnings": [],
180 })
181
182 # === Tests for findings_from_report() ===
183
184
185 def test_findings_from_report_processes_all_sources(sample_report):
186 """Test that findings_from_report extracts items from all sources in items_by_source."""
187 findings = store.findings_from_report(sample_report)
188
189 # Should have 4 findings (reddit + x + hackernews + polymarket)
190 assert len(findings) == 4
191
192 # Check all sources are present
193 sources = {f["source"] for f in findings}
194 assert sources == {"reddit", "x", "hackernews", "polymarket"}
195
196
197 def test_findings_from_report_includes_hackernews(sample_report):
198 """Test that HN items are extracted correctly (PR #85 feature)."""
199 findings = store.findings_from_report(sample_report)
200
201 hn_findings = [f for f in findings if f["source"] == "hackernews"]
202 assert len(hn_findings) == 1
203
204 hn = hn_findings[0]
205 assert hn["source_url"] == "https://news.ycombinator.com/item?id=12345"
206 assert hn["source_title"] == "Test HN Story"
207 assert hn["engagement_score"] == 120.0
208 assert hn["relevance_score"] == 0.9
209 assert "HN story content" in hn["content"]
210
211
212 def test_findings_from_report_includes_polymarket(sample_report):
213 """Test that Polymarket items are extracted correctly (PR #85 feature)."""
214 findings = store.findings_from_report(sample_report)
215
216 pm_findings = [f for f in findings if f["source"] == "polymarket"]
217 assert len(pm_findings) == 1
218
219 pm = pm_findings[0]
220 assert pm["source_url"] == "https://polymarket.com/event/test-event"
221 assert pm["source_title"] == "Will event happen?"
222 assert pm["engagement_score"] == 342000.0
223 assert pm["relevance_score"] == 0.7
224 assert "Yes: 64%" in pm["content"]
225
226
227 def test_findings_from_report_respects_limit(sample_report):
228 """Test that limit parameter works correctly."""
229 findings = store.findings_from_report(sample_report, limit=2)
230
231 # Should have at most 2 items per source
232 source_counts = {}
233 for f in findings:
234 source = f["source"]
235 source_counts[source] = source_counts.get(source, 0) + 1
236
237 for count in source_counts.values():
238 assert count <= 2
239
240
241 def test_findings_from_report_handles_empty_sources():
242 """Test that empty sources in items_by_source don't cause issues."""
243 report = schema.report_from_dict({
244 "topic": "Test",
245 "range_from": "2026-01-01",
246 "range_to": "2026-04-03",
247 "generated_at": "2026-04-03T00:00:00Z",
248 "provider_runtime": {
249 "reasoning_provider": "gemini",
250 "planner_model": "gemini-2.0-flash-exp",
251 "rerank_model": "gemini-2.0-flash-exp",
252 },
253 "query_plan": {
254 "intent": "test",
255 "freshness_mode": "recent",
256 "cluster_mode": "standard",
257 "raw_topic": "test",
258 "subqueries": [],
259 "source_weights": {},
260 },
261 "clusters": [],
262 "ranked_candidates": [],
263 "items_by_source": {
264 "reddit": [],
265 "x": [],
266 "hackernews": [],
267 "polymarket": [],
268 },
269 "errors_by_source": {},
270 "warnings": [],
271 })
272
273 findings = store.findings_from_report(report)
274 assert len(findings) == 0
275
276
277 def test_findings_from_report_handles_missing_fields():
278 """Test that missing optional fields (author, snippet) are handled gracefully."""
279 report = schema.report_from_dict({
280 "topic": "Test",
281 "range_from": "2026-01-01",
282 "range_to": "2026-04-03",
283 "generated_at": "2026-04-03T00:00:00Z",
284 "provider_runtime": {
285 "reasoning_provider": "gemini",
286 "planner_model": "gemini-2.0-flash-exp",
287 "rerank_model": "gemini-2.0-flash-exp",
288 },
289 "query_plan": {
290 "intent": "test",
291 "freshness_mode": "recent",
292 "cluster_mode": "standard",
293 "raw_topic": "test",
294 "subqueries": [],
295 "source_weights": {},
296 },
297 "clusters": [],
298 "ranked_candidates": [],
299 "items_by_source": {
300 "hackernews": [
301 {
302 "item_id": "R1",
303 "source": "hackernews",
304 "title": "Test",
305 "body": "Content",
306 "url": "https://news.ycombinator.com/item?id=1",
307 "author": None, # Missing author
308 "engagement_score": None, # Missing engagement
309 "local_relevance": None, # Missing relevance
310 "snippet": None, # Missing snippet
311 }
312 ],
313 },
314 "errors_by_source": {},
315 "warnings": [],
316 })
317
318 findings = store.findings_from_report(report)
319 assert len(findings) == 1
320
321 f = findings[0]
322 assert f["author"] == ""
323 assert f["engagement_score"] == 0.0
324 assert f["relevance_score"] == 0.5
325 assert f["summary"] == "Content" # Falls back to body
326
327 # === Tests for store_findings() ===
328
329
330 def test_store_findings_basic(temp_db, sample_report):
331 """Test basic storage of findings."""
332 topic = store.add_topic("Test Topic")
333 run_id = store.record_run(topic["id"], source_mode="v3")
334
335 findings = store.findings_from_report(sample_report)
336 counts = store.store_findings(run_id, topic["id"], findings)
337
338 assert counts["new"] == 4
339 assert counts["updated"] == 0
340
341 # Verify in database
342 conn = sqlite3.connect(str(temp_db))
343 total = conn.execute("SELECT COUNT(*) FROM findings").fetchone()[0]
344 assert total == 4
345 conn.close()
346
347
348 def test_store_findings_deduplicates_by_url(temp_db, sample_report):
349 """Test that duplicate URLs are detected and updated, not duplicated."""
350 topic = store.add_topic("Test Topic")
351 run_id = store.record_run(topic["id"], source_mode="v3")
352
353 findings = store.findings_from_report(sample_report)
354
355 # Store once
356 counts1 = store.store_findings(run_id, topic["id"], findings)
357 assert counts1["new"] == 4
358 assert counts1["updated"] == 0
359
360 # Store again (same URLs)
361 counts2 = store.store_findings(run_id, topic["id"], findings)
362 assert counts2["new"] == 0
363 assert counts2["updated"] == 4
364
365 # Verify total count didn't double
366 conn = sqlite3.connect(str(temp_db))
367 total = conn.execute("SELECT COUNT(*) FROM findings").fetchone()[0]
368 assert total == 4
369 conn.close()
370
371
372 def test_store_findings_updates_engagement_on_resighting(temp_db, sample_report):
373 """Test that re-sighting a finding updates engagement score if higher."""
374 topic = store.add_topic("Test Topic")
375 run_id = store.record_run(topic["id"], source_mode="v3")
376
377 findings = store.findings_from_report(sample_report)
378
379 # Store with initial engagement
380 store.store_findings(run_id, topic["id"], findings)
381
382 # Modify engagement score for HN finding
383 hn_finding = next(f for f in findings if f["source"] == "hackernews")
384 hn_finding["engagement_score"] = 200.0 # Higher than original 120.0
385
386 # Store again
387 store.store_findings(run_id, topic["id"], [hn_finding])
388
389 # Verify engagement was updated
390 conn = sqlite3.connect(str(temp_db))
391 score = conn.execute(
392 "SELECT engagement_score FROM findings WHERE source='hackernews'"
393 ).fetchone()[0]
394 assert score == 200.0
395 conn.close()
396
397
398 def test_store_findings_increments_sighting_count(temp_db, sample_report):
399 """Test that re-sighting increments sighting_count."""
400 topic = store.add_topic("Test Topic")
401 run_id = store.record_run(topic["id"], source_mode="v3")
402
403 findings = store.findings_from_report(sample_report)
404
405 # Store once
406 store.store_findings(run_id, topic["id"], findings)
407
408 # Store again
409 store.store_findings(run_id, topic["id"], findings)
410
411 # Verify sighting_count
412 conn = sqlite3.connect(str(temp_db))
413 counts = conn.execute(
414 "SELECT sighting_count FROM findings"
415 ).fetchall()
416
417 for (count,) in counts:
418 assert count == 2 # Seen twice
419 conn.close()
420
421
422 def test_store_findings_upserts_on_concurrent_duplicate_url(temp_db, monkeypatch):
423 """A stale dedup read (a concurrent run that inserted the same URL between
424 our SELECT and INSERT) must upsert, not raise IntegrityError and lose the
425 whole batch. Regression for the SELECT-then-INSERT race in store_findings."""
426 topic = store.add_topic("Test Topic")
427 finding = {
428 "source": "reddit",
429 "source_url": "https://reddit.com/race",
430 "source_title": "Race",
431 "engagement_score": 5.0,
432 }
433
434 # First run inserts the URL.
435 run1 = store.record_run(topic["id"], source_mode="v3")
436 store.store_findings(run1, topic["id"], [finding])
437
438 # Force the dedup lookup to miss the now-existing URL, so store_findings
439 # takes the INSERT path for a row that is already present — exactly what a
440 # racing writer's stale read produces.
441 real_connect = store._connect
442 dedup_prefix = (
443 "SELECT id, source_url, engagement_score FROM findings WHERE source_url IN"
444 )
445
446 class StaleReadConn:
447 def __init__(self, conn):
448 self._conn = conn
449
450 def execute(self, sql, params=()):
451 if sql.strip().startswith(dedup_prefix):
452 return self._conn.execute(
453 "SELECT id, source_url, engagement_score FROM findings WHERE 0"
454 )
455 return self._conn.execute(sql, params)
456
457 def __getattr__(self, name):
458 return getattr(self._conn, name)
459
460 monkeypatch.setattr(
461 store, "_connect", lambda *a, **k: StaleReadConn(real_connect(*a, **k))
462 )
463
464 run2 = store.record_run(topic["id"], source_mode="v3")
465 # Without ON CONFLICT this raises sqlite3.IntegrityError on the UNIQUE
466 # source_url and rolls back the batch.
467 counts = store.store_findings(run2, topic["id"], [{**finding, "engagement_score": 9.0}])
468
469 # The conflict-resolved row is an update, not a new finding. The counters
470 # must reflect that, not inflate findings_new.
471 assert counts == {"new": 0, "updated": 1}
472
473 conn = sqlite3.connect(str(temp_db))
474 rows = conn.execute(
475 "SELECT engagement_score, sighting_count FROM findings WHERE source_url = ?",
476 ("https://reddit.com/race",),
477 ).fetchall()
478 run_counts = conn.execute(
479 "SELECT findings_new, findings_updated FROM research_runs WHERE id = ?",
480 (run2,),
481 ).fetchone()
482 conn.close()
483
484 assert len(rows) == 1 # not duplicated, not crashed
485 assert rows[0][0] == 9.0 # engagement upgraded via max()
486 assert rows[0][1] == 2 # sighting_count bumped by the conflict update
487 assert run_counts == (0, 1) # research_runs counters not inflated
488
489
490 def test_store_findings_skips_items_without_url(temp_db):
491 """Test that findings without URLs are skipped."""
492 topic = store.add_topic("Test Topic")
493 run_id = store.record_run(topic["id"], source_mode="v3")
494
495 findings = [
496 {
497 "source": "reddit",
498 "source_url": None, # Missing URL
499 "source_title": "Test",
500 "content": "Content",
501 },
502 {
503 "source": "reddit",
504 "source_url": "https://reddit.com/1", # Has URL
505 "source_title": "Test 2",
506 "content": "Content 2",
507 },
508 ]
509
510 counts = store.store_findings(run_id, topic["id"], findings)
511
512 # Only the one with URL should be stored
513 assert counts["new"] == 1
514
515
516 def test_init_db_creates_finding_sightings_table(temp_db):
517 """Test that the per-run sightings ledger is available on fresh databases."""
518 conn = sqlite3.connect(str(temp_db))
519 table = conn.execute(
520 "SELECT name FROM sqlite_master WHERE type='table' AND name='finding_sightings'"
521 ).fetchone()
522 columns = {
523 row[1]: row[3]
524 for row in conn.execute("PRAGMA table_info(finding_sightings)").fetchall()
525 }
526 conn.close()
527
528 assert table is not None
529 assert columns["finding_id"] == 1
530
531
532 def test_store_findings_records_sightings_for_new_findings(temp_db):
533 """Test that each stored finding is linked to the run that observed it."""
534 topic = store.add_topic("Test Topic")
535 run_id = store.record_run(topic["id"], source_mode="v3")
536 findings = [
537 {
538 "source": "reddit",
539 "source_url": "https://reddit.com/1",
540 "source_title": "Reddit 1",
541 "content": "Content 1",
542 "engagement_score": 10.0,
543 "relevance_score": 0.7,
544 },
545 {
546 "source": "x",
547 "source_url": "https://x.com/a/status/1",
548 "source_title": "X 1",
549 "content": "Content 2",
550 "engagement_score": 20.0,
551 "relevance_score": 0.8,
552 },
553 ]
554
555 store.store_findings(run_id, topic["id"], findings)
556
557 sightings = store.get_sightings_for_run(topic["id"], run_id)
558 assert [s["source_url"] for s in sightings] == [
559 "https://reddit.com/1",
560 "https://x.com/a/status/1",
561 ]
562 assert {s["source"] for s in sightings} == {"reddit", "x"}
563
564
565 def test_store_findings_records_sightings_for_resighted_findings(temp_db):
566 """Test that a re-seen finding is recorded for each run that observes it."""
567 topic = store.add_topic("Test Topic")
568 first_run_id = store.record_run(topic["id"], source_mode="v3")
569 second_run_id = store.record_run(topic["id"], source_mode="v3")
570 finding = {
571 "source": "reddit",
572 "source_url": "https://reddit.com/1",
573 "source_title": "Reddit 1",
574 "content": "Content",
575 "engagement_score": 10.0,
576 "relevance_score": 0.7,
577 }
578
579 store.store_findings(first_run_id, topic["id"], [finding])
580 store.store_findings(second_run_id, topic["id"], [{**finding, "engagement_score": 15.0}])
581
582 first_sightings = store.get_sightings_for_run(topic["id"], first_run_id)
583 second_sightings = store.get_sightings_for_run(topic["id"], second_run_id)
584
585 assert len(first_sightings) == 1
586 assert len(second_sightings) == 1
587 assert first_sightings[0]["source_url"] == second_sightings[0]["source_url"]
588 assert second_sightings[0]["engagement_score"] == 15.0
589
590
591 def test_store_findings_sightings_are_idempotent_per_run(temp_db):
592 """Test that storing the same finding twice for one run does not duplicate sightings."""
593 topic = store.add_topic("Test Topic")
594 run_id = store.record_run(topic["id"], source_mode="v3")
595 finding = {
596 "source": "reddit",
597 "source_url": "https://reddit.com/1",
598 "source_title": "Reddit 1",
599 "content": "Content",
600 "engagement_score": 10.0,
601 "relevance_score": 0.7,
602 }
603
604 store.store_findings(run_id, topic["id"], [finding])
605 store.store_findings(run_id, topic["id"], [finding])
606
607 sightings = store.get_sightings_for_run(topic["id"], run_id)
608 assert len(sightings) == 1
609
610
611 def test_store_findings_updates_existing_sighting_for_same_run(temp_db):
612 """Test that retrying a run refreshes its sighting snapshot instead of freezing it."""
613 topic = store.add_topic("Test Topic")
614 run_id = store.record_run(topic["id"], source_mode="v3")
615 finding = {
616 "source": "reddit",
617 "source_url": "https://reddit.com/1",
618 "source_title": "Reddit 1",
619 "content": "Content",
620 "engagement_score": 10.0,
621 "relevance_score": 0.7,
622 }
623
624 store.store_findings(run_id, topic["id"], [finding])
625 store.store_findings(
626 run_id,
627 topic["id"],
628 [{**finding, "source_title": "Reddit 1 updated", "engagement_score": 15.0}],
629 )
630
631 sightings = store.get_sightings_for_run(topic["id"], run_id)
632 assert len(sightings) == 1
633 assert sightings[0]["source_title"] == "Reddit 1 updated"
634 assert sightings[0]["engagement_score"] == 15.0
635
636
637 def test_get_latest_completed_runs_returns_newest_completed_only(temp_db):
638 """Test latest-run lookup ignores failed runs and orders newest first."""
639 topic = store.add_topic("Test Topic")
640 old_run_id = store.record_run(topic["id"], source_mode="v3", status="completed")
641 store.record_run(topic["id"], source_mode="v3", status="failed")
642 latest_run_id = store.record_run(topic["id"], source_mode="v3", status="completed")
643
644 runs = store.get_latest_completed_runs(topic["id"], limit=2)
645
646 assert [run["id"] for run in runs] == [latest_run_id, old_run_id]
647
648
649 def test_compute_topic_delta_compares_latest_two_runs(temp_db):
650 """Test watchlist delta classification using per-run sightings."""
651 topic = store.add_topic("Test Topic")
652 previous_run_id = store.record_run(topic["id"], source_mode="v3", status="completed")
653 store.store_findings(previous_run_id, topic["id"], [
654 {
655 "source": "reddit",
656 "source_url": "https://reddit.com/continued",
657 "source_title": "Continued",
658 "content": "Still present",
659 "engagement_score": 10.0,
660 "relevance_score": 0.7,
661 },
662 {
663 "source": "x",
664 "source_url": "https://x.com/dropped/status/1",
665 "source_title": "Dropped",
666 "content": "Dropped this run",
667 "engagement_score": 20.0,
668 "relevance_score": 0.8,
669 },
670 ])
671 current_run_id = store.record_run(topic["id"], source_mode="v3", status="completed")
672 store.store_findings(current_run_id, topic["id"], [
673 {
674 "source": "reddit",
675 "source_url": "https://reddit.com/continued",
676 "source_title": "Continued",
677 "content": "Still present",
678 "engagement_score": 15.0,
679 "relevance_score": 0.7,
680 },
681 {
682 "source": "github",
683 "source_url": "https://github.com/example/new",
684 "source_title": "New",
685 "content": "New this run",
686 "engagement_score": 30.0,
687 "relevance_score": 0.9,
688 },
689 ])
690
691 delta = store.compute_topic_delta(topic["id"])
692
693 assert delta["status"] == "ok"
694 assert delta["current_run_id"] == current_run_id
695 assert delta["previous_run_id"] == previous_run_id
696 assert delta["new"] == 1
697 assert delta["continued"] == 1
698 assert delta["dropped"] == 1
699 assert [f["source_url"] for f in delta["findings"]["new"]] == ["https://github.com/example/new"]
700 assert [f["source_url"] for f in delta["findings"]["continued"]] == ["https://reddit.com/continued"]
701 assert [f["source_url"] for f in delta["findings"]["dropped"]] == ["https://x.com/dropped/status/1"]
702 assert delta["sources"] == {
703 "github": {"new": 1, "continued": 0, "dropped": 0},
704 "reddit": {"new": 0, "continued": 1, "dropped": 0},
705 "x": {"new": 0, "continued": 0, "dropped": 1},
706 }
707
708
709 def test_compute_topic_delta_requires_two_completed_runs(temp_db):
710 """Test that delta reports insufficient history before two successful runs."""
711 topic = store.add_topic("Test Topic")
712 store.record_run(topic["id"], source_mode="v3", status="completed")
713
714 delta = store.compute_topic_delta(topic["id"])
715
716 assert delta["status"] == "insufficient_history"
717 assert "Need at least two completed runs" in delta["message"]
718
719
720 def test_update_validates_allowed_columns(temp_db, sample_report):
721 """Test update_run/update_finding accept valid keys and reject invalid keys."""
722 topic = store.add_topic("Test Topic")
723 run_id = store.record_run(topic["id"], source_mode="v3")
724
725 # Valid run update key should not raise
726 store.update_run(run_id, status="failed")
727
728 findings = store.findings_from_report(sample_report)
729 store.store_findings(run_id, topic["id"], findings[:1])
730
731 conn = sqlite3.connect(str(temp_db))
732 finding_id = conn.execute("SELECT id FROM findings LIMIT 1").fetchone()[0]
733 conn.close()
734
735 # Valid finding update key should not raise
736 store.update_finding(finding_id, dismissed=1)
737
738 with pytest.raises(ValueError, match="invalid_run_column"):
739 store.update_run(run_id, invalid_run_column="x")
740
741 with pytest.raises(ValueError, match="invalid_finding_column"):
742 store.update_finding(finding_id, invalid_finding_column="x")
743
744 # === Tests for topic management ===
745
746
747 def test_add_topic(temp_db):
748 """Test adding a topic."""
749 topic = store.add_topic("Test Topic", schedule="0 8 * * *")
750
751 assert topic["name"] == "Test Topic"
752 assert topic["schedule"] == "0 8 * * *"
753 assert topic["enabled"] == 1
754
755
756 def test_add_topic_with_search_queries(temp_db):
757 """Test adding a topic with custom search queries."""
758 topic = store.add_topic(
759 "Test Topic",
760 search_queries=["query1", "query2"],
761 schedule="0 8 * * *",
762 )
763
764 assert topic["name"] == "Test Topic"
765 assert json.loads(topic["search_queries"]) == ["query1", "query2"]
766
767
768 def test_remove_topic_cascades_findings(temp_db, sample_report):
769 """Test that removing a topic deletes its findings and runs."""
770 topic = store.add_topic("Test Topic")
771 run_id = store.record_run(topic["id"], source_mode="v3")
772 findings = store.findings_from_report(sample_report)
773 store.store_findings(run_id, topic["id"], findings)
774
775 # Verify data exists
776 conn = sqlite3.connect(str(temp_db))
777 finding_count = conn.execute("SELECT COUNT(*) FROM findings").fetchone()[0]
778 run_count = conn.execute("SELECT COUNT(*) FROM research_runs").fetchone()[0]
779 assert finding_count == 4
780 assert run_count == 1
781 conn.close()
782
783 # Remove topic
784 removed = store.remove_topic("Test Topic")
785 assert removed is True
786
787 # Verify cascade delete
788 conn = sqlite3.connect(str(temp_db))
789 finding_count = conn.execute("SELECT COUNT(*) FROM findings").fetchone()[0]
790 run_count = conn.execute("SELECT COUNT(*) FROM research_runs").fetchone()[0]
791 assert finding_count == 0
792 assert run_count == 0
793 conn.close()
794
795
796 def test_list_topics(temp_db):
797 """Test listing topics with stats."""
798 # Add multiple topics
799 store.add_topic("Topic 1")
800 store.add_topic("Topic 2")
801 store.add_topic("Topic 3")
802
803 topics = store.list_topics()
804
805 assert len(topics) == 3
806 assert {t["name"] for t in topics} == {"Topic 1", "Topic 2", "Topic 3"}
807
808 # Check that stats fields are present
809 for topic in topics:
810 assert "finding_count" in topic
811 assert "last_run" in topic
812 assert "last_status" in topic
813
814 # === Tests for get_new_findings() ===
815
816
817 def test_get_new_findings(temp_db, sample_report):
818 """Test retrieving new findings for a topic."""
819 topic = store.add_topic("Test Topic")
820 run_id = store.record_run(topic["id"], source_mode="v3")
821 findings = store.findings_from_report(sample_report)
822 store.store_findings(run_id, topic["id"], findings)
823
824 new_findings = store.get_new_findings(topic["id"])
825
826 assert len(new_findings) == 4
827 sources = {f["source"] for f in new_findings}
828 assert "hackernews" in sources
829 assert "polymarket" in sources
830
831
832 def test_get_new_findings_filters_by_date(temp_db, sample_report):
833 """Test that since parameter filters findings correctly."""
834 topic = store.add_topic("Test Topic")
835 run_id = store.record_run(topic["id"], source_mode="v3")
836 findings = store.findings_from_report(sample_report)
837 store.store_findings(run_id, topic["id"], findings)
838
839 # Use UTC because store writes first_seen via SQLite's datetime('now') (UTC).
840 # Local-time math here would flake near midnight UTC.
841 tomorrow = (datetime.now(timezone.utc) + timedelta(days=1)).strftime("%Y-%m-%d")
842 new_findings = store.get_new_findings(topic["id"], since=tomorrow)
843
844 assert len(new_findings) == 0
845
846 yesterday = (datetime.now(timezone.utc) - timedelta(days=1)).strftime("%Y-%m-%d")
847 new_findings = store.get_new_findings(topic["id"], since=yesterday)
848
849 assert len(new_findings) == 4
850
851 # === Tests for the discovery topic queue (migration 3, U6) ===
852
853
854 def test_init_db_reaches_migration_3_with_discovery_topics_table(temp_db):
855 """A fresh database lands on migration 3 with the queue table present."""
856 conn = sqlite3.connect(str(temp_db))
857 version = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]
858 table = conn.execute(
859 "SELECT name FROM sqlite_master WHERE type='table' AND name='discovery_topics'"
860 ).fetchone()
861 columns = {
862 row[1] for row in conn.execute("PRAGMA table_info(discovery_topics)").fetchall()
863 }
864 conn.close()
865
866 assert version == 3
867 assert table is not None
868 assert {
869 "id", "name", "normalized_name", "entity_key", "domain",
870 "first_surfaced", "last_surfaced", "surface_count", "status",
871 "covered_at", "last_run_ref",
872 } <= columns
873
874
875 def test_migration_2_db_upgrades_to_3_without_data_loss(tmp_path, monkeypatch):
876 """A database built at migration 2 gains the queue table without losing rows."""
877 db_path = tmp_path / "research.db"
878 monkeypatch.setattr(store, "_db_override", db_path)
879 full_migrations = store.MIGRATIONS
880
881 monkeypatch.setattr(store, "MIGRATIONS", {2: full_migrations[2]})
882 store.init_db()
883 topic = store.add_topic("Preexisting Topic")
884 run_id = store.record_run(topic["id"], source_mode="v3")
885 store.store_findings(run_id, topic["id"], [{
886 "source": "reddit",
887 "source_url": "https://reddit.com/preexisting",
888 "source_title": "Preexisting",
889 "content": "Content",
890 }])
891
892 conn = sqlite3.connect(str(db_path))
893 assert conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0] == 2
894 assert conn.execute(
895 "SELECT name FROM sqlite_master WHERE type='table' AND name='discovery_topics'"
896 ).fetchone() is None
897 conn.close()
898
899 monkeypatch.setattr(store, "MIGRATIONS", full_migrations)
900 store.init_db()
901
902 conn = sqlite3.connect(str(db_path))
903 assert conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0] == 3
904 assert conn.execute(
905 "SELECT name FROM sqlite_master WHERE type='table' AND name='discovery_topics'"
906 ).fetchone() is not None
907 assert conn.execute("SELECT COUNT(*) FROM topics").fetchone()[0] == 1
908 assert conn.execute("SELECT COUNT(*) FROM findings").fetchone()[0] == 1
909 conn.close()
910
911
912 def test_record_discovery_surfacing_inserts_fresh_row(temp_db):
913 row = store.record_discovery_surfacing(
914 "Gemma 4 chat templates",
915 domain="AI agents",
916 run_ref="discover:AI agents:2026-07-20T00:00:00+00:00",
917 as_of="2026-07-20",
918 )
919
920 assert row["name"] == "Gemma 4 chat templates"
921 assert row["normalized_name"] == "gemma 4 chat templates"
922 assert row["domain"] == "AI agents"
923 assert row["surface_count"] == 1
924 assert row["first_surfaced"] == "2026-07-20"
925 assert row["last_surfaced"] == "2026-07-20"
926 assert row["status"] == "surfaced"
927 assert row["covered_at"] is None
928 assert row["entity_key"] # computed at write time from entity tokens
929
930
931 def test_record_discovery_surfacing_resurfacing_increments_count(temp_db):
932 store.record_discovery_surfacing(
933 "Gemma 4 chat templates", domain="AI agents", run_ref="run-1", as_of="2026-07-13",
934 )
935 # Same normalized identity: casefold, punctuation-stripped, whitespace-collapsed.
936 row = store.record_discovery_surfacing(
937 " GEMMA 4 chat, templates! ", domain="AI agents", run_ref="run-2", as_of="2026-07-20",
938 )
939
940 assert row["surface_count"] == 2
941 assert row["first_surfaced"] == "2026-07-13"
942 assert row["last_surfaced"] == "2026-07-20"
943 assert row["last_run_ref"] == "run-2"
944
945 conn = sqlite3.connect(str(temp_db))
946 assert conn.execute("SELECT COUNT(*) FROM discovery_topics").fetchone()[0] == 1
947 conn.close()
948
949
950 def test_record_discovery_surfacing_blank_domain_resurfacing_preserves_prior_domain(temp_db):
951 """A bare global-trending resurfacing (domain='') must not blank a domain
952 recorded by an earlier, domain-scoped surfacing of the same topic."""
953 store.record_discovery_surfacing(
954 "Gemma 4 chat templates", domain="AI agents", run_ref="run-1", as_of="2026-07-13",
955 )
956
957 row = store.record_discovery_surfacing(
958 "Gemma 4 chat templates", domain="", run_ref="run-2", as_of="2026-07-20",
959 )
960
961 assert row["domain"] == "AI agents"
962 assert row["surface_count"] == 2
963
964
965 def test_record_discovery_surfacing_none_domain_resurfacing_preserves_prior_domain(temp_db):
966 """If the API is called with domain=None (bypassing the str default),
967 that must not bind NULL and blank a previously recorded domain either."""
968 store.record_discovery_surfacing(
969 "Gemma 4 chat templates", domain="AI agents", run_ref="run-1", as_of="2026-07-13",
970 )
971
972 row = store.record_discovery_surfacing(
973 "Gemma 4 chat templates", domain=None, run_ref="run-2", as_of="2026-07-20",
974 )
975
976 assert row["domain"] == "AI agents"
977 assert row["surface_count"] == 2
978
979
980 def test_record_discovery_surfacing_new_nonempty_domain_still_updates(temp_db):
981 """A resurfacing that supplies a NEW non-empty domain should still update
982 the stored domain - only a blank/None incoming domain preserves history."""
983 store.record_discovery_surfacing(
984 "Gemma 4 chat templates", domain="AI agents", run_ref="run-1", as_of="2026-07-13",
985 )
986
987 row = store.record_discovery_surfacing(
988 "Gemma 4 chat templates", domain="LLM tooling", run_ref="run-2", as_of="2026-07-20",
989 )
990
991 assert row["domain"] == "LLM tooling"
992 assert row["surface_count"] == 2
993
994
995 def test_match_discovery_topic_exact_normalized_name(temp_db):
996 store.record_discovery_surfacing(
997 "Gemma 4 chat templates", domain="AI agents", run_ref="run-1", as_of="2026-07-20",
998 )
999
1000 match = store.match_discovery_topic("gemma 4 CHAT templates!")
1001
1002 assert match is not None
1003 assert match["normalized_name"] == "gemma 4 chat templates"
1004
1005
1006 def test_match_discovery_topic_cross_matches_near_duplicates_without_merging(temp_db):
1007 """Two angles on the same subject cross-match for annotation, but recording
1008 both keeps both rows - matching NEVER merges."""
1009 store.record_discovery_surfacing(
1010 "Gemma 4 chat templates", domain="AI agents", run_ref="run-1", as_of="2026-07-13",
1011 )
1012
1013 match = store.match_discovery_topic("Gemma 4 tool calling fixes")
1014 assert match is not None
1015 assert match["normalized_name"] == "gemma 4 chat templates"
1016
1017 store.record_discovery_surfacing(
1018 "Gemma 4 tool calling fixes", domain="AI agents", run_ref="run-2", as_of="2026-07-20",
1019 )
1020
1021 conn = sqlite3.connect(str(temp_db))
1022 rows = conn.execute(
1023 "SELECT normalized_name, surface_count FROM discovery_topics ORDER BY id"
1024 ).fetchall()
1025 conn.close()
1026 assert rows == [
1027 ("gemma 4 chat templates", 1),
1028 ("gemma 4 tool calling fixes", 1),
1029 ]
1030
1031
1032 def test_match_discovery_topic_unrelated_names_do_not_match(temp_db):
1033 store.record_discovery_surfacing(
1034 "Gemma 4 chat templates", domain="AI agents", run_ref="run-1", as_of="2026-07-20",
1035 )
1036
1037 assert store.match_discovery_topic("OpenAI Agent SDK pricing") is None
1038 assert store.match_discovery_topic("Rust async runtime debates") is None
1039
1040
1041 def test_match_discovery_topic_empty_queue_returns_none(temp_db):
1042 assert store.match_discovery_topic("Anything at all") is None
1043
1044
1045 def test_record_discovery_surfacing_inherit_covered_creates_row_born_covered(temp_db):
1046 """A fresh row recorded with inherit_covered_at is born covered - the
1047 caller passes it when the name fuzzy-matched an already-covered prior,
1048 so a user's covered mark survives judge naming drift (review #7)."""
1049 row = store.record_discovery_surfacing(
1050 "Gemma 4 template fixes", domain="AI agents", run_ref="run-2", as_of="2026-07-20",
1051 inherit_covered_at="2026-07-14",
1052 )
1053
1054 assert row["status"] == "covered"
1055 assert row["covered_at"] == "2026-07-14"
1056 assert row["surface_count"] == 1
1057
1058
1059 def test_record_discovery_surfacing_inherit_never_alters_existing_row_status(temp_db):
1060 """The ON CONFLICT path ignores inherit_covered_at: an existing surfaced
1061 row stays surfaced, and an existing covered row stays covered with its
1062 original covered_at."""
1063 store.record_discovery_surfacing(
1064 "Gemma 4 chat templates", domain="AI agents", run_ref="run-1", as_of="2026-07-13",
1065 )
1066 row = store.record_discovery_surfacing(
1067 "Gemma 4 chat templates", domain="AI agents", run_ref="run-2", as_of="2026-07-20",
1068 inherit_covered_at="2026-07-14",
1069 )
1070 assert row["status"] == "surfaced"
1071 assert row["covered_at"] is None
1072
1073 store.mark_discovery_covered("Gemma 4 chat templates", as_of="2026-07-21")
1074 row = store.record_discovery_surfacing(
1075 "Gemma 4 chat templates", domain="AI agents", run_ref="run-3", as_of="2026-07-22",
1076 inherit_covered_at=None,
1077 )
1078 assert row["status"] == "covered"
1079 assert row["covered_at"] == "2026-07-21"
1080
1081
1082 def test_covered_status_survives_judge_rename_across_runs(temp_db):
1083 """Flip-flop regression (review #7): cover name A; a fuzzy-matching
1084 rename B is recorded born-covered; resurfacing B exact-matches its own
1085 covered row, so the covered mark never silently evaporates."""
1086 store.record_discovery_surfacing(
1087 "Gemma 4 chat templates", domain="AI agents", run_ref="run-1", as_of="2026-07-13",
1088 )
1089 store.mark_discovery_covered("Gemma 4 chat templates", as_of="2026-07-14")
1090
1091 prior = store.match_discovery_topic("Gemma 4 template fixes")
1092 assert prior is not None and prior["status"] == "covered"
1093 store.record_discovery_surfacing(
1094 "Gemma 4 template fixes", domain="AI agents", run_ref="run-2", as_of="2026-07-20",
1095 inherit_covered_at=prior["covered_at"],
1096 )
1097
1098 exact = store.match_discovery_topic("Gemma 4 template fixes")
1099 assert exact is not None
1100 assert exact["status"] == "covered"
1101 assert exact["covered_at"] == "2026-07-14"
1102
1103
1104 def test_record_discovery_surfacing_same_run_ref_is_idempotent(temp_db):
1105 """AE6: a retry within the same run identity (e.g. --finalize re-run with
1106 a corrected angles file) never double-counts - the guarded call returns
1107 the row unchanged."""
1108 first = store.record_discovery_surfacing(
1109 "Gemma 4 chat templates", domain="AI agents", run_ref="run-1", as_of="2026-07-13",
1110 )
1111 retry = store.record_discovery_surfacing(
1112 "Gemma 4 chat templates", domain="AI agents", run_ref="run-1", as_of="2026-07-20",
1113 )
1114
1115 assert retry["surface_count"] == 1
1116 assert retry["last_surfaced"] == "2026-07-13"
1117 assert retry == first
1118
1119
1120 def test_record_discovery_surfacing_guard_is_per_run_not_global(temp_db):
1121 """A later run with a DIFFERENT run_ref still increments: the idempotency
1122 guard binds to one run identity, never to the row."""
1123 store.record_discovery_surfacing(
1124 "Gemma 4 chat templates", domain="AI agents", run_ref="run-1", as_of="2026-07-13",
1125 )
1126 store.record_discovery_surfacing(
1127 "Gemma 4 chat templates", domain="AI agents", run_ref="run-1", as_of="2026-07-13",
1128 )
1129 row = store.record_discovery_surfacing(
1130 "Gemma 4 chat templates", domain="AI agents", run_ref="run-2", as_of="2026-07-20",
1131 )
1132
1133 assert row["surface_count"] == 2
1134 assert row["last_surfaced"] == "2026-07-20"
1135 assert row["last_run_ref"] == "run-2"
1136
1137
1138 def test_record_discovery_surfacing_same_run_ref_never_touches_covered(temp_db):
1139 """The guard obeys the existing never-mutate rule: a retry against a
1140 covered row leaves status/covered_at exactly as the user set them."""
1141 store.record_discovery_surfacing(
1142 "Gemma 4 chat templates", domain="AI agents", run_ref="run-1", as_of="2026-07-13",
1143 )
1144 store.mark_discovery_covered("Gemma 4 chat templates", as_of="2026-07-14")
1145
1146 retry = store.record_discovery_surfacing(
1147 "Gemma 4 chat templates", domain="AI agents", run_ref="run-1", as_of="2026-07-20",
1148 inherit_covered_at="2026-07-19",
1149 )
1150
1151 assert retry["surface_count"] == 1
1152 assert retry["status"] == "covered"
1153 assert retry["covered_at"] == "2026-07-14"
1154
1155
1156 def test_record_discovery_surfacing_blank_run_ref_keeps_legacy_increment(temp_db):
1157 """Callers that pass no run_ref (blank) keep the pre-guard behavior:
1158 every surfacing increments. The guard only binds real run identities."""
1159 store.record_discovery_surfacing(
1160 "Gemma 4 chat templates", domain="AI agents", as_of="2026-07-13",
1161 )
1162 row = store.record_discovery_surfacing(
1163 "Gemma 4 chat templates", domain="AI agents", as_of="2026-07-20",
1164 )
1165
1166 assert row["surface_count"] == 2
1167
1168
1169 def test_mark_discovery_covered_by_exact_name(temp_db):
1170 store.record_discovery_surfacing(
1171 "Gemma 4 chat templates", domain="AI agents", run_ref="run-1", as_of="2026-07-13",
1172 )
1173
1174 row = store.mark_discovery_covered("Gemma 4 chat templates", as_of="2026-07-20")
1175
1176 assert row is not None
1177 assert row["status"] == "covered"
1178 assert row["covered_at"] == "2026-07-20"
1179
1180
1181 def test_mark_discovery_covered_unknown_name_returns_none(temp_db):
1182 store.record_discovery_surfacing(
1183 "Gemma 4 chat templates", domain="AI agents", run_ref="run-1", as_of="2026-07-13",
1184 )
1185
1186 # Exact normalized match required: a near-duplicate must NOT cover the row.
1187 assert store.mark_discovery_covered("Gemma 4 tool calling fixes", as_of="2026-07-20") is None
1188 assert store.mark_discovery_covered("No Such Topic", as_of="2026-07-20") is None
1189
1190
1191 def test_list_discovery_queue_orders_by_last_surfaced_and_filters_status(temp_db):
1192 store.record_discovery_surfacing("Older topic", domain="d", run_ref="r1", as_of="2026-07-01")
1193 store.record_discovery_surfacing("Newer topic", domain="d", run_ref="r2", as_of="2026-07-19")
1194 store.record_discovery_surfacing("Covered topic", domain="d", run_ref="r3", as_of="2026-07-10")
1195 store.mark_discovery_covered("Covered topic", as_of="2026-07-20")
1196
1197 all_rows = store.list_discovery_queue()
1198 assert [r["name"] for r in all_rows] == ["Newer topic", "Covered topic", "Older topic"]
1199
1200 surfaced = store.list_discovery_queue(status="surfaced")
1201 assert [r["name"] for r in surfaced] == ["Newer topic", "Older topic"]
1202
1203 covered = store.list_discovery_queue(status="covered")
1204 assert [r["name"] for r in covered] == ["Covered topic"]
1205
1206
1207 def test_store_findings_none_engagement_on_update_does_not_crash(temp_db):
1208 """Regression: store_findings must not TypeError when an update-path finding
1209 carries engagement_score=None.
1210
1211 .get("engagement_score", 0) only substitutes 0 for an *absent* key, so a
1212 present-but-None value reached max(None, existing) on the update branch.
1213 store_findings accepts arbitrary List[Dict[str, Any]] callers, so the
1214 boundary must stay null-safe (engagement_score is float | None in schema.py).
1215 """
1216 topic_id = store.add_topic("None Engagement Topic")["id"]
1217 url = "https://example.com/none-engagement"
1218 base = {
1219 "source": "reddit",
1220 "source_url": url,
1221 "source_title": "T",
1222 "author": "",
1223 "content": "",
1224 "summary": "",
1225 "relevance_score": 0.5,
1226 }
1227
1228 # First sighting carries a real engagement score.
1229 run1 = store.record_run(topic_id, source_mode="test", status="running")
1230 store.store_findings(run1, topic_id, [{**base, "engagement_score": 5.0}])
1231
1232 # Re-sighting the same URL with engagement_score=None takes the UPDATE branch;
1233 # before the fix this raised TypeError from max(None, 5.0).
1234 run2 = store.record_run(topic_id, source_mode="test", status="running")
1235 counts = store.store_findings(run2, topic_id, [{**base, "engagement_score": None}])
1236 assert counts["updated"] == 1
1237
1238 con = sqlite3.connect(str(temp_db))
1239 try:
1240 stored = con.execute(
1241 "SELECT engagement_score FROM findings WHERE source_url = ?", (url,)
1242 ).fetchone()[0]
1243 finally:
1244 con.close()
1245 # max(None -> 0, existing 5.0) keeps the higher real score.
1246 assert stored == 5.0
1247
1248
1249 if __name__ == "__main__":
1250 pytest.main([__file__, "-v"])
1251
1252
1253 def test_scoped_db_routes_all_store_access_and_restores(tmp_path):
1254 scoped = tmp_path / "client" / "research.db"
1255 scoped.parent.mkdir()
1256 before = store._db_override
1257
1258 with store.scoped_db(scoped):
1259 assert store._get_db_path() == scoped
1260 store.init_db()
1261
1262 assert store._db_override == before
1263 assert scoped.is_file()
1264
1265
1266 def test_scoped_db_with_none_is_a_no_op(tmp_path):
1267 before = store._db_override
1268 with store.scoped_db(None):
1269 assert store._get_db_path() == (before or store.DB_PATH)
1270 assert store._db_override == before
1271
1272
1273 def test_persist_report_with_scoped_store_writes_inside_save_dir(tmp_path, sample_report):
1274 import last30days as cli
1275
1276 shared = tmp_path / "shared.db"
1277 scoped = tmp_path / "client" / "research.db"
1278 scoped.parent.mkdir()
1279 original_override = store._db_override
1280 store._db_override = shared
1281 try:
1282 store.init_db()
1283 counts = cli.persist_report(sample_report, store_db=scoped)
1284 finally:
1285 store._db_override = original_override
1286
1287 assert counts["new"] > 0
1288 assert scoped.is_file()
1289 with sqlite3.connect(scoped) as conn:
1290 assert conn.execute("SELECT COUNT(*) FROM findings").fetchone()[0] > 0
1291 # The shared store saw no findings from the scoped run.
1292 with sqlite3.connect(shared) as conn:
1293 assert conn.execute("SELECT COUNT(*) FROM findings").fetchone()[0] == 0
1294
1294 lines PYTHON