Files
ros_control/tests/test_operations.py
T
ayurishchevandClaude Opus 5.5 5f7bd6588a Состояния «Upgrade FW» и осознанный откат прошивки RouterBOARD
Пункт 18 ревью 2026-09-28 21:32 (docs/changes/027): после отката ROS
плата остаётся на более новой прошивке, а приложение сравнивало версии
на неравенство и показывало откат как обновление.
- devices.fw_state: unknown/update/downgrade/current; has_fw_update и
  фильтры не считают откат обновлением; метка «в ROS: X».
- upgrade_firmware не понижает прошивку; общая запись — _flash_firmware.
- Задача fw_downgrade: проверка → обязательный бэкап → запись →
  перезагрузка; API POST /devices/{id}/firmware/downgrade и
  /batch/fw_downgrade (target_version обязателен).
- UI: «Откатить прошивку…» (строка и группа), общее окно отката с kind.

Тесты: 39 из 39. Стенд: временное недоступное устройство — 422/202,
задачи failed на проверке, бэкапов нет. Реальная запись прошивки при
откате и ручная проверка UI не выполнялись.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 00:04:51 +03:00

246 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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_fw_downgrade_order_and_failures(monkeypatch):
"""Изменение 027, по образцу test_run_ros_downgrade_order_and_failures: плата новее встроенной в ROS —
upgrade_firmware не пишет прошивку; fw_downgrade с несовпавшей версией — бэкап и запись не вызываются;
с совпавшей — порядок backup → upgrade → reboot. Заглушка после POST .../routerboard/upgrade сразу
отдаёт в /rest/log запись с FW_DONE_MSG, чтобы ожидание завершалось без опроса."""
calls = []
written = {"on": False}
def handler(r: httpx.Request) -> httpx.Response:
if r.url.path == "/rest/system/routerboard/upgrade":
calls.append("upgrade")
written["on"] = True
return httpx.Response(200, json=[])
if r.url.path == "/rest/system/reboot":
calls.append("reboot")
return httpx.Response(200, json=[])
data = {
"/rest/system/routerboard": {"routerboard": "true", "current-firmware": "7.24.4", "upgrade-firmware": "7.23.7"},
"/rest/system/clock": {"date": "2026-09-28", "time": "12:00:00"},
"/rest/system/resource": {"uptime": "1h"},
"/rest/log": [{".id": "*9", "time": "2026-09-28 12:00:00", "message": ros.FW_DONE_MSG}] if written["on"] else [],
}
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))
# 1) плата (7.24.4) новее встроенной в ROS (7.23.7) — это откат, не обновление: запись не выполняется
async with ros_client(handler) as c:
msg = await ros.upgrade_firmware(c)
assert "upgrade" not in calls and "новее встроенной" in msg and "Откатить прошивку" in msg
async def fake_backup_ok(device_id):
calls.append("backup")
return "Бэкап загружен в S3: k.backup, k.rsc"
monkeypatch.setattr(ops, "run_backup", fake_backup_ok)
# 2) версия в ROS не совпала с подтверждённой -> задача failed, бэкапа и записи нет
[jid] = await jobs.start_jobs("fw_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 == []
# 3) версия совпала -> порядок backup -> upgrade -> reboot
[jid] = await jobs.start_jobs("fw_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", "upgrade", "reboot"]
def test_batch_fw_downgrade_requires_target_version():
"""Изменение 027: /batch/fw_downgrade — отдельный типизированный эндпоинт, target_version обязателен."""
d = devices.create_device("r1", "10.0.0.1", 443, "admin", "pw")
with TestClient(create_app()) as c:
h = {"Authorization": f"Bearer {API_TOKEN}"}
assert c.post("/api/v1/batch/fw_downgrade", headers=h, json={"device_ids": [d.id]}).status_code == 422
@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"