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"