bff6d70b17
Aplicatie Flask + SQLite pentru productie alimentara: cantarirea componentelor unui produs finit si tiparirea etichetei prin QZ Tray. - produse finite (retete) formate din componente cantarite - designer vizual de etichete (mm/px, zona sigura, campuri variabile, cod QR cu continut din mai multe bucati) - legare produs finit -> sablon de eticheta - navigare pe doua taburi: Cantarire / Setari - administrare: componente, produse finite, sabloane, campuri, istoric - backup, resetare baza de date, checklist de pregatire, /healthz - CLI de administrare si serviciu systemd pentru productie - vendor/syncpulse-scale-bridge (GPL-3.0-or-later) pentru citirea cantarului
157 lines
6.5 KiB
Python
157 lines
6.5 KiB
Python
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
"""The HTTP interface, end to end, with simulated scales."""
|
|
import json
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
import pytest
|
|
|
|
from scale_bridge.config import Config
|
|
from scale_bridge.detect import Timings
|
|
from scale_bridge.server import Bridge, make_server
|
|
|
|
LOCAL = {"X-Requested-With": "ScaleBridge"}
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def bridge_url(tmp_path_factory):
|
|
config = Config(str(tmp_path_factory.mktemp("bridge") / "bridge.json"))
|
|
bridge = Bridge(config, simulate="mahak_mds,toledo_cont:4800:7E1")
|
|
bridge.detect_timings = Timings(passive_s=0.5, probe_s=0.15, confirm_s=0.4, drain_s=0.02)
|
|
server = make_server(bridge, "127.0.0.1", 0)
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
yield f"http://127.0.0.1:{server.server_address[1]}", config
|
|
server.shutdown()
|
|
bridge.registry.stop_all()
|
|
|
|
|
|
def call(url, method="GET", body=None, headers=None):
|
|
req = urllib.request.Request(url, method=method,
|
|
data=json.dumps(body).encode() if body is not None else None,
|
|
headers={"Content-Type": "application/json", **(headers or {})})
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=15) as r:
|
|
return r.status, json.loads(r.read())
|
|
except urllib.error.HTTPError as e:
|
|
return e.code, json.loads(e.read() or b"{}")
|
|
|
|
|
|
def test_health_is_open_and_identifies_the_author(bridge_url):
|
|
url, _ = bridge_url
|
|
code, body = call(url + "/health")
|
|
assert code == 200 and body["service"] == "syncpulse-scale-bridge"
|
|
assert body["author"] == "Farshad Zamani" and "weight" in body["capabilities"]
|
|
|
|
|
|
def test_key_is_required(bridge_url):
|
|
url, config = bridge_url
|
|
assert call(url + "/ports")[0] == 401
|
|
assert call(url + "/ports", headers={"X-Bridge-Token": "wrong"})[0] == 401
|
|
code, body = call(url + "/ports", headers={"X-Bridge-Token": config.token})
|
|
assert code == 200 and any(p["device"] == "COM90" for p in body["ports"])
|
|
|
|
|
|
def test_legacy_read_contract(bridge_url):
|
|
url, config = bridge_url
|
|
code, body = call(url + "/read", "POST", {
|
|
"serial": {"port": "COM90", "baudrate": 9600, "bytesize": 8, "parity": "N", "stopbits": 1},
|
|
"exchange": {"request_hex": "4D 09 04 07", "poll_interval_ms": 300}, "frames": 3},
|
|
headers={"X-Bridge-Token": config.token})
|
|
assert code == 200 and body["ok"] and body["open"]
|
|
assert body["frames"] and body["frames"][-1]["hex"].startswith("58")
|
|
assert {"t", "hex"} <= set(body["frames"][-1]) and "now" in body
|
|
|
|
|
|
def test_weight_by_port_and_protocol(bridge_url):
|
|
url, config = bridge_url
|
|
code, body = call(url + "/weight?port=COM91&protocol=toledo_cont&baud=4800&framing=7E1",
|
|
headers={"X-Bridge-Token": config.token})
|
|
assert code == 200 and body["ok"] and body["unit"] == "kg" and body["checksum_ok"] is True
|
|
assert isinstance(body["value"], float) and "measured_at" in body
|
|
|
|
|
|
def test_weight_errors(bridge_url):
|
|
url, config = bridge_url
|
|
h = {"X-Bridge-Token": config.token}
|
|
assert call(url + "/weight?profile=missing", headers=h)[0] == 404
|
|
assert call(url + "/weight?port=COM90", headers=h)[0] == 400
|
|
assert call(url + "/weight?port=COM90&protocol=mt_sics&framing=9X9", headers=h)[0] == 400
|
|
|
|
|
|
def test_local_api_needs_header_and_local_host(bridge_url):
|
|
url, _ = bridge_url
|
|
assert call(url + "/api/state")[0] == 403
|
|
assert call(url + "/api/state", headers={**LOCAL, "Host": "evil.example"})[0] == 403
|
|
code, state = call(url + "/api/state", headers=LOCAL)
|
|
assert code == 200 and state["about"]["author"] == "Farshad Zamani" and state["token"]
|
|
|
|
|
|
def test_page_is_served_with_signature(bridge_url):
|
|
url, _ = bridge_url
|
|
with urllib.request.urlopen(url + "/", timeout=5) as r:
|
|
html = r.read().decode("utf-8")
|
|
assert "Content-Security-Policy" in r.headers
|
|
assert "Farshad Zamani" in html and "linkedin.com/in/farshad-zamani92" in html
|
|
assert "cloudtart.com" in html and "SyncPulse" in html
|
|
|
|
|
|
def test_detect_save_read_delete(bridge_url):
|
|
url, config = bridge_url
|
|
code, body = call(url + "/api/detect", "POST", {"ports": ["COM90"]}, headers=LOCAL)
|
|
assert code == 200 and body["job"]["running"]
|
|
# a second search while the first runs is refused, and the port is reserved
|
|
assert call(url + "/api/detect", "POST", {"ports": ["COM90"]}, headers=LOCAL)[0] == 409
|
|
busy = call(url + "/weight?port=COM90&protocol=mahak_mds", headers={"X-Bridge-Token": config.token})
|
|
assert busy[0] == 409
|
|
deadline = time.time() + 60
|
|
job = None
|
|
while time.time() < deadline:
|
|
job = call(url + "/api/detect", headers=LOCAL)[1]["job"]
|
|
if not job["running"]:
|
|
break
|
|
time.sleep(0.3)
|
|
assert job and job["results"][0]["found"] and job["results"][0]["protocol"] == "mahak_mds"
|
|
assert job["progress"] == 1.0
|
|
|
|
code, saved = call(url + "/api/profiles", "POST",
|
|
{"detection": job["results"][0], "name": "packing-1", "label": "Packing"},
|
|
headers=LOCAL)
|
|
assert code == 200 and saved["profile"]["name"] == "packing-1"
|
|
|
|
code, listed = call(url + "/profiles", headers={"X-Bridge-Token": config.token})
|
|
assert [p["name"] for p in listed["profiles"]] == ["packing-1"]
|
|
|
|
deadline = time.time() + 10
|
|
body = {}
|
|
while time.time() < deadline:
|
|
body = call(url + "/weight?profile=packing-1", headers={"X-Bridge-Token": config.token})[1]
|
|
if body.get("stable"):
|
|
break
|
|
time.sleep(0.4)
|
|
assert body["ok"] and body["unit"] == "g" and body["stable"] is True
|
|
|
|
code, _ = call(url + "/api/profiles/packing-1", "DELETE", headers=LOCAL)
|
|
assert code == 200 and config.profiles() == []
|
|
|
|
|
|
def test_allowed_clients_do_not_lock_out_this_pc(bridge_url):
|
|
url, config = bridge_url
|
|
code, _ = call(url + "/api/allowed", "POST", {"clients": ["10.1.2.3"]}, headers=LOCAL)
|
|
assert code == 200
|
|
try:
|
|
assert call(url + "/ports", headers={"X-Bridge-Token": config.token})[0] == 200
|
|
finally:
|
|
call(url + "/api/allowed", "POST", {"clients": []}, headers=LOCAL)
|
|
|
|
|
|
def test_bad_json_is_a_400(bridge_url):
|
|
url, config = bridge_url
|
|
req = urllib.request.Request(url + "/read", method="POST", data=b"{nope",
|
|
headers={"X-Bridge-Token": config.token})
|
|
with pytest.raises(urllib.error.HTTPError) as e:
|
|
urllib.request.urlopen(req, timeout=5)
|
|
assert e.value.code == 400
|