import os import unittest from datetime import datetime, timedelta from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, patch from apscheduler.triggers.cron import CronTrigger from httpx import ASGITransport, AsyncClient from sqlalchemy import delete, select TEST_DB_PATH = Path(__file__).with_name("test_ignis.db") MASTER_KEY = "master-secret-for-tests" os.environ["IGNIS_API_KEY"] = MASTER_KEY os.environ["IGNIS_DATABASE_URL"] = f"sqlite+aiosqlite:///{TEST_DB_PATH}" os.environ["IGNIS_SYNC_DATABASE_URL"] = f"sqlite:///{TEST_DB_PATH}" import main # noqa: E402 from app.core.database import async_session, init_db # noqa: E402 from app.core.scheduler import ( # noqa: E402 app_tz, execute_schedule_job, reconcile_schedule_jobs, scheduler, start_scheduler, ) from app.core.state import state_manager # noqa: E402 from app.models.event_log import EventLog # noqa: E402 from app.models.schedule import ScheduleTask # noqa: E402 class ScheduleApiTests(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): os.environ["IGNIS_API_KEY"] = MASTER_KEY await init_db() await start_scheduler() await self._reset_database() self._reset_state() self.client = AsyncClient( transport=ASGITransport(app=main.app), base_url="http://testserver", ) async def asyncTearDown(self): await self.client.aclose() self._clear_runtime_jobs() state_manager.devices.clear() state_manager.groups.clear() def _reset_state(self): state_manager.devices.clear() state_manager.groups.clear() state_manager.devices["dev-1"] = SimpleNamespace(id="dev-1", ip="192.168.1.10") state_manager.devices["dev-2"] = SimpleNamespace(id="dev-2", ip="192.168.1.11") state_manager.groups["grp-1"] = SimpleNamespace( id="grp-1", name="Office", device_ids=["dev-1", "dev-2"], ) def _clear_runtime_jobs(self): for job in scheduler.get_jobs(): if not job.id.startswith("cleanup_"): scheduler.remove_job(job.id) async def _reset_database(self): self._clear_runtime_jobs() async with async_session() as session: await session.execute(delete(EventLog)) await session.execute(delete(ScheduleTask)) await session.commit() def _headers(self) -> dict[str, str]: return {"X-API-Key": MASTER_KEY} async def test_cron_tasks_do_not_collide_and_are_persisted(self): first = await self.client.post( "/schedules/cron", headers=self._headers(), params={ "target_id": "grp-1", "hour": "22", "minute": "00", "day_of_week": "1", "is_group": "true", "state": "true", }, ) second = await self.client.post( "/schedules/cron", headers=self._headers(), params={ "target_id": "grp-1", "hour": "22", "minute": "00", "day_of_week": "5", "is_group": "true", "state": "false", }, ) self.assertEqual(first.status_code, 200) self.assertEqual(second.status_code, 200) self.assertNotEqual(first.json()["job_id"], second.json()["job_id"]) tasks_response = await self.client.get( "/schedules/tasks", headers=self._headers() ) self.assertEqual(tasks_response.status_code, 200) tasks = tasks_response.json()["tasks"] self.assertEqual(len(tasks), 2) async with async_session() as session: result = await session.execute( select(ScheduleTask).order_by(ScheduleTask.id) ) rows = result.scalars().all() self.assertEqual(len(rows), 2) self.assertEqual(rows[0].trigger_args["day_of_week"], "1") self.assertEqual(rows[1].trigger_args["day_of_week"], "5") async def test_once_task_supports_non_toggle_payload_and_cleans_itself_after_execution( self, ): run_at = datetime.now(app_tz) + timedelta(hours=2) response = await self.client.post( "/schedules/once", headers=self._headers(), params={ "target_id": "grp-1", "run_at": run_at.isoformat(), "is_group": "true", "temp": "3200", }, ) self.assertEqual(response.status_code, 200) payload = response.json() job_id = payload["job_id"] async with async_session() as session: result = await session.execute( select(ScheduleTask).where(ScheduleTask.job_id == job_id) ) task = result.scalar_one() self.assertEqual(task.action_params, {"temp": 3200}) with patch( "app.api.routes.schedules.run_group_command", AsyncMock(return_value=None), ) as mocked_runner: await execute_schedule_job(job_id) mocked_runner.assert_awaited_once_with("grp-1", True, {"temp": 3200}) async with async_session() as session: result = await session.execute( select(ScheduleTask).where(ScheduleTask.job_id == job_id) ) self.assertIsNone(result.scalar_one_or_none()) async def test_cancel_task_removes_db_metadata_and_runtime_job(self): create_response = await self.client.post( "/schedules/cron", headers=self._headers(), params={ "target_id": "grp-1", "hour": "21", "minute": "15", "is_group": "true", "state": "true", }, ) job_id = create_response.json()["job_id"] self.assertIsNotNone(scheduler.get_job(job_id)) delete_response = await self.client.delete( f"/schedules/{job_id}", headers=self._headers(), ) self.assertEqual(delete_response.status_code, 200) self.assertIsNone(scheduler.get_job(job_id)) async with async_session() as session: result = await session.execute( select(ScheduleTask).where(ScheduleTask.job_id == job_id) ) self.assertIsNone(result.scalar_one_or_none()) async def test_reconcile_migrates_legacy_scheduler_job_into_metadata_and_rebuilds_runtime_job( self, ): from app.api.routes.schedules import run_group_command scheduler.add_job( run_group_command, trigger=CronTrigger(hour="23", minute="05", timezone=app_tz), args=["grp-1", True, {"state": True}], id="legacy_job", name="CRON: grp-1 | 23:05 | True", replace_existing=True, ) await reconcile_schedule_jobs() async with async_session() as session: result = await session.execute( select(ScheduleTask).where(ScheduleTask.job_id == "legacy_job") ) task = result.scalar_one_or_none() self.assertIsNotNone(task) self.assertEqual(task.target_type, "group") self.assertEqual(task.action_params, {"state": True}) rebuilt_job = scheduler.get_job("legacy_job") self.assertIsNotNone(rebuilt_job) self.assertEqual(list(rebuilt_job.args), ["legacy_job"]) async def test_invalid_cron_values_return_400_without_persisting_task(self): response = await self.client.post( "/schedules/cron", headers=self._headers(), params={ "target_id": "grp-1", "hour": "99", "minute": "99", "is_group": "true", "temp": "3200", }, ) self.assertEqual(response.status_code, 400) self.assertIn("Error validating expression", response.json()["detail"]) async with async_session() as session: result = await session.execute(select(ScheduleTask)) rows = result.scalars().all() self.assertEqual(rows, [])