Fix API regressions and refresh project docs

This commit is contained in:
Artem Kokos
2026-05-15 23:12:28 +07:00
parent 654f64bb90
commit 13fba2fa44
19 changed files with 3258 additions and 964 deletions

239
tests/test_p0_schedules.py Normal file
View 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, [])