Fix API regressions and refresh project docs
This commit is contained in:
239
tests/test_p0_schedules.py
Normal file
239
tests/test_p0_schedules.py
Normal file
@@ -0,0 +1,239 @@
|
||||
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, [])
|
||||
Reference in New Issue
Block a user