134 lines
4.8 KiB
Python
134 lines
4.8 KiB
Python
"""Unit tests for the cron pipeline schedule config."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.exceptions import ValidationError
|
||
from app.scheduler import SCHEDULE_DEFAULTS, validate_cron
|
||
from app.services.admin_service import get_schedule_config, update_schedule_config
|
||
|
||
|
||
@pytest.fixture
|
||
async def session() -> AsyncSession:
|
||
from tests.conftest import _test_session_factory
|
||
|
||
async with _test_session_factory() as session:
|
||
yield session
|
||
|
||
|
||
class TestValidateCron:
|
||
def test_accepts_valid(self):
|
||
validate_cron("0 7 * * *", "Europe/Berlin")
|
||
validate_cron("0 14-22 * * 1-5", "UTC")
|
||
|
||
def test_rejects_bad_cron(self):
|
||
with pytest.raises(Exception):
|
||
validate_cron("not a cron", "UTC")
|
||
|
||
def test_rejects_bad_timezone(self):
|
||
with pytest.raises(Exception):
|
||
validate_cron("0 7 * * *", "Mars/Phobos")
|
||
|
||
|
||
class TestTradingDayCrons:
|
||
"""APScheduler's from_crontab() uses 0=Monday, so numeric "1-5" means
|
||
Tue–Sat: it skips every Monday and fires on Saturdays. Weekday schedules
|
||
must therefore be spelled with day *names*.
|
||
"""
|
||
|
||
_WEEKDAY_KEYS = (
|
||
"schedule_near_close_pipeline_cron",
|
||
"schedule_after_close_pipeline_cron",
|
||
"schedule_intraday_pipeline_cron",
|
||
)
|
||
|
||
@pytest.mark.parametrize("key", _WEEKDAY_KEYS)
|
||
def test_fires_monday_and_never_saturday(self, key: str):
|
||
from datetime import datetime, timedelta
|
||
|
||
from apscheduler.triggers.cron import CronTrigger
|
||
|
||
trigger = CronTrigger.from_crontab(
|
||
SCHEDULE_DEFAULTS[key], timezone=SCHEDULE_DEFAULTS["schedule_timezone"]
|
||
)
|
||
# Walk a full week of fire times from a known Sunday.
|
||
cursor = datetime(2026, 7, 19, tzinfo=trigger.timezone)
|
||
weekdays = set()
|
||
previous = None
|
||
for _ in range(12):
|
||
fire = trigger.get_next_fire_time(previous, cursor)
|
||
weekdays.add(fire.strftime("%a"))
|
||
previous = fire
|
||
cursor = fire + timedelta(seconds=1)
|
||
|
||
assert "Mon" in weekdays, f"{key} skips Mondays — numeric day-of-week?"
|
||
assert {"Sat", "Sun"}.isdisjoint(weekdays), f"{key} fires on a weekend"
|
||
|
||
def test_fundamentals_runs_on_monday(self):
|
||
from datetime import datetime
|
||
|
||
from apscheduler.triggers.cron import CronTrigger
|
||
|
||
trigger = CronTrigger.from_crontab(
|
||
SCHEDULE_DEFAULTS["schedule_fundamentals_cron"],
|
||
timezone=SCHEDULE_DEFAULTS["schedule_timezone"],
|
||
)
|
||
fire = trigger.get_next_fire_time(
|
||
None, datetime(2026, 7, 19, tzinfo=trigger.timezone)
|
||
)
|
||
assert fire.strftime("%a") == "Mon"
|
||
|
||
@pytest.mark.parametrize(
|
||
("key", "hour", "minute"),
|
||
(
|
||
("schedule_dolt_earnings_cron", 2, 30),
|
||
("schedule_sec_fundamentals_cron", 4, 0),
|
||
("schedule_fundamentals_parity_cron", 5, 30),
|
||
),
|
||
)
|
||
def test_shadow_imports_run_daily_at_expected_et_time(
|
||
self, key: str, hour: int, minute: int
|
||
):
|
||
from datetime import datetime
|
||
|
||
from apscheduler.triggers.cron import CronTrigger
|
||
|
||
trigger = CronTrigger.from_crontab(
|
||
SCHEDULE_DEFAULTS[key], timezone=SCHEDULE_DEFAULTS["schedule_timezone"]
|
||
)
|
||
fire = trigger.get_next_fire_time(
|
||
None, datetime(2026, 7, 19, tzinfo=trigger.timezone)
|
||
)
|
||
assert (fire.hour, fire.minute) == (hour, minute)
|
||
|
||
|
||
class TestScheduleConfig:
|
||
async def test_defaults_when_unset(self, session: AsyncSession):
|
||
config = await get_schedule_config(session)
|
||
assert config == SCHEDULE_DEFAULTS
|
||
|
||
async def test_update_and_read_back(self, session: AsyncSession):
|
||
updated = await update_schedule_config(
|
||
session, {"schedule_daily_pipeline_cron": "30 6 * * *"}
|
||
)
|
||
assert updated["schedule_daily_pipeline_cron"] == "30 6 * * *"
|
||
# untouched keys keep their defaults
|
||
assert updated["schedule_intraday_pipeline_cron"] == SCHEDULE_DEFAULTS["schedule_intraday_pipeline_cron"]
|
||
|
||
config = await get_schedule_config(session)
|
||
assert config["schedule_daily_pipeline_cron"] == "30 6 * * *"
|
||
|
||
async def test_rejects_bad_cron(self, session: AsyncSession):
|
||
with pytest.raises(ValidationError):
|
||
await update_schedule_config(session, {"schedule_fundamentals_cron": "every monday"})
|
||
|
||
async def test_rejects_bad_timezone(self, session: AsyncSession):
|
||
with pytest.raises(ValidationError):
|
||
await update_schedule_config(session, {"schedule_timezone": "Nowhere/Void"})
|
||
|
||
async def test_rejects_unknown_key(self, session: AsyncSession):
|
||
with pytest.raises(ValidationError):
|
||
await update_schedule_config(session, {"schedule_bogus": "0 0 * * *"})
|