All checks were successful
Deploy to Production / deploy (push) Successful in 1m12s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
178 lines
7.4 KiB
Python
178 lines
7.4 KiB
Python
"""
|
|
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
|