Files
ros_control/tests/test_operations.py
T
ayurishchevandClaude Opus 5.5 e1f197ccff Тесты по модулям предметных областей
Пункт 12 ревью 2026-09-28 17:35 (docs/changes/026): tests/test_app.py
(936 строк) разнесён на test_security, test_devices, test_operations,
test_backups, test_events, test_ids_migrations, test_architecture; общие
хелперы — tests/helpers.py. Тела тестов не менялись (AST совпадает).
security.reset_all_failures() — фикстура сбрасывает счётчики попыток по
всем ключам, тесты не зависят от порядка.

Тесты: 36 из 36 полным прогоном, каждый модуль отдельно и в обратном
порядке; тест-линтер после переноса проверен мутацией.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 21:30:23 +03:00

186 lines
9.5 KiB
Python

import asyncio
import json
import httpx
import pytest
from fastapi.testclient import TestClient
from app.db import session_scope
from app.main import create_app
from app.models import Device
from app.ros import operations as ros
from app.services import devices, events, jobs, ops
from tests.conftest import ADMIN_PASSWORD, API_TOKEN
from tests.helpers import ros_client
@pytest.mark.asyncio
async def test_firmware_reboots_on_log_message():
"""Перезагрузка — по записи в журнале (current-firmware до неё не меняется)."""
def run(log_at_poll, old_entry_time):
calls, polls = [], {"n": 0}
def handler(r: httpx.Request) -> httpx.Response:
calls.append(f"{r.method} {r.url.path}")
data = {
"/rest/system/routerboard": {"routerboard": "true", "current-firmware": "7.22", "upgrade-firmware": "7.24"},
"/rest/system/clock": {"date": "2026-09-19", "time": "12:00:00"},
"/rest/system/resource": {"uptime": "1h"}, # загрузка была в 11:00
}
if r.url.path == "/rest/log":
polls["n"] += 1
log = [{".id": "*1", "time": old_entry_time, "message": ros.FW_DONE_MSG}]
if polls["n"] >= log_at_poll:
log.append({".id": "*9", "time": "2026-09-19 12:00:00", "message": ros.FW_DONE_MSG})
return httpx.Response(200, json=log)
return httpx.Response(200, json=data.get(r.url.path, []))
async def go():
async with ros_client(handler) as c:
return await ros.upgrade_firmware(c, wait_seconds=10, poll=0)
return calls, go()
# 1) новая запись появилась на 3-м чтении журнала -> команда upgrade, затем сразу reboot
calls, coro = run(3, "2026-09-19 08:00:00")
await coro
assert calls.index("POST /rest/system/routerboard/upgrade") < calls.index("POST /rest/system/reboot") == len(calls) - 1
# 2) запись новее последней загрузки уже есть (прошивка записана, перезагрузки не было) -> сразу reboot
calls, coro = run(99, "2026-09-19 11:30:00")
await coro
assert "POST /rest/system/routerboard/upgrade" not in calls and calls[-1] == "POST /rest/system/reboot"
@pytest.mark.asyncio
async def test_batch_channel_runs_as_jobs(monkeypatch):
"""Групповая смена канала — фоновыми задачами: 202 + job_ids, задачи завершаются done, канал — в data job.created."""
calls = []
async def fake_set_channel(device_id, channel):
calls.append((device_id, channel))
monkeypatch.setattr(ops, "set_channel", fake_set_channel)
d1 = devices.create_device("r1", "10.0.0.1", 443, "admin", "pw")
d2 = devices.create_device("r2", "10.0.0.2", 443, "admin", "pw")
with TestClient(create_app()) as c:
h = {"Authorization": f"Bearer {API_TOKEN}"}
r = c.put("/api/v1/batch/channel", headers=h, json={"device_ids": [d1.id, d2.id], "channel": "testing"})
assert r.status_code == 202
job_ids = r.json()["job_ids"]
assert len(job_ids) == 2
await asyncio.sleep(0.3)
assert sorted(calls) == sorted([(d1.id, "testing"), (d2.id, "testing")])
for jid in job_ids:
assert jobs.get_job(jid).status == "done"
created = [e for e in events.list_events(type_="job.created") if e.entity_id in job_ids]
assert len(created) == 2 and all(json.loads(e.data) == {"type": "set_channel", "channel": "testing"} for e in created)
# неверный канал отклоняется до создания задач
assert c.put("/api/v1/batch/channel", headers=h, json={"device_ids": [d1.id], "channel": "bogus"}).status_code == 422
@pytest.mark.asyncio
async def test_run_ros_downgrade_order_and_failures(monkeypatch):
"""run_ros_downgrade: версия не совпала с подтверждённой — бэкап и install не вызываются; версия совпала —
бэкап выполняется до install (порядок); бэкап упал — install не вызывается."""
calls = []
def handler(r: httpx.Request) -> httpx.Response:
if r.url.path == "/rest/system/package/update/install":
calls.append("install")
return httpx.Response(200, json=[])
data = {"/rest/system/package/update": {"installed-version": "7.24.4", "latest-version": "7.23.7"}}
return httpx.Response(200, json=data.get(r.url.path, []))
d = devices.create_device("r1", "10.0.0.1", 443, "admin", "pw")
monkeypatch.setattr(devices, "open_client", lambda conn: ros_client(handler))
async def fake_backup_ok(device_id):
calls.append("backup")
return "Бэкап загружен в S3: k.backup, k.rsc"
async def fake_backup_fail(device_id):
calls.append("backup")
raise RuntimeError("бэкап не удался")
# 1) версия канала (7.23.7) не совпала с подтверждённой пользователем -> задача failed, ничего не вызывается
monkeypatch.setattr(ops, "run_backup", fake_backup_ok)
[jid] = await jobs.start_jobs("ros_downgrade", [d.id], {"target_version": "7.23.6"})
await asyncio.sleep(0.2)
j = jobs.get_job(jid)
assert j.status == "failed" and "не совпадает" in j.message and calls == []
# 2) версия совпала -> бэкап выполняется до install, install вызван
[jid] = await jobs.start_jobs("ros_downgrade", [d.id], {"target_version": "7.23.7"})
await asyncio.sleep(0.2)
j = jobs.get_job(jid)
assert j.status == "done" and calls == ["backup", "install"]
# 3) бэкап падает -> install не вызывается
calls.clear()
monkeypatch.setattr(ops, "run_backup", fake_backup_fail)
[jid] = await jobs.start_jobs("ros_downgrade", [d.id], {"target_version": "7.23.7"})
await asyncio.sleep(0.2)
j = jobs.get_job(jid)
assert j.status == "failed" and calls == ["backup"]
@pytest.mark.asyncio
async def test_downgrade_api_endpoints(monkeypatch):
"""target_version обязателен для одного устройства и группы; /batch/ros_downgrade — отдельный типизированный
эндпоинт (не через общий /batch/{action}, у которого в Literal ros_downgrade нет)."""
async def fake_downgrade(device_id, target_version):
return f"откат до {target_version}"
monkeypatch.setitem(jobs.JOB_TYPES, "ros_downgrade", fake_downgrade)
d1 = devices.create_device("r1", "10.0.0.1", 443, "admin", "pw")
d2 = devices.create_device("r2", "10.0.0.2", 443, "admin", "pw")
with TestClient(create_app()) as c:
h = {"Authorization": f"Bearer {API_TOKEN}"}
assert c.post(f"/api/v1/devices/{d1.id}/update/downgrade", headers=h, json={}).status_code == 422
r = c.post(f"/api/v1/devices/{d1.id}/update/downgrade", headers=h, json={"target_version": "7.23.7"})
assert r.status_code == 202
job_ids = r.json()["job_ids"]
# тело без target_version — если бы запрос ушёл в общий /batch/{action} (там target_version не нужен),
# он был бы принят; 422 подтверждает, что обрабатывает именно типизированный /batch/ros_downgrade
assert c.post("/api/v1/batch/ros_downgrade", headers=h, json={"device_ids": [d1.id]}).status_code == 422
r = c.post("/api/v1/batch/ros_downgrade", headers=h,
json={"device_ids": [d1.id, d2.id], "target_version": "7.23.7"})
assert r.status_code == 202
job_ids += r.json()["job_ids"]
assert len(job_ids) == 3
await asyncio.sleep(0.3)
for jid in job_ids:
j = jobs.get_job(jid)
assert j.type == "ros_downgrade" and j.status == "done" and j.message == "откат до 7.23.7"
def test_ui_downgrade_dialog_and_mismatch(monkeypatch):
"""Окно отката: неактивная кнопка, пока версия не введена (data-enables); несовпадающая версия — ошибка
в окне, задача не создаётся."""
d = devices.create_device("r1", "10.0.0.1", 443, "admin", "pw")
def _set_status(device, status):
with session_scope() as s:
s.get(Device, device.id).status_json = json.dumps(status)
_set_status(d, {"channel": "long-term", "ros_installed": "7.24.4", "ros_latest": "7.23.7"})
with TestClient(create_app()) as c:
c.post("/login", data={"username": "admin", "password": ADMIN_PASSWORD})
htmx = {"HX-Request": "true"}
page = c.get(f"/ui/dialog/downgrade?device_ids={d.id}", headers=htmx).text
assert "r1" in page and 'data-enables="#downgrade-confirm"' in page and 'id="downgrade-confirm" disabled' in page
r = c.post("/ui/downgrade", data={"device_ids": [d.id], "target_version": "7.16"}, headers=htmx)
assert "не совпадает" in r.text and 'id="modal-title"' in r.text
assert jobs.list_jobs() == []
r = c.post("/ui/downgrade", data={"device_ids": [d.id], "target_version": "7.23.7"}, headers=htmx)
assert r.headers.get("HX-Refresh") == "true"
assert len(jobs.list_jobs()) == 1 and jobs.list_jobs()[0].type == "ros_downgrade"