Пункт 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>
186 lines
9.5 KiB
Python
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"
|