"""Celery Beat's sync_schedules task: dynamic due-schedule discovery, idempotent skip of companies already mid-run, and delegation to run_monitoring.delay for everything else.""" from __future__ import annotations import uuid from datetime import UTC, datetime, timedelta from unittest.mock import patch import pytest from sqlalchemy import select from app.models.company import Company from app.models.enums import MonitoringFrequency, MonitoringRunTrigger, SourceType from app.models.monitor_configuration import MonitorConfiguration from app.models.monitoring_run import MonitoringRun from app.models.source import Source from app.repositories.monitoring_run_repository import MonitoringRunRepository from app.tasks.scheduler import _sync_schedules_async async def _make_due_company(db_session, *, next_run_offset_minutes: int, enabled: bool = True): company = Company( id=uuid.uuid4(), user_id=uuid.uuid4(), name="Scheduled Co", slug=f"scheduled-co-{uuid.uuid4().hex[:6]}", ) db_session.add(company) db_session.add( MonitorConfiguration( company_id=company.id, frequency_type=MonitoringFrequency.WEEKLY, enabled=enabled, next_run=datetime.now(UTC) + timedelta(minutes=next_run_offset_minutes), ) ) await db_session.commit() return company @pytest.mark.asyncio async def test_sync_schedules_enqueues_due_companies(db_session): company = await _make_due_company(db_session, next_run_offset_minutes=-5) # 5 min overdue with patch("app.tasks.collection.run_monitoring.delay") as mock_delay: await _sync_schedules_async() mock_delay.assert_called_once() result = await db_session.execute( select(MonitoringRun).where(MonitoringRun.company_id == company.id) ) run = result.scalar_one() assert run.trigger_type == MonitoringRunTrigger.SCHEDULED @pytest.mark.asyncio async def test_sync_schedules_skips_not_yet_due_companies(db_session): await _make_due_company(db_session, next_run_offset_minutes=60) # due in the future with patch("app.tasks.collection.run_monitoring.delay") as mock_delay: await _sync_schedules_async() mock_delay.assert_not_called() @pytest.mark.asyncio async def test_sync_schedules_skips_disabled_configs(db_session): await _make_due_company(db_session, next_run_offset_minutes=-5, enabled=False) with patch("app.tasks.collection.run_monitoring.delay") as mock_delay: await _sync_schedules_async() mock_delay.assert_not_called() @pytest.mark.asyncio async def test_sync_schedules_does_not_double_enqueue_a_company_with_an_active_run(db_session): company = await _make_due_company(db_session, next_run_offset_minutes=-5) run_repo = MonitoringRunRepository(db_session) await run_repo.create(company_id=company.id, trigger_type=MonitoringRunTrigger.MANUAL) await db_session.commit() with patch("app.tasks.collection.run_monitoring.delay") as mock_delay: await _sync_schedules_async() mock_delay.assert_not_called() @pytest.mark.asyncio async def test_sync_schedules_enqueues_via_a_source_override_even_when_company_default_isnt_due( db_session, ): """A source with its own faster cadence (e.g. "check news daily") must be able to trigger a run even while the company's own default schedule (e.g. weekly) isn't due yet - this is the whole point of per-source scheduling.""" company = await _make_due_company(db_session, next_run_offset_minutes=60) # not due db_session.add( Source( company_id=company.id, source_type=SourceType.RSS, name="Daily News", active=True, frequency_type=MonitoringFrequency.DAILY, next_check=datetime.now(UTC) - timedelta(minutes=5), # overdue ) ) await db_session.commit() with patch("app.tasks.collection.run_monitoring.delay") as mock_delay: await _sync_schedules_async() mock_delay.assert_called_once() @pytest.mark.asyncio async def test_sync_schedules_skips_when_neither_company_default_nor_any_source_is_due(db_session): company = await _make_due_company(db_session, next_run_offset_minutes=60) # not due db_session.add( Source( company_id=company.id, source_type=SourceType.RSS, name="Daily News", active=True, frequency_type=MonitoringFrequency.DAILY, next_check=datetime.now(UTC) + timedelta(hours=12), # not due yet ) ) await db_session.commit() with patch("app.tasks.collection.run_monitoring.delay") as mock_delay: await _sync_schedules_async() mock_delay.assert_not_called()