| 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 |