"""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" @pytest.mark.parametrize( ("key", "hour", "minute"), ( ("schedule_dolt_earnings_cron", 2, 30), ("schedule_sec_fundamentals_cron", 4, 0), ), ) 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_daily_pipeline_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 * * *"})