Files
ignis-core/tests/test_p0_schedules.py
2026-05-16 10:29:54 +07:00

275 lines
9.2 KiB
Python

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(),
json={
"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(),
json={
"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(),
json={
"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(),
json={
"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(),
json={
"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, [])
async def test_once_schedule_requires_exactly_one_time_selector(self):
run_at = datetime.now(app_tz) + timedelta(hours=2)
response = await self.client.post(
"/schedules/once",
headers=self._headers(),
json={
"target_id": "grp-1",
"run_at": run_at.isoformat(),
"hours_from_now": 2,
"is_group": True,
"state": False,
},
)
self.assertEqual(response.status_code, 422)
self.assertIn(
"Передайте ровно одно из полей run_at или hours_from_now",
str(response.json()),
)
async def test_schedule_rejects_legacy_query_only_contract(self):
response = await self.client.post(
"/schedules/cron",
headers=self._headers(),
params={
"target_id": "grp-1",
"hour": "7",
"minute": "30",
"is_group": "true",
"state": "true",
},
)
self.assertEqual(response.status_code, 422)