Files
ros_control/tests/test_devices.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

180 lines
11 KiB
Python

import json
import re
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, groups, ops
from tests.conftest import ADMIN_PASSWORD, API_TOKEN
from tests.helpers import ros_client
@pytest.mark.asyncio
async def test_status_parsing():
data = {
"/rest/system/resource": {"board-name": "hAP ax3", "version": "7.15 (stable)", "uptime": "1d2h"},
"/rest/system/routerboard": {"routerboard": "true", "model": "C53UiG", "current-firmware": "7.14", "upgrade-firmware": "7.15"},
"/rest/system/package/update": {"channel": "stable", "installed-version": "7.15", "latest-version": "7.16", "status": "New version is available"},
"/rest/system/package/update/check-for-updates": [], # POST: проверка обновлений
}
async with ros_client(lambda r: httpx.Response(200, json=data[r.url.path])) as c:
st = await ros.get_status(c)
assert (st["model"], st["channel"], st["fw_upgrade"], st["ros_latest"]) == ("C53UiG", "stable", "7.15", "7.16")
assert st["ros_installed"] == "7.15" and st["ros_check_error"] is None
@pytest.mark.asyncio
async def test_chr_status_and_version_compare():
"""CHR: раздела system/routerboard нет (HTTP 400) — это не сбой; «последняя» версия старше установленной — не обновление."""
def handler(r: httpx.Request) -> httpx.Response:
if r.url.path == "/rest/system/routerboard":
return httpx.Response(400, json={"detail": "no such command or directory (routerboard)", "error": 400})
data = {
"/rest/system/resource": {"board-name": "CHR Yandex epyc-9654", "version": "7.24 (stable)", "uptime": "1d"},
"/rest/system/package/update": {"channel": "long-term", "installed-version": "7.24", "latest-version": "7.23.7"},
}
return httpx.Response(200, json=data.get(r.url.path, []))
async with ros_client(handler) as c:
st = await ros.get_status(c)
assert await ros.upgrade_firmware(c) == "Устройство без RouterBOARD firmware (например, CHR) — пропущено"
assert st["model"].startswith("CHR") and st["fw_current"] is None
assert not ros.version_newer(st["ros_latest"], st["ros_installed"]) # 7.23.7 старше 7.24
assert ros.version_newer("7.24.1", "7.24") and ros.version_newer("7.25", "7.25rc1") and not ros.version_newer("7.25rc1", "7.25")
def test_groups_and_device_filters():
office, store = groups.create_group("Офис"), groups.create_group("Склад")
a = devices.create_device("a", "10.0.0.1", 80, "u", "p", group_id=office.id)
b = devices.create_device("b", "10.0.0.2", 80, "u", "p", group_id=store.id)
devices.create_device("c", "10.0.0.3", 80, "u", "p")
with session_scope() as s:
s.get(Device, a.id).online = True
s.get(Device, a.id).status_json = json.dumps({"ros_installed": "7.1", "ros_latest": "7.2", "channel": "stable"})
s.get(Device, b.id).online = False
def names(**kw):
return sorted(d.name for d in devices.filter_devices(devices.list_devices(), **kw))
assert names(group=str(office.id)) == ["a"] and names(group="none") == ["c"]
assert names(status="offline") == ["b", "c"] and names(updates="ros") == ["a"]
assert names(q="10.0.0.2") == ["b"] and names(channel="stable") == ["a"]
groups.delete_group(office.id) # устройства остаются, но без группы
assert devices.get_device(a.id).group_id is None
def test_create_device_via_ui_binds_group(monkeypatch):
"""Добавление устройства из окна: привязка к существующей группе и создание группы «на месте»."""
async def no_poll(_device_id): # без обращения к реальному устройству
return None
monkeypatch.setattr(ops, "refresh_status", no_poll)
office = groups.create_group("Офис")
form = dict(host="10.0.0.1", port="80", username="u", password="p")
htmx = {"HX-Request": "true"}
with TestClient(create_app()) as c:
c.post("/login", data={"username": "admin", "password": ADMIN_PASSWORD})
# 1) существующая группа
r = c.post("/devices/new", data={**form, "name": "a", "group_id": str(office.id), "note": " Серверная, 2 этаж "},
follow_redirects=False)
assert r.status_code == 303
# имя устройства в таблице открывает окно изменения (без JS — запасную страницу)
page = c.get("/").text
assert re.search(r'class="dev-link" href="/devices/(dev_[0-9a-f-]+)/edit" hx-get="/ui/dialog/device/\1"', page)
# 2) «Новая группа…» из окна: группа создаётся и привязывается, окно просит обновить страницу
r = c.post("/devices/new", data={**form, "name": "b", "group_id": "__new__", "new_group": "Склад"}, headers=htmx)
assert r.headers["HX-Refresh"] == "true"
# 3) «Новая группа…» без названия: ошибка в окне, устройство не создаётся
r = c.post("/devices/new", data={**form, "name": "c", "group_id": "__new__", "new_group": ""}, headers=htmx)
assert "Введите название новой группы" in r.text and 'id="modal-title"' in r.text
# окна групп открываются и показывают ошибку (дубликат) внутри окна
assert "Новая группа" in c.get("/ui/dialog/group").text
r = c.post("/groups/new", data={"name": "Склад"}, headers=htmx)
assert "уже существует" in r.text and 'id="modal-title"' in r.text
by_name = {d.name: d for d in devices.list_devices()}
store = next(g for g in groups.list_groups() if g["name"] == "Склад")
assert by_name["a"].group_id == office.id and by_name["b"].group_id == store["id"] and "c" not in by_name
assert by_name["a"].note == "Серверная, 2 этаж" and by_name["b"].note is None # пробелы обрезаются, пусто -> None
def test_device_name_is_immutable():
"""Имя задаётся только при создании: API отклоняет смену, форма изменения имя игнорирует."""
with TestClient(create_app()) as c:
c.post("/login", data={"username": "admin", "password": ADMIN_PASSWORD})
h = {"Authorization": f"Bearer {API_TOKEN}"}
d = c.post("/api/v1/devices", headers=h, json={"name": "r1", "host": "10.0.0.1", "username": "u", "password": "p"}).json()
r = c.patch(f"/api/v1/devices/{d['id']}", headers=h, json={"name": "r2"})
assert r.status_code == 400 and "нельзя изменить" in r.text
# то же имя — допустимо (клиенты часто присылают объект целиком); остальные поля меняются
r = c.patch(f"/api/v1/devices/{d['id']}", headers=h, json={"name": "r1", "host": "10.0.0.9"})
assert r.status_code == 200 and r.json()["name"] == "r1" and r.json()["host"] == "10.0.0.9"
# форма изменения: присланное имя игнорируется, остальное сохраняется
r = c.post(f"/devices/{d['id']}/edit", data={"name": "hacked", "host": "10.0.0.7", "port": "80", "username": "u"},
follow_redirects=False)
assert r.status_code == 303
page = c.get(f"/ui/dialog/device/{d['id']}").text
assert "readonly" in page and 'name="name"' not in page
dev = devices.get_device(d["id"])
assert (dev.name, dev.host) == ("r1", "10.0.0.7")
@pytest.mark.asyncio
async def test_poll_marks_offline_and_recovers(monkeypatch):
"""Фоновый опрос: недоступность видна сразу; после возвращения — полный опрос (версии обновились)."""
d = devices.create_device("r1", "10.0.0.1", 80, "u", "p")
up = {"on": False}
data = {
"/rest/system/resource": {"version": "7.24.4 (stable)", "uptime": "1m", "board-name": "hAP"},
"/rest/system/routerboard": {"routerboard": "false"},
"/rest/system/package/update": {"channel": "stable", "installed-version": "7.24.4", "latest-version": "7.24.4"},
}
def handler(r: httpx.Request) -> httpx.Response:
if not up["on"]:
raise httpx.ConnectError("connection refused")
return httpx.Response(200, json=data.get(r.url.path, []))
monkeypatch.setattr(devices, "open_client", lambda conn: ros_client(handler))
await ops.poll_device(d.id)
assert devices.get_device(d.id).online is False
up["on"] = True
await ops.poll_device(d.id) # был offline -> полный опрос
dev = devices.get_device(d.id)
assert dev.online is True and dev.status["ros_installed"] == "7.24.4"
data["/rest/system/resource"]["uptime"] = "2m"
await ops.poll_device(d.id) # онлайн -> лёгкий опрос обновляет uptime, остальное сохраняется
dev = devices.get_device(d.id)
assert dev.status["uptime"] == "2m" and dev.status["ros_latest"] == "7.24.4"
def test_ros_state_values():
"""Состояния колонки «Upgrade ROS»: unknown / check_error / update / downgrade / current."""
assert devices.ros_state({}) == "unknown"
assert devices.ros_state({"ros_installed": "7.24.4"}) == "unknown" # нет latest — проверка ещё не выполнялась
assert devices.ros_state({"ros_installed": "7.24.4", "ros_latest": "7.16", "ros_check_error": "timeout"}) == "check_error"
assert devices.ros_state({"ros_installed": "7.15", "ros_latest": "7.16"}) == "update"
assert devices.ros_state({"ros_installed": "7.24.4", "ros_latest": "7.23.7"}) == "downgrade" # long-term старше установленной
assert devices.ros_state({"ros_installed": "7.24.4", "ros_latest": "7.24.4"}) == "current"
def test_fw_state_values():
"""Состояния колонки «Upgrade FW» (изменение 027): unknown / update / downgrade / current;
has_fw_update ложно для downgrade — откат прошивки не считается обновлением."""
assert devices.fw_state({}) == "unknown" # CHR или ещё не опрошено
assert devices.fw_state({"fw_current": "7.14"}) == "unknown" # нет upgrade-firmware
assert devices.fw_state({"fw_current": "7.14", "fw_upgrade": "7.15"}) == "update"
assert devices.fw_state({"fw_current": "7.24.4", "fw_upgrade": "7.23.7"}) == "downgrade" # после отката ROS
assert devices.fw_state({"fw_current": "7.15", "fw_upgrade": "7.15"}) == "current"
assert devices.has_fw_update({"fw_current": "7.14", "fw_upgrade": "7.15"}) is True
assert devices.has_fw_update({"fw_current": "7.24.4", "fw_upgrade": "7.23.7"}) is False