""" Tests for collection job orchestration. """ from unittest.mock import patch, MagicMock from jobs.collection_job import run_daily_collection def test_run_daily_collection_calls_collectors_in_order(): """Daily collection should run all collectors in dependency order.""" call_order = [] def make_mock_collector(name): mock_cls = MagicMock() instance = MagicMock() instance.run.side_effect = lambda: call_order.append(name) mock_cls.return_value = instance return mock_cls with patch("jobs.collection_job.SessionLocal") as mock_session_local, \ patch("jobs.collection_job.StockCollector", make_mock_collector("stock")), \ patch("jobs.collection_job.SectorCollector", make_mock_collector("sector")), \ patch("jobs.collection_job.PriceCollector", make_mock_collector("price")), \ patch("jobs.collection_job.ValuationCollector", make_mock_collector("valuation")), \ patch("jobs.collection_job.ETFCollector", make_mock_collector("etf")), \ patch("jobs.collection_job.ETFPriceCollector", make_mock_collector("etf_price")): mock_session_local.return_value = MagicMock() run_daily_collection() assert call_order == ["stock", "sector", "price", "valuation", "etf", "etf_price"] def test_run_daily_collection_continues_on_failure(): """If one collector fails, the rest should still run.""" call_order = [] def make_mock_collector(name, should_fail=False): mock_cls = MagicMock() instance = MagicMock() def side_effect(): if should_fail: raise RuntimeError(f"{name} failed") call_order.append(name) instance.run.side_effect = side_effect mock_cls.return_value = instance return mock_cls with patch("jobs.collection_job.SessionLocal") as mock_session_local, \ patch("jobs.collection_job.StockCollector", make_mock_collector("stock", should_fail=True)), \ patch("jobs.collection_job.SectorCollector", make_mock_collector("sector")), \ patch("jobs.collection_job.PriceCollector", make_mock_collector("price")), \ patch("jobs.collection_job.ValuationCollector", make_mock_collector("valuation")), \ patch("jobs.collection_job.ETFCollector", make_mock_collector("etf")), \ patch("jobs.collection_job.ETFPriceCollector", make_mock_collector("etf_price")): mock_session_local.return_value = MagicMock() run_daily_collection() # stock failed, but rest should continue assert call_order == ["sector", "price", "valuation", "etf", "etf_price"] from jobs.collection_job import run_backfill def test_run_backfill_generates_yearly_chunks(): """Backfill should split date range into yearly chunks.""" collected_ranges = [] def make_price_collector(name): mock_cls = MagicMock() def capture_init(db, start_date=None, end_date=None): instance = MagicMock() collected_ranges.append((name, start_date, end_date)) return instance mock_cls.side_effect = capture_init return mock_cls with patch("jobs.collection_job.SessionLocal") as mock_session_local, \ patch("jobs.collection_job.PriceCollector", make_price_collector("price")), \ patch("jobs.collection_job.ETFPriceCollector", make_price_collector("etf_price")): mock_db = MagicMock() mock_session_local.return_value = mock_db # Simulate no existing data (min date returns None) mock_db.query.return_value.scalar.return_value = None run_backfill(start_year=2023) # Should generate chunks: 2023, 2024, 2025, 2026 (partial) for both price and etf_price price_ranges = [(s, e) for name, s, e in collected_ranges if name == "price"] assert len(price_ranges) >= 3 # At least 2023, 2024, 2025 assert price_ranges[0][0] == "20230101" # First chunk starts at start_year def test_run_backfill_with_existing_data_only_fills_gaps(): """Backfill should only collect before earliest and after latest existing data.""" collected_ranges = [] def make_price_collector(name): mock_cls = MagicMock() def capture_init(db, start_date=None, end_date=None): instance = MagicMock() collected_ranges.append((name, start_date, end_date)) return instance mock_cls.side_effect = capture_init return mock_cls from datetime import date with patch("jobs.collection_job.SessionLocal") as mock_session_local, \ patch("jobs.collection_job.PriceCollector", make_price_collector("price")), \ patch("jobs.collection_job.ETFPriceCollector", make_price_collector("etf_price")): mock_db = MagicMock() mock_session_local.return_value = mock_db # Simulate: data exists from 2024-06-01 to 2024-12-31 call_count = [0] def scalar_side_effect(): call_count[0] += 1 # func.min returns earliest date, func.max returns latest date # Calls alternate: min for Price, (then max for Price forward fill), # min for ETFPrice, (then max for ETFPrice forward fill) if call_count[0] == 1: # min(Price.date) return date(2024, 6, 1) elif call_count[0] == 2: # max(Price.date) for forward fill return date(2024, 12, 31) elif call_count[0] == 3: # min(ETFPrice.date) return date(2024, 6, 1) elif call_count[0] == 4: # max(ETFPrice.date) for forward fill return date(2024, 12, 31) return None mock_db.query.return_value.scalar.side_effect = scalar_side_effect run_backfill(start_year=2023) # Price backfill: should collect 2023-01-01 to 2024-05-31 (before earliest) price_ranges = [(s, e) for name, s, e in collected_ranges if name == "price"] assert len(price_ranges) >= 2 # At least backward chunks + forward fill assert price_ranges[0][0] == "20230101" # Last backward chunk should end at or before 2024-05-31 backward_chunks = [r for r in price_ranges if r[1] <= "20240531"] assert len(backward_chunks) >= 1 def test_scheduler_has_daily_collection_job(): """Scheduler should register a daily_collection job at 18:00.""" from jobs.scheduler import configure_jobs from apscheduler.schedulers.background import BackgroundScheduler test_scheduler = BackgroundScheduler() with patch("jobs.scheduler.scheduler", test_scheduler): configure_jobs() jobs = {job.id: job for job in test_scheduler.get_jobs()} assert "daily_collection" in jobs trigger = jobs["daily_collection"].trigger trigger_str = str(trigger) assert "18" in trigger_str # hour=18 def test_backfill_api_endpoint(client, admin_auth_headers): """POST /api/admin/collect/backfill should trigger backfill.""" with patch("app.api.admin.run_backfill_background") as mock_backfill: response = client.post( "/api/admin/collect/backfill?start_year=2020", headers=admin_auth_headers, ) assert response.status_code == 200 assert "backfill" in response.json()["message"].lower() mock_backfill.assert_called_once() def test_backfill_api_requires_auth(client): """POST /api/admin/collect/backfill should require authentication.""" response = client.post("/api/admin/collect/backfill") assert response.status_code == 401