返回 last30days-skill
test_watchlist_commands.py
根目录 / tests / test_watchlist_commands.py
1 """Tests for watchlist.py command functions."""
2
3 import json
4 import sqlite3
5 import subprocess
6 import tempfile
7 from pathlib import Path
8 from unittest.mock import Mock, patch
9
10 import pytest
11
12 import store
13 import watchlist
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 # === Tests for cmd_add() ===
39
40
41 def test_cmd_add_basic(temp_db, capsys):
42 """Test adding a topic with default schedule."""
43 args = Mock()
44 args.topic = "Test Topic"
45 args.weekly = False
46 args.schedule = None
47 args.queries = None
48
49 watchlist.cmd_add(args)
50
51 # Verify output
52 captured = capsys.readouterr()
53 output = json.loads(captured.out)
54
55 assert output["action"] == "added"
56 assert output["topic"] == "Test Topic"
57 assert "daily" in output["schedule"]
58
59
60 def test_cmd_add_with_custom_schedule(temp_db, capsys):
61 """Test adding a topic with custom schedule."""
62 args = Mock()
63 args.topic = "Test Topic"
64 args.weekly = False
65 args.schedule = "0 12 * * *"
66 args.queries = None
67
68 watchlist.cmd_add(args)
69
70 # Verify in database
71 topic = store.get_topic("Test Topic")
72 assert topic["schedule"] == "0 12 * * *"
73
74
75 def test_cmd_add_weekly(temp_db, capsys):
76 """Test adding a topic with weekly schedule."""
77 args = Mock()
78 args.topic = "Test Topic"
79 args.weekly = True
80 args.schedule = None
81 args.queries = None
82
83 watchlist.cmd_add(args)
84
85 # Verify weekly schedule
86 topic = store.get_topic("Test Topic")
87 assert topic["schedule"] == "0 8 * * 1" # Monday 8am
88
89
90 def test_cmd_add_with_search_queries(temp_db, capsys):
91 """Test adding a topic with custom search queries."""
92 args = Mock()
93 args.topic = "Test Topic"
94 args.weekly = False
95 args.schedule = None
96 args.queries = "query1, query2, query3"
97
98 watchlist.cmd_add(args)
99
100 # Verify queries stored
101 topic = store.get_topic("Test Topic")
102 queries = json.loads(topic["search_queries"])
103 assert queries == ["query1", "query2", "query3"]
104
105 # === Tests for cmd_remove() ===
106
107
108 def test_cmd_remove_existing_topic(temp_db, capsys):
109 """Test removing an existing topic."""
110 # Add a topic first
111 store.add_topic("Test Topic")
112
113 args = Mock()
114 args.topic = "Test Topic"
115
116 watchlist.cmd_remove(args)
117
118 # Verify output
119 captured = capsys.readouterr()
120 output = json.loads(captured.out)
121
122 assert output["action"] == "removed"
123 assert output["topic"] == "Test Topic"
124
125
126 def test_cmd_remove_nonexistent_topic(temp_db, capsys):
127 """Test removing a topic that doesn't exist."""
128 args = Mock()
129 args.topic = "Nonexistent Topic"
130
131 watchlist.cmd_remove(args)
132
133 # Verify output
134 captured = capsys.readouterr()
135 output = json.loads(captured.out)
136
137 assert output["action"] == "not_found"
138 assert output["topic"] == "Nonexistent Topic"
139
140 # === Tests for cmd_list() ===
141
142
143 def test_cmd_list_empty(temp_db, capsys):
144 """Test listing when no topics exist."""
145 args = Mock()
146
147 watchlist.cmd_list(args)
148
149 # Verify output
150 captured = capsys.readouterr()
151 output = json.loads(captured.out)
152
153 assert output["topics"] == []
154 assert output["budget_used"] == 0.0
155 assert output["budget_limit"] == 5.0
156
157
158 def test_cmd_list_with_topics(temp_db, capsys):
159 """Test listing with multiple topics."""
160 # Add topics
161 store.add_topic("Topic 1")
162 store.add_topic("Topic 2")
163 store.add_topic("Topic 3")
164
165 args = Mock()
166
167 watchlist.cmd_list(args)
168
169 # Verify output
170 captured = capsys.readouterr()
171 output = json.loads(captured.out)
172
173 assert len(output["topics"]) == 3
174 topic_names = {t["name"] for t in output["topics"]}
175 assert topic_names == {"Topic 1", "Topic 2", "Topic 3"}
176
177 # === Tests for cmd_delta() ===
178
179
180 def test_cmd_delta_outputs_topic_delta(temp_db, capsys):
181 """Test printing the latest watchlist delta as JSON."""
182 topic = store.add_topic("Test Topic")
183 previous_run_id = store.record_run(topic["id"], source_mode="v3", status="completed")
184 store.store_findings(previous_run_id, topic["id"], [
185 {
186 "source": "reddit",
187 "source_url": "https://reddit.com/continued",
188 "source_title": "Continued",
189 "content": "Still present",
190 }
191 ])
192 current_run_id = store.record_run(topic["id"], source_mode="v3", status="completed")
193 store.store_findings(current_run_id, topic["id"], [
194 {
195 "source": "reddit",
196 "source_url": "https://reddit.com/continued",
197 "source_title": "Continued",
198 "content": "Still present",
199 },
200 {
201 "source": "github",
202 "source_url": "https://github.com/example/new",
203 "source_title": "New",
204 "content": "New this run",
205 },
206 ])
207
208 args = Mock()
209 args.topic = "Test Topic"
210
211 watchlist.cmd_delta(args)
212
213 captured = capsys.readouterr()
214 output = json.loads(captured.out)
215
216 assert output["topic"] == "Test Topic"
217 assert output["status"] == "ok"
218 assert output["current_run_id"] == current_run_id
219 assert output["previous_run_id"] == previous_run_id
220 assert output["new"] == 1
221 assert output["continued"] == 1
222
223
224 def test_cmd_delta_unknown_topic_exits(temp_db):
225 """Test delta for an unknown topic exits with an error."""
226 args = Mock()
227 args.topic = "Missing Topic"
228
229 with pytest.raises(SystemExit):
230 watchlist.cmd_delta(args)
231
232 # === Tests for cmd_config() ===
233
234
235 def test_cmd_config_delivery(temp_db, capsys):
236 """Test configuring delivery channel."""
237 args = Mock()
238 args.key = "delivery"
239 args.value = "https://hooks.slack.com/services/TEST"
240
241 watchlist.cmd_config(args)
242
243 # Verify setting stored
244 channel = store.get_setting("delivery_channel")
245 assert channel == "https://hooks.slack.com/services/TEST"
246
247 # Verify output
248 captured = capsys.readouterr()
249 output = json.loads(captured.out)
250
251 assert output["action"] == "config"
252 assert output["key"] == "delivery_channel"
253
254
255 def test_cmd_config_delivery_rejects_non_https(temp_db):
256 """A non-https delivery channel is rejected at write time, not stored."""
257 args = Mock()
258 args.key = "delivery"
259 args.value = "http://evil.example/hooks.slack.com"
260
261 with pytest.raises(SystemExit):
262 watchlist.cmd_config(args)
263
264 assert not store.get_setting("delivery_channel")
265
266
267 def test_cmd_config_budget(temp_db, capsys):
268 """Test configuring daily budget."""
269 args = Mock()
270 args.key = "budget"
271 args.value = 10.0
272
273 watchlist.cmd_config(args)
274
275 # Verify setting stored
276 budget = store.get_setting("daily_budget")
277 assert budget == "10.0"
278
279
280 def test_cmd_config_unknown_key(temp_db):
281 """Test that unknown config key raises error."""
282 args = Mock()
283 args.key = "unknown_key"
284 args.value = "value"
285
286 with pytest.raises(SystemExit):
287 watchlist.cmd_config(args)
288
289 # === Tests for _run_topic() ===
290
291 @patch('watchlist.subprocess.run')
292
293
294 def test_run_topic_success(mock_subprocess, temp_db):
295 """Test successful topic run."""
296 topic = store.add_topic("Test Topic")
297
298 # Mock successful subprocess call
299 mock_result = Mock()
300 mock_result.returncode = 0
301 mock_result.stdout = json.dumps({
302 "topic": "Test Topic",
303 "range_from": "2026-01-01",
304 "range_to": "2026-04-03",
305 "generated_at": "2026-04-03T00:00:00Z",
306 "provider_runtime": {
307 "reasoning_provider": "gemini",
308 "planner_model": "gemini-2.0-flash-exp",
309 "rerank_model": "gemini-2.0-flash-exp",
310 },
311 "query_plan": {
312 "intent": "test",
313 "freshness_mode": "recent",
314 "cluster_mode": "standard",
315 "raw_topic": "test",
316 "subqueries": [],
317 "source_weights": {},
318 },
319 "clusters": [],
320 "ranked_candidates": [
321 {
322 "candidate_id": "c-r1",
323 "item_id": "R1",
324 "source": "reddit",
325 "title": "Test",
326 "url": "https://reddit.com/1",
327 "snippet": "Snippet",
328 "subquery_labels": ["primary"],
329 "native_ranks": {"reddit": 1},
330 "local_relevance": 0.8,
331 "freshness": 100,
332 "engagement": 50.0,
333 "source_quality": 0.8,
334 "rrf_score": 1.0,
335 "final_score": 0.8,
336 "explanation": "Snippet",
337 "source_items": [
338 {
339 "item_id": "R1",
340 "source": "reddit",
341 "title": "Test",
342 "body": "Content",
343 "url": "https://reddit.com/1",
344 "author": "user",
345 "engagement_score": 50.0,
346 "local_relevance": 0.8,
347 "snippet": "Snippet",
348 }
349 ],
350 }
351 ],
352 "items_by_source": {
353 "reddit": [
354 {
355 "item_id": "R1",
356 "source": "reddit",
357 "title": "Test",
358 "body": "Content",
359 "url": "https://reddit.com/1",
360 "author": "user",
361 "engagement_score": 50.0,
362 "local_relevance": 0.8,
363 "snippet": "Snippet",
364 }
365 ],
366 },
367 "errors_by_source": {},
368 "warnings": [],
369 })
370 mock_subprocess.return_value = mock_result
371
372 result = watchlist._run_topic(topic)
373
374 assert result["status"] == "completed"
375 assert result["new"] == 1
376 assert result["topic"] == "Test Topic"
377 argv = mock_subprocess.call_args.args[0]
378 assert "--emit=json" in argv
379 assert "--json-profile=raw" in argv
380
381 @patch('watchlist.subprocess.run')
382
383
384 def test_run_topic_failure(mock_subprocess, temp_db):
385 """Test topic run failure."""
386 topic = store.add_topic("Test Topic")
387
388 # Mock failed subprocess call
389 mock_result = Mock()
390 mock_result.returncode = 1
391 mock_result.stderr = "Error message"
392 mock_subprocess.return_value = mock_result
393
394 result = watchlist._run_topic(topic)
395
396 assert result["status"] == "failed"
397 assert "Error message" in result["error"]
398
399 @patch('watchlist.subprocess.run')
400
401
402 def test_run_topic_timeout(mock_subprocess, temp_db):
403 """Test topic run timeout."""
404 topic = store.add_topic("Test Topic")
405
406 # Mock timeout
407 mock_subprocess.side_effect = subprocess.TimeoutExpired("cmd", 300)
408
409 result = watchlist._run_topic(topic)
410
411 assert result["status"] == "failed"
412 assert result["error"] == "timeout"
413
414 @patch('watchlist.subprocess.run')
415 @patch('watchlist._deliver_findings')
416
417
418 def test_run_topic_calls_delivery(mock_deliver, mock_subprocess, temp_db):
419 """Test that successful run calls delivery."""
420 topic = store.add_topic("Test Topic")
421
422 # Mock successful subprocess call with findings
423 mock_result = Mock()
424 mock_result.returncode = 0
425 mock_result.stdout = json.dumps({
426 "topic": "Test Topic",
427 "range_from": "2026-01-01",
428 "range_to": "2026-04-03",
429 "generated_at": "2026-04-03T00:00:00Z",
430 "provider_runtime": {
431 "reasoning_provider": "gemini",
432 "planner_model": "gemini-2.0-flash-exp",
433 "rerank_model": "gemini-2.0-flash-exp",
434 },
435 "query_plan": {
436 "intent": "test",
437 "freshness_mode": "recent",
438 "cluster_mode": "standard",
439 "raw_topic": "test",
440 "subqueries": [],
441 "source_weights": {},
442 },
443 "clusters": [],
444 "ranked_candidates": [
445 {
446 "candidate_id": "c-r1",
447 "item_id": "R1",
448 "source": "reddit",
449 "title": "Test",
450 "url": "https://reddit.com/1",
451 "snippet": "Snippet",
452 "subquery_labels": ["primary"],
453 "native_ranks": {"reddit": 1},
454 "local_relevance": 0.8,
455 "freshness": 100,
456 "engagement": 50.0,
457 "source_quality": 0.8,
458 "rrf_score": 1.0,
459 "final_score": 0.8,
460 "explanation": "Snippet",
461 "source_items": [
462 {
463 "item_id": "R1",
464 "source": "reddit",
465 "title": "Test",
466 "body": "Content",
467 "url": "https://reddit.com/1",
468 "author": "user",
469 "engagement_score": 50.0,
470 "local_relevance": 0.8,
471 "snippet": "Snippet",
472 }
473 ],
474 }
475 ],
476 "items_by_source": {
477 "reddit": [
478 {
479 "item_id": "R1",
480 "source": "reddit",
481 "title": "Test",
482 "body": "Content",
483 "url": "https://reddit.com/1",
484 "author": "user",
485 "engagement_score": 50.0,
486 "local_relevance": 0.8,
487 "snippet": "Snippet",
488 }
489 ],
490 },
491 "errors_by_source": {},
492 "warnings": [],
493 })
494 mock_subprocess.return_value = mock_result
495
496 watchlist._run_topic(topic)
497
498 # Verify delivery was called
499 assert mock_deliver.called
500 call_args = mock_deliver.call_args[0]
501 assert call_args[0] == "Test Topic"
502 assert call_args[1]["new"] == 1
503
504 # === Tests for cmd_run_one() ===
505
506 @patch('watchlist._run_topic')
507
508
509 def test_cmd_run_one(mock_run, temp_db, capsys):
510 """Test running a single topic."""
511 topic = store.add_topic("Test Topic")
512
513 mock_run.return_value = {
514 "topic": "Test Topic",
515 "status": "completed",
516 "new": 5,
517 "updated": 2,
518 "duration": 60.0,
519 }
520
521 args = Mock()
522 args.topic = "Test Topic"
523
524 watchlist.cmd_run_one(args)
525
526 # Verify output
527 captured = capsys.readouterr()
528 output = json.loads(captured.out)
529
530 assert output["status"] == "completed"
531 assert output["new"] == 5
532
533
534 def test_cmd_run_one_nonexistent_topic(temp_db, capsys):
535 """Test running a nonexistent topic."""
536 args = Mock()
537 args.topic = "Nonexistent Topic"
538
539 with pytest.raises(SystemExit):
540 watchlist.cmd_run_one(args)
541
542 # === Tests for cmd_run_all() ===
543
544 @patch('watchlist._run_topic')
545
546
547 def test_cmd_run_all_no_topics(mock_run, temp_db, capsys):
548 """Test running all topics when none exist."""
549 args = Mock()
550
551 watchlist.cmd_run_all(args)
552
553 # Verify output
554 captured = capsys.readouterr()
555 output = json.loads(captured.out)
556
557 assert "No enabled topics" in output["message"]
558
559 @patch('watchlist._run_topic')
560
561
562 def test_cmd_run_all_multiple_topics(mock_run, temp_db, capsys):
563 """Test running multiple topics."""
564 # Add topics
565 store.add_topic("Topic 1")
566 store.add_topic("Topic 2")
567
568 mock_run.return_value = {
569 "topic": "Test",
570 "status": "completed",
571 "new": 5,
572 "updated": 2,
573 "duration": 60.0,
574 }
575
576 args = Mock()
577
578 watchlist.cmd_run_all(args)
579
580 # Verify output
581 captured = capsys.readouterr()
582 output = json.loads(captured.out)
583
584 assert output["action"] == "run_all"
585 assert len(output["results"]) == 2
586
587 @patch('watchlist._run_topic')
588 @patch('watchlist.store.get_daily_cost')
589
590
591 def test_cmd_run_all_respects_budget(mock_cost, mock_run, temp_db, capsys):
592 """Test that run-all respects daily budget."""
593 # Add topics
594 store.add_topic("Topic 1")
595 store.add_topic("Topic 2")
596 store.add_topic("Topic 3")
597
598 # Mock budget exceeded (budget limit is 5.0)
599 mock_cost.return_value = 6.0 # Over budget
600
601 mock_run.return_value = {
602 "topic": "Test",
603 "status": "completed",
604 "new": 5,
605 "updated": 2,
606 "duration": 60.0,
607 }
608
609 args = Mock()
610
611 watchlist.cmd_run_all(args)
612
613 # Verify output
614 captured = capsys.readouterr()
615 output = json.loads(captured.out)
616
617 # All topics should be skipped due to budget
618 results = output["results"]
619 skipped = [r for r in results if r["status"] == "skipped"]
620
621 assert len(skipped) == 3 # All 3 topics skipped
622
623 if __name__ == "__main__":
624 pytest.main([__file__, "-v"])
625
625 lines PYTHON