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, [])

View File

@@ -0,0 +1,410 @@
import asyncio
import os
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from httpx import ASGITransport, AsyncClient
from sqlalchemy import delete, select
TEST_DB_PATH = Path(__file__).with_name("test_ignis.db")
if TEST_DB_PATH.exists():
TEST_DB_PATH.unlink()
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.api.routes import control # noqa: E402
from app.core.database import async_session, engine, init_db, sync_engine # noqa: E402
from app.core.state import state_manager # noqa: E402
from app.drivers.wiz import WizResponse # noqa: E402
from app.models.api_key import ApiKeyModel # noqa: E402
from app.models.device import DeviceSchema # noqa: E402
from app.models.event_log import EventLog # noqa: E402
class SecurityAndControlApiTests(unittest.IsolatedAsyncioTestCase):
@classmethod
def tearDownClass(cls):
sync_engine.dispose()
asyncio.run(engine.dispose())
if TEST_DB_PATH.exists():
TEST_DB_PATH.unlink()
super().tearDownClass()
async def asyncSetUp(self):
os.environ["IGNIS_API_KEY"] = MASTER_KEY
await init_db()
await self._reset_database()
state_manager.devices.clear()
state_manager.groups.clear()
self.client = AsyncClient(
transport=ASGITransport(app=main.app),
base_url="http://testserver",
)
async def asyncTearDown(self):
await self.client.aclose()
state_manager.devices.clear()
state_manager.groups.clear()
async def _reset_database(self):
async with async_session() as session:
await session.execute(delete(EventLog))
await session.execute(delete(ApiKeyModel))
await session.commit()
async def _event_actions(self) -> list[str]:
async with async_session() as session:
result = await session.execute(
select(EventLog.action).order_by(EventLog.id)
)
return list(result.scalars().all())
def _master_headers(self) -> dict[str, str]:
return {"X-API-Key": MASTER_KEY}
def _set_single_device_state(self):
state_manager.devices["dev-1"] = DeviceSchema(
id="dev-1",
ip="192.168.1.10",
name="Lamp 1",
room="Office",
)
def _set_group_state(self):
state_manager.devices["dev-1"] = DeviceSchema(
id="dev-1",
ip="192.168.1.10",
name="Lamp 1",
room="Office",
)
state_manager.devices["dev-2"] = DeviceSchema(
id="dev-2",
ip="192.168.1.11",
name="Lamp 2",
room="Office",
)
state_manager.groups["grp-1"] = SimpleNamespace(
id="grp-1",
name="Office",
device_ids=["dev-1", "dev-2"],
)
async def test_auth_is_fail_closed_when_master_key_missing(self):
os.environ["IGNIS_API_KEY"] = ""
response = await self.client.get("/auth/me")
self.assertEqual(response.status_code, 503)
self.assertEqual(
response.json()["detail"], "Сервер не настроен: задайте IGNIS_API_KEY"
)
async def test_master_can_create_key_and_list_endpoint_returns_public_id(self):
create_response = await self.client.post(
"/api-keys",
headers=self._master_headers(),
params={"name": "tablet", "is_admin": True},
)
self.assertEqual(create_response.status_code, 200)
created = create_response.json()
self.assertIn("key", created)
self.assertIn("key_id", created)
self.assertNotEqual(created["key"], created["key_id"])
list_response = await self.client.get(
"/api-keys",
headers=self._master_headers(),
)
self.assertEqual(list_response.status_code, 200)
listed = list_response.json()
self.assertEqual(len(listed), 1)
self.assertEqual(listed[0]["name"], "tablet")
self.assertEqual(listed[0]["key"], created["key_id"])
self.assertNotEqual(listed[0]["key"], created["key"])
self.assertIn("display_key", listed[0])
async def test_admin_guest_cannot_manage_api_keys(self):
create_response = await self.client.post(
"/api-keys",
headers=self._master_headers(),
params={"name": "guest-admin", "is_admin": True},
)
guest_key = create_response.json()["key"]
auth_response = await self.client.get(
"/auth/me",
headers={"X-API-Key": guest_key},
)
self.assertEqual(auth_response.status_code, 200)
self.assertEqual(
auth_response.json(),
{"is_admin": True, "is_master": False, "name": "guest-admin"},
)
forbidden_response = await self.client.get(
"/api-keys",
headers={"X-API-Key": guest_key},
)
self.assertEqual(forbidden_response.status_code, 403)
self.assertEqual(forbidden_response.json()["detail"], "Требуется мастер-ключ")
async def test_master_can_revoke_and_activate_by_public_key_id(self):
create_response = await self.client.post(
"/api-keys",
headers=self._master_headers(),
params={"name": "wall-panel"},
)
payload = create_response.json()
public_id = payload["key_id"]
secret = payload["key"]
revoke_response = await self.client.post(
"/api-keys/revoke",
headers=self._master_headers(),
json={"key": public_id},
)
self.assertEqual(revoke_response.status_code, 200)
revoked_auth = await self.client.get(
"/auth/me",
headers={"X-API-Key": secret},
)
self.assertEqual(revoked_auth.status_code, 403)
activate_response = await self.client.post(
"/api-keys/activate",
headers=self._master_headers(),
json={"key": public_id},
)
self.assertEqual(activate_response.status_code, 200)
active_auth = await self.client.get(
"/auth/me",
headers={"X-API-Key": secret},
)
self.assertEqual(active_auth.status_code, 200)
async def test_admin_guest_keeps_access_to_admin_routes_except_master_only_ones(
self,
):
create_response = await self.client.post(
"/api-keys",
headers=self._master_headers(),
params={"name": "guest-admin", "is_admin": True},
)
guest_key = create_response.json()["key"]
response = await self.client.post(
"/devices/groups",
headers={"X-API-Key": guest_key},
json={"id": "bedroom", "name": "Bedroom", "macs": ["dev-1"]},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["status"], "created")
async def test_device_control_timeout_returns_504_and_does_not_log_false_success(
self,
):
self._set_single_device_state()
with patch.object(
control.wiz,
"set_pilot",
AsyncMock(
return_value=WizResponse(
ok=False,
ip="192.168.1.10",
kind="timeout",
message="Таймаут ответа от лампы",
)
),
):
response = await self.client.post(
"/control/device/dev-1",
headers=self._master_headers(),
params={"state": "true"},
)
self.assertEqual(response.status_code, 504)
self.assertIn("Команда лампе не доставлена", response.json()["detail"])
self.assertEqual(
await self._event_actions(), ["toggle_on_requested", "toggle_on_failed"]
)
async def test_group_control_partial_success_reports_partial_and_logs_applied_result(
self,
):
self._set_group_state()
mocked_results = [
WizResponse(
ok=True,
ip="192.168.1.10",
kind="ok",
payload={"result": {"success": True}},
),
WizResponse(
ok=False,
ip="192.168.1.11",
kind="timeout",
message="Таймаут ответа от лампы",
),
]
with patch.object(
control.wiz, "set_pilot", AsyncMock(side_effect=mocked_results)
):
response = await self.client.post(
"/control/group/grp-1",
headers=self._master_headers(),
params={"state": "true"},
)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["status"], "partial")
self.assertEqual(payload["success_count"], 1)
self.assertEqual(payload["failure_count"], 1)
self.assertEqual(len(payload["results"]), 2)
self.assertEqual(
await self._event_actions(), ["toggle_on_requested", "toggle_on"]
)
async def test_group_control_total_failure_returns_gateway_error(self):
self._set_group_state()
mocked_results = [
WizResponse(
ok=False,
ip="192.168.1.10",
kind="timeout",
message="Таймаут ответа от лампы",
),
WizResponse(
ok=False,
ip="192.168.1.11",
kind="timeout",
message="Таймаут ответа от лампы",
),
]
with patch.object(
control.wiz, "set_pilot", AsyncMock(side_effect=mocked_results)
):
response = await self.client.post(
"/control/group/grp-1",
headers=self._master_headers(),
params={"state": "true"},
)
self.assertEqual(response.status_code, 504)
self.assertIn("Команда группе не доставлена", response.json()["detail"])
self.assertEqual(
await self._event_actions(), ["toggle_on_requested", "toggle_on_failed"]
)
async def test_group_status_returns_per_device_errors_instead_of_500(self):
self._set_group_state()
mocked_results = [
WizResponse(
ok=False,
ip="192.168.1.10",
kind="timeout",
message="Таймаут ответа от лампы",
),
WizResponse(
ok=True,
ip="192.168.1.11",
kind="ok",
payload={"result": {"state": True, "dimming": 50}},
),
]
with patch.object(
control.wiz, "get_pilot", AsyncMock(side_effect=mocked_results)
):
response = await self.client.get(
"/control/group/grp-1/status",
headers=self._master_headers(),
)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["group_id"], "grp-1")
self.assertEqual(payload["results"][0]["kind"], "timeout")
self.assertEqual(payload["results"][1]["status"]["dimming"], 50)
async def test_device_status_timeout_is_reported_as_504(self):
self._set_single_device_state()
with patch.object(
control.wiz,
"get_pilot",
AsyncMock(
return_value=WizResponse(
ok=False,
ip="192.168.1.10",
kind="timeout",
message="Таймаут ответа от лампы",
)
),
):
response = await self.client.get(
"/control/device/dev-1/status",
headers=self._master_headers(),
)
self.assertEqual(response.status_code, 504)
self.assertIn("Ошибка опроса лампы", response.json()["detail"])
async def test_device_control_unknown_scene_returns_clear_400(self):
self._set_single_device_state()
response = await self.client.post(
"/control/device/dev-1",
headers=self._master_headers(),
params={"scene": "not_a_scene"},
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["detail"], "Неизвестная сцена")
self.assertEqual(await self._event_actions(), [])
async def test_stats_summary_counts_real_commands_without_requested_duplicates(self):
self._set_single_device_state()
with patch.object(
control.wiz,
"set_pilot",
AsyncMock(
return_value=WizResponse(
ok=True,
ip="192.168.1.10",
kind="ok",
payload={"result": {"success": True}},
)
),
):
response = await self.client.post(
"/control/device/dev-1",
headers=self._master_headers(),
params={"temp": "4200"},
)
self.assertEqual(response.status_code, 200)
summary_response = await self.client.get(
"/stats/summary",
headers=self._master_headers(),
)
self.assertEqual(summary_response.status_code, 200)
payload = summary_response.json()
self.assertEqual(len(payload["groups"]), 1)
self.assertEqual(payload["groups"][0]["target_id"], "dev-1")
self.assertEqual(payload["groups"][0]["total_commands"], 1)
self.assertEqual(payload["groups"][0]["by_user"], {"master": 1})