- 后端 prrack:MQTT(phonerail-backend @ 127.0.0.1:1884)↔ 遥测入库 / 事件增量同步 / WebSocket 推送; 命令白名单 + 重传同 seq;审计记门户 X-Auth-User(只绑 127.0.0.1,main.py 拒绝非回环)。 - 自动恢复:只看电流(平线 / 过低,服务器够不到手机 adb),先长按电源再断电重启, 每小时限次后停手;默认逐台关闭。adb 探活接口 recovery.Prober 预留。 - 前端:总览 / 板子页(五路操作、电流电压曲线)/ 事件 / 操作记录 / 自动恢复;手机宽度可用。 - 部署 deploy/deploy.sh:服务器 Python 3.8 上先跑测试再替换;systemd phonerail-rack,每日备份。 - 门户应用 id 16、key rack(phonerail 已被需求文档站占用),授权 admin + staff。 测试 26 例(本机 3.14 / 服务器 3.8 均过)。线上板 b81f3fc9c8bc 在线,WS 经门户 101 + 1 条/s 遥测。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
132 行
4.9 KiB
Python
132 行
4.9 KiB
Python
"""HTTP / WebSocket 接口:用 TestClient 起完整的应用,Hub 经进程内假 broker 连模拟板子。"""
|
|
import time
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from prrack.api import create_app
|
|
from prrack.bus import MemBroker, MemBus
|
|
from prrack.db import Db, now_ms
|
|
from prrack.hub import Hub
|
|
from prrack.recovery import RecoveryManager
|
|
from prrack.simboard import SimBoard
|
|
|
|
B = "b81f3fc9c8bc"
|
|
|
|
|
|
@pytest.fixture
|
|
def env(tmp_path):
|
|
broker = MemBroker()
|
|
db = Db(str(tmp_path / "a.db"))
|
|
hub = Hub(db, MemBus(broker), attempt_timeout=0.2)
|
|
rec = RecoveryManager(hub, now_ms)
|
|
sim = SimBoard(MemBus(broker), B, present=(True, True, True, False, True))
|
|
dist = tmp_path / "dist"
|
|
(dist / "assets").mkdir(parents=True)
|
|
(dist / "index.html").write_text("<html>rack</html>", encoding="utf-8")
|
|
async def up():
|
|
await hub.start()
|
|
sim.start()
|
|
sim.tick(now_ms())
|
|
|
|
app = create_app(hub, rec, str(dist), startup=[up], shutdown=[hub.stop])
|
|
with TestClient(app) as c:
|
|
deadline = time.time() + 2
|
|
while B not in hub.latest and time.time() < deadline:
|
|
time.sleep(0.01)
|
|
yield c, hub, sim, db
|
|
|
|
|
|
def test_boards_and_snapshot(env):
|
|
c, hub, sim, db = env
|
|
boards = c.get("/api/boards").json()
|
|
assert [b["id"] for b in boards] == [B]
|
|
assert boards[0]["online"] is True and len(boards[0]["phones"]) == 5
|
|
assert c.get("/api/boards/nope").status_code == 404
|
|
|
|
|
|
def test_command_records_the_portal_user(env):
|
|
c, hub, sim, db = env
|
|
r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 1, "on": False},
|
|
headers={"X-Auth-User": "zhouyang"})
|
|
assert r.status_code == 200 and r.json()["ok"] is True
|
|
assert sim.rails[0].en is False
|
|
a = c.get("/api/audit").json()[0]
|
|
assert a["user"] == "zhouyang" and a["request"]["cmd"] == "power" and a["ok"] == 1
|
|
# 没有门户头(服务器本机直接 curl)记作 local
|
|
c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 1, "on": True})
|
|
assert c.get("/api/audit").json()[0]["user"] == "local"
|
|
|
|
|
|
def test_bad_commands_never_reach_the_board(env):
|
|
c, hub, sim, db = env
|
|
n = len(sim.received)
|
|
for body in ({"cmd": "wifi", "ssid": "x"}, {"cmd": "set_voltage", "rail": 1, "mv": 4300},
|
|
{"cmd": "key", "rail": 1, "keys": "pwr", "ms": 60000}, {"cmd": "rm -rf"}):
|
|
r = c.post("/api/boards/%s/cmd" % B, json=body)
|
|
assert r.status_code == 400, body
|
|
assert len(sim.received) == n
|
|
|
|
|
|
def test_board_rejection_is_passed_through(env):
|
|
c, hub, sim, db = env
|
|
r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power_cycle", "rail": 4})
|
|
assert r.status_code == 200 and r.json()["err"] == "empty"
|
|
|
|
|
|
def test_timeout_is_504(env):
|
|
c, hub, sim, db = env
|
|
sim.drop_next_replies = 99
|
|
r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 2, "on": True})
|
|
assert r.status_code == 504
|
|
|
|
|
|
def test_phone_settings_toggle_auto_recovery(env):
|
|
c, hub, sim, db = env
|
|
r = c.patch("/api/boards/%s/rails/3" % B, json={"label": "A-03", "auto_recover": True})
|
|
assert r.status_code == 200
|
|
ph = [p for p in r.json()["phones"] if p["rail"] == 3][0]
|
|
assert ph["label"] == "A-03" and ph["auto_recover"] == 1
|
|
rec = c.get("/api/recovery").json()
|
|
assert rec["params"]["window_s"] == 300
|
|
r = c.put("/api/recovery/params", json={"window_s": 120, "flat_ua": -5, "bogus": 1})
|
|
assert r.json()["window_s"] == 120 and r.json()["flat_ua"] == 5000 # 负数不收
|
|
|
|
|
|
def test_history_after_telemetry(env):
|
|
c, hub, sim, db = env
|
|
for _ in range(3):
|
|
sim.tick(now_ms())
|
|
time.sleep(0.1)
|
|
hub.flush()
|
|
h = c.get("/api/boards/%s/rails/1/history" % B, params={"start": now_ms() - 60000}).json()
|
|
assert h["resolution"] == "raw" and len(h["points"]) >= 1
|
|
assert h["points"][0][2] is not None
|
|
|
|
|
|
def test_websocket_hello_and_live_tele(env):
|
|
c, hub, sim, db = env
|
|
with c.websocket_connect("/api/ws", headers={"X-Auth-User": "zhouyang"}) as ws:
|
|
hello = ws.receive_json()
|
|
assert hello["t"] == "hello" and hello["user"] == "zhouyang" and hello["boards"][0]["id"] == B
|
|
sim.tick(now_ms())
|
|
msg = ws.receive_json()
|
|
while msg["t"] != "tele":
|
|
msg = ws.receive_json()
|
|
assert msg["board"] == B and len(msg["status"]["rails"]) == 5
|
|
|
|
|
|
def test_spa_fallback_is_not_cached(env):
|
|
c, hub, sim, db = env
|
|
r = c.get("/some/deep/route")
|
|
assert r.status_code == 200 and "rack" in r.text
|
|
assert r.headers["cache-control"] == "no-cache"
|
|
|
|
|
|
def test_a_real_websocket_library_is_installed():
|
|
"""TestClient 自带 WS 实现,上面那条 WS 用例在没装 websockets 的机器上也是绿的;
|
|
而真 uvicorn 缺了它会把 /api/ws 当普通 GET(落到 SPA 兜底,回 200 HTML)。
|
|
deploy.sh 在服务器上跑这套测试,所以缺库会在换上去之前被拦住。"""
|
|
from prrack.main import ws_library_ok
|
|
assert ws_library_ok()
|