Mesh: én sentral fordeler til flere soner
Flere anlegg – hovedscene, resepsjon, crew – og ett sted å sende fra, med avkryssing for hvilke soner meldingen skal til. Sentralen er en vanlig announcer med [zones] i config. En sone er en vanlig announcer med ANNOUNCER_MESH_SECRET. Samme image, ingen mode, og en sone kan brukes alene hvis sentralen faller ut. Hver avhengighet med C-kode har aarch64-wheel i uv.lock, så det samme bildet går på en Raspberry Pi uten å kompilere noe. Sentralen syntetiserer én gang og sender ferdig WAV (POST /api/play, multipart). Sonen trenger da verken nett, edge-tts eller Pipers 670 MB for det den får – Item.wav er hele endringen i avspillingen. Køene forblir per sone: dispatcheren har egen tråd og egen kø, leverer til hver sone i hver sin tråd med timeout, og går videre. En opptatt hovedscene holder ikke igjen resepsjonen, og en sone som er nede feiler alene – synlig per sone i UI og revisjonslogg. Auth gjenbrukes: ny leverandør mesh på header X-Mesh-Key (ikke Bearer – den leses av JWT-leverandøren, som avviser alt som ikke er et JWT før neste får prøve), med én handling, play. To regler som fulgte med: - play følger aldri med «alt åpent». Uten det kunne hvem som helst POSTe en WAV til en åpen sone og få den spilt. Testen for åpen modus fanget det. - En legitimasjon som vises fram og er feil gir 401 også der gjester ellers har lov. Før slapp en feil PIN i åpen modus stille gjennom som gjest. mesh teller ikke som innlogging for folk: en sone med bare nøkkelen har fortsatt åpen side lokalt. Planlagte sendinger husker sonene sine og fordeles gjennom dispatcheren; planen ender som sent hvis minst én sone tok imot, med «hovedscene: kø #3, crew: nede» som note. Hodeløs Pi: install.sh kjører loginctl enable-linger, ellers finnes ikke lydsocketen når ingen logger inn. docs/mesh.md tar også raspi-config for jack framfor HDMI. 38 nye tester, 148 totalt. Fordelingen testes uten nett med stubbet transport; selve HTTP-en testes mot en levende werkzeug-server, så den håndrullede multiparten møter Flask sin parser. Verifisert i compose med sentral og to soner: til alle, bare til én, og én sone stoppet. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
eb27a162d5
commit
c4a15799dd
17 changed files with 1486 additions and 23 deletions
|
|
@ -72,7 +72,21 @@ def test_uten_konfig_er_alt_apent(tmp_path):
|
|||
c, a = build(tmp_path)
|
||||
assert a.enabled is False
|
||||
for action in config.ACTIONS:
|
||||
if action == "play":
|
||||
continue
|
||||
assert c.get(f"/do/{action}").status_code == 200, action
|
||||
# Det ene unntaket: «play» er for en sentral med mesh-nøkkel. En åpen
|
||||
# sone skal ikke spille lyd for hvem som helst som finner den.
|
||||
assert c.get("/do/play").status_code == 401
|
||||
|
||||
|
||||
def test_feil_legitimasjon_er_feil_ogsa_pa_apen_sone(tmp_path):
|
||||
"""En PIN som vises fram og er feil, skal avvises – ikke behandles som
|
||||
gjest fordi gjester tilfeldigvis har lov."""
|
||||
c, _ = build(tmp_path, '[mesh]\nsecret = "riktig"\n')
|
||||
assert c.get("/do/view").status_code == 200 # gjest: lov
|
||||
assert c.get("/do/view", headers={"X-Mesh-Key": "feil"}).status_code == 401 # feil nøkkel: nei
|
||||
assert c.get("/do/play", headers={"X-Mesh-Key": "riktig"}).status_code == 200
|
||||
|
||||
|
||||
def test_tom_providers_liste_er_fortsatt_apen(tmp_path):
|
||||
|
|
|
|||
240
tests/test_mesh.py
Normal file
240
tests/test_mesh.py
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
"""Tester for mesh.py: soner, fordeling og statuspolling.
|
||||
|
||||
Dispatcheren får synthesize/play_local/send inn som funksjoner, så alt her
|
||||
går uten nett. Den ekte HTTP-transporten (send_to_zone mot en levende
|
||||
/api/play) prøves i test_routes.py, der announcer-appen finnes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import mesh # noqa: E402
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Soner fra config
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_zones_fra_config():
|
||||
z = mesh.zones_from_config({"her": "local", "scene": "http://10.0.0.5:8090/", "res": "https://r.lan"})
|
||||
assert z["her"].local and z["her"].url == ""
|
||||
assert not z["scene"].local and z["scene"].url == "http://10.0.0.5:8090" # uten skråstrek
|
||||
assert list(z) == ["her", "scene", "res"] # rekkefølgen bevares
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw, feil", [
|
||||
({"scene": "10.0.0.5:8090"}, "verken"),
|
||||
({"scene": "ftp://x"}, "verken"),
|
||||
({"": "local"}, "ugyldig sonenavn"),
|
||||
({"har mellomrom": "local"}, "ugyldig sonenavn"),
|
||||
({"x" * 33: "local"}, "ugyldig sonenavn"),
|
||||
])
|
||||
def test_ugyldig_zones_feiler_hoyt(raw, feil):
|
||||
with pytest.raises(ValueError, match=feil):
|
||||
mesh.zones_from_config(raw)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Dispatcher
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
ZONES = mesh.zones_from_config({"her": "local", "a": "http://a", "b": "http://b"})
|
||||
|
||||
|
||||
def make(tmp_path, *, send=None, synth=None, timeout=1.0, on_finished=None):
|
||||
wav = tmp_path / "x.wav"
|
||||
wav.write_bytes(b"RIFF")
|
||||
played: list[tuple[str, Path]] = []
|
||||
sent: list[str] = []
|
||||
|
||||
def play_local(d, path):
|
||||
played.append((d.text, path))
|
||||
return 42
|
||||
|
||||
def default_send(zone, path, d):
|
||||
sent.append(zone.name)
|
||||
return "queued", 7, ""
|
||||
|
||||
disp = mesh.Dispatcher(
|
||||
ZONES,
|
||||
synthesize=synth or (lambda *a: wav),
|
||||
play_local=play_local,
|
||||
send=send or default_send,
|
||||
timeout=timeout,
|
||||
on_finished=on_finished,
|
||||
)
|
||||
return disp, played, sent, wav
|
||||
|
||||
|
||||
def test_resolve():
|
||||
disp, *_ = make(Path("/tmp"))
|
||||
assert disp.resolve(None) == ["her", "a", "b"]
|
||||
assert disp.resolve([]) == ["her", "a", "b"]
|
||||
assert disp.resolve(["b", "a", "b"]) == ["b", "a"] # dedupe, rekkefølge som bedt
|
||||
assert disp.resolve("a") == ["a"]
|
||||
with pytest.raises(ValueError, match="Ukjent sone: c"):
|
||||
disp.resolve(["a", "c"])
|
||||
with pytest.raises(ValueError, match="liste"):
|
||||
disp.resolve({"a": 1})
|
||||
|
||||
|
||||
def test_fordeling_til_alle(tmp_path):
|
||||
ferdig = []
|
||||
disp, played, sent, wav = make(tmp_path, on_finished=ferdig.append)
|
||||
d = disp.submit("Hallo", "finn", 1.0, "none", ["her", "a", "b"], user="Kari")
|
||||
assert d.pending
|
||||
assert disp.run_once(timeout=1) is d
|
||||
assert not d.pending
|
||||
assert {n: z.status for n, z in d.zones.items()} == {"her": "queued", "a": "queued", "b": "queued"}
|
||||
assert d.zones["her"].item_id == 42 and d.zones["a"].item_id == 7
|
||||
assert played == [("Hallo", wav)]
|
||||
assert sorted(sent) == ["a", "b"]
|
||||
assert ferdig == [d]
|
||||
assert d.accepted == ["her", "a", "b"]
|
||||
assert d.summary() == "her: kø #42, a: kø #7, b: kø #7"
|
||||
|
||||
|
||||
def test_bare_valgte_soner(tmp_path):
|
||||
disp, played, sent, _ = make(tmp_path)
|
||||
disp.submit("x", "finn", 1.0, "none", ["b"])
|
||||
disp.run_once(timeout=1)
|
||||
assert played == [] and sent == ["b"]
|
||||
|
||||
|
||||
def test_en_sone_nede_stopper_ikke_de_andre(tmp_path):
|
||||
def send(zone, path, d):
|
||||
if zone.name == "b":
|
||||
return "unreachable", None, "connection refused"
|
||||
return "queued", 3, ""
|
||||
disp, played, _, _ = make(tmp_path, send=send)
|
||||
d = disp.submit("x", "finn", 1.0, "none", ["her", "a", "b"])
|
||||
disp.run_once(timeout=1)
|
||||
assert d.zones["a"].status == "queued"
|
||||
assert d.zones["b"].status == "unreachable" and "refused" in d.zones["b"].error
|
||||
assert d.zones["her"].status == "queued"
|
||||
assert d.accepted == ["her", "a"]
|
||||
assert "b: nede" in d.summary()
|
||||
|
||||
|
||||
def test_send_som_kaster_blir_unreachable(tmp_path):
|
||||
def send(zone, path, d):
|
||||
raise OSError("kabelen er ute")
|
||||
disp, *_ = make(tmp_path, send=send)
|
||||
d = disp.submit("x", "finn", 1.0, "none", ["a"])
|
||||
disp.run_once(timeout=1)
|
||||
assert d.zones["a"].status == "unreachable"
|
||||
assert "kabelen" in d.zones["a"].error
|
||||
|
||||
|
||||
def test_treg_sone_gir_opp_etter_timeout(tmp_path):
|
||||
def send(zone, path, d):
|
||||
time.sleep(2)
|
||||
return "queued", 1, ""
|
||||
disp, *_ = make(tmp_path, send=send, timeout=0.1)
|
||||
d = disp.submit("x", "finn", 1.0, "none", ["a"])
|
||||
t0 = time.time()
|
||||
disp.run_once(timeout=1)
|
||||
assert time.time() - t0 < 1.8
|
||||
assert d.zones["a"].status == "unreachable"
|
||||
assert "i tide" in d.zones["a"].error
|
||||
|
||||
|
||||
def test_syntesefeil_feiler_alle_soner(tmp_path):
|
||||
def synth(*a):
|
||||
raise RuntimeError("edge-tts nede")
|
||||
ferdig = []
|
||||
disp, played, sent, _ = make(tmp_path, synth=synth, on_finished=ferdig.append)
|
||||
d = disp.submit("x", "finn", 1.0, "none", ["her", "a"])
|
||||
disp.run_once(timeout=1)
|
||||
assert d.error == "edge-tts nede"
|
||||
assert all(z.status == "failed" for z in d.zones.values())
|
||||
assert played == [] and sent == []
|
||||
assert d.accepted == []
|
||||
assert ferdig == [d] # planen må få vite det
|
||||
|
||||
|
||||
def test_lokal_feil_rammer_bare_lokal(tmp_path):
|
||||
disp, _, sent, _ = make(tmp_path)
|
||||
def boom(d, path):
|
||||
raise RuntimeError("ingen lyd")
|
||||
disp.play_local = boom
|
||||
d = disp.submit("x", "finn", 1.0, "none", ["her", "a"])
|
||||
disp.run_once(timeout=1)
|
||||
assert d.zones["her"].status == "failed" and "ingen lyd" in d.zones["her"].error
|
||||
assert d.zones["a"].status == "queued"
|
||||
|
||||
|
||||
def test_snapshot_nyeste_forst_og_uten_ip(tmp_path):
|
||||
disp, *_ = make(tmp_path)
|
||||
disp.submit("en", "finn", 1.0, "none", ["a"], sender="10.0.0.9")
|
||||
disp.submit("to", "finn", 1.0, "none", ["a"], sender="10.0.0.9")
|
||||
snap = disp.snapshot()
|
||||
assert [d["text"] for d in snap] == ["to", "en"]
|
||||
assert snap[0]["sender"] == ""
|
||||
assert disp.snapshot(show_ip=True)[0]["sender"] == "10.0.0.9"
|
||||
assert "zones" in snap[0] and snap[0]["zones"]["a"]["status"] == "sending"
|
||||
|
||||
|
||||
def test_run_once_pa_tom_ko(tmp_path):
|
||||
disp, *_ = make(tmp_path)
|
||||
assert disp.run_once(timeout=0.05) is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# ZonePoller
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_poller_henter_eksterne_og_slar_opp_lokal_ferskt():
|
||||
svar = {"a": {"reachable": True, "current": None, "queued": 2},
|
||||
"b": {"reachable": False, "error": "timeout"}}
|
||||
lokal = {"n": 0}
|
||||
def local_status():
|
||||
lokal["n"] += 1
|
||||
return {"current": None, "queued": lokal["n"]}
|
||||
p = mesh.ZonePoller(ZONES, fetch=lambda z: svar[z.name], interval=99, local_status=local_status)
|
||||
|
||||
before = p.snapshot()
|
||||
assert before["a"]["reachable"] is False and "ennå" in before["a"]["error"]
|
||||
assert before["her"]["reachable"] is True and before["her"]["queued"] == 1
|
||||
|
||||
p.poll_once()
|
||||
after = p.snapshot()
|
||||
assert after["a"] == {"reachable": True, "current": None, "queued": 2, "checked": after["a"]["checked"]}
|
||||
assert after["b"]["reachable"] is False and after["b"]["error"] == "timeout"
|
||||
assert after["her"]["queued"] == 2 # lokal slås opp på nytt hver gang
|
||||
assert list(after) == ["her", "a", "b"]
|
||||
|
||||
|
||||
def test_poller_taler_fetch_som_kaster():
|
||||
def fetch(z):
|
||||
raise RuntimeError("nei")
|
||||
p = mesh.ZonePoller(ZONES, fetch=fetch, interval=99)
|
||||
p.poll_once() # skal ikke kaste
|
||||
assert p.snapshot()["a"]["reachable"] is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Transport mot noe som ikke finnes
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_send_til_sone_som_ikke_finnes(tmp_path):
|
||||
wav = tmp_path / "x.wav"; wav.write_bytes(b"RIFF")
|
||||
d = mesh.Dispatch("abc", "x", "finn", 1.0, "none", {})
|
||||
status, item_id, err = mesh.send_to_zone(mesh.Zone("z", "http://127.0.0.1:1"), wav, d,
|
||||
secret="s", timeout=1)
|
||||
assert status == "unreachable" and item_id is None and err
|
||||
|
||||
|
||||
def test_status_fra_sone_som_ikke_finnes():
|
||||
r = mesh.fetch_zone_status(mesh.Zone("z", "http://127.0.0.1:1"), secret="s", timeout=1)
|
||||
assert r["reachable"] is False and r["error"]
|
||||
|
|
@ -343,3 +343,231 @@ def test_uskrivbar_datamappe_gir_klar_melding(tmp_path, monkeypatch):
|
|||
announcer.check_writable()
|
||||
assert "PUID" in str(e.value) and "chown" in str(e.value)
|
||||
locked.chmod(0o700)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Mesh gjennom HTTP: sonens /api/play, sentralens fordeling, og ekte transport
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
import mesh # noqa: E402
|
||||
|
||||
MESH_CFG = '[mesh]\nsecret = "mesh-hemmelighet-for-test"\n'
|
||||
KEY = {"X-Mesh-Key": "mesh-hemmelighet-for-test"}
|
||||
|
||||
|
||||
def play_body(tmp_path, meta: dict | None = None):
|
||||
wav = tiny_wav(tmp_path / "inn.wav")
|
||||
return {"wav": (wav.open("rb"), "inn.wav"),
|
||||
"meta": json.dumps(meta or {"origin_id": "d1", "text": "Fra sentralen", "user": "Kari",
|
||||
"subject": "jwt:7", "method": "jwt", "voice": "finn"})}
|
||||
|
||||
|
||||
def test_play_krever_mesh_nokkel(c, tmp_path):
|
||||
with_auth(tmp_path, MESH_CFG)
|
||||
assert c.post("/api/play", data=play_body(tmp_path)).status_code == 401
|
||||
r = c.post("/api/play", data=play_body(tmp_path), headers={"X-Mesh-Key": "feil"})
|
||||
assert r.status_code == 401 and r.get_json()["error"] == "Feil mesh-nøkkel"
|
||||
|
||||
|
||||
def test_play_uten_mesh_oppsett_er_stengt(c):
|
||||
"""Ingen leverandør gir play, så 401 selv om endepunktet finnes."""
|
||||
assert c.post("/api/play", data=play_body(_TMP)).status_code == 401
|
||||
|
||||
|
||||
def test_mesh_nokkel_apner_ikke_sida_for_folk(c, tmp_path):
|
||||
"""En sone med bare mesh-nøkkel skal fortsatt ha åpen side lokalt."""
|
||||
with_auth(tmp_path, MESH_CFG)
|
||||
d = c.get("/api/config").get_json()["auth"]
|
||||
assert d["enabled"] is False and d["methods"] == []
|
||||
assert c.post("/api/announce", json={"text": "lokalt"}).status_code == 200
|
||||
|
||||
|
||||
def test_play_koer_wav_uten_syntese(c, tmp_path, monkeypatch):
|
||||
with_auth(tmp_path, MESH_CFG)
|
||||
def aldri(*a):
|
||||
raise AssertionError("sonen skal ikke syntetisere")
|
||||
monkeypatch.setattr(announcer, "synthesize", aldri)
|
||||
|
||||
r = c.post("/api/play", data=play_body(tmp_path), headers=KEY)
|
||||
assert r.status_code == 200
|
||||
item = announcer.player.history[0]
|
||||
assert r.get_json() == {"id": item.id}
|
||||
assert item.wav == announcer.CACHE_DIR / "mesh-d1.wav" and item.wav.exists()
|
||||
assert (item.text, item.user, item.subject, item.method) == ("Fra sentralen", "Kari", "jwt:7", "jwt")
|
||||
assert announcer.player.run_once(timeout=1).status == "done"
|
||||
log = audit_lines(tmp_path)
|
||||
assert log[0]["action"] == "play" and log[0]["user"] == "Kari" and log[0]["subject"] == "mesh"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("data, feil", [
|
||||
({}, "Mangler wav"),
|
||||
({"meta": "{ikke json"}, "Ugyldig meta"),
|
||||
])
|
||||
def test_play_avviser_ufullstendig(c, tmp_path, data, feil):
|
||||
with_auth(tmp_path, MESH_CFG)
|
||||
if "meta" in data:
|
||||
data = {**data, "wav": (tiny_wav(tmp_path / "w.wav").open("rb"), "w.wav")}
|
||||
r = c.post("/api/play", data=data, headers=KEY)
|
||||
assert r.status_code == 400 and r.get_json()["error"] == feil
|
||||
|
||||
|
||||
def test_play_avviser_fil_som_ikke_er_wav(c, tmp_path):
|
||||
with_auth(tmp_path, MESH_CFG)
|
||||
junk = tmp_path / "junk.wav"; junk.write_bytes(b"dette er ikke lyd")
|
||||
r = c.post("/api/play", data={"wav": (junk.open("rb"), "junk.wav"), "meta": "{}"}, headers=KEY)
|
||||
assert r.status_code == 400 and "WAV" in r.get_json()["error"]
|
||||
assert not list(announcer.CACHE_DIR.glob("mesh-*.wav")), "søppel skal ikke bli liggende"
|
||||
|
||||
|
||||
def test_zones_uten_mesh_oppsett_gir_400(c):
|
||||
r = c.post("/api/announce", json={"text": "x", "zones": ["scene"]})
|
||||
assert r.status_code == 400 and "Ingen soner" in r.get_json()["error"]
|
||||
assert c.get("/api/config").get_json()["zones"] == []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sentral(app, tmp_path, monkeypatch):
|
||||
"""Gjør test-appen til sentral for «her» + to soner, med stubbet transport."""
|
||||
zones = mesh.zones_from_config({"her": "local", "a": "http://a", "b": "http://b"})
|
||||
sent: list[tuple[str, str]] = []
|
||||
def send(zone, path, d):
|
||||
sent.append((zone.name, d.text))
|
||||
return ("unreachable", None, "nede") if zone.name == "b" else ("queued", 5, "")
|
||||
disp = mesh.Dispatcher(zones, synthesize=lambda *a: announcer.synthesize(*a),
|
||||
play_local=announcer._play_local, send=send, timeout=1,
|
||||
on_finished=announcer._dispatch_finished)
|
||||
poller = mesh.ZonePoller(zones, fetch=lambda z: {"reachable": z.name == "a", "queued": 0, "current": None},
|
||||
interval=99, local_status=lambda: {"queued": 0, "current": None})
|
||||
monkeypatch.setattr(announcer, "DISPATCHER", disp)
|
||||
monkeypatch.setattr(announcer, "POLLER", poller)
|
||||
return disp, sent
|
||||
|
||||
|
||||
def test_sentral_fordeler(c, sentral, tmp_path):
|
||||
disp, sent = sentral
|
||||
assert c.get("/api/config").get_json()["zones"] == ["her", "a", "b"]
|
||||
|
||||
r = c.post("/api/announce", json={"text": "Til alle", "chime": "none"})
|
||||
assert r.status_code == 200
|
||||
body = r.get_json()
|
||||
assert body["dispatch"]["zones"]["a"]["status"] == "sending"
|
||||
|
||||
d = disp.run_once(timeout=1)
|
||||
assert d.id == body["id"]
|
||||
assert {n: z.status for n, z in d.zones.items()} == {"her": "queued", "a": "queued", "b": "unreachable"}
|
||||
assert sorted(sent) == [("a", "Til alle"), ("b", "Til alle")]
|
||||
|
||||
# Lokalt: ferdig WAV i køen, spilles uten ny syntese
|
||||
item = announcer.player.history[0]
|
||||
assert item.wav is not None and item.text == "Til alle"
|
||||
assert announcer.player.run_once(timeout=1).status == "done"
|
||||
|
||||
log = audit_lines(tmp_path)
|
||||
assert [l["action"] for l in log] == ["announce", "dispatched", "played"]
|
||||
assert log[0]["zones"] == ["her", "a", "b"]
|
||||
assert log[1]["zones"] == {"her": "queued", "a": "queued", "b": "unreachable"}
|
||||
|
||||
|
||||
def test_sentral_bare_til_valgte(c, sentral):
|
||||
disp, sent = sentral
|
||||
c.post("/api/announce", json={"text": "x", "zones": ["a"]})
|
||||
disp.run_once(timeout=1)
|
||||
assert sent == [("a", "x")]
|
||||
assert announcer.player.history == [] # ikke lokalt
|
||||
|
||||
|
||||
def test_sentral_ukjent_sone_gir_400_for_noe_sendes(c, sentral):
|
||||
disp, sent = sentral
|
||||
r = c.post("/api/announce", json={"text": "x", "zones": ["a", "finnes-ikke"]})
|
||||
assert r.status_code == 400 and r.get_json()["error"] == "Ukjent sone: finnes-ikke"
|
||||
assert disp.run_once(timeout=0.05) is None and sent == []
|
||||
|
||||
|
||||
def test_sentral_status_har_soner_og_fordelinger(c, sentral):
|
||||
disp, _ = sentral
|
||||
announcer.POLLER.poll_once()
|
||||
c.post("/api/announce", json={"text": "x"})
|
||||
disp.run_once(timeout=1)
|
||||
s = c.get("/api/status").get_json()
|
||||
assert s["zones"]["her"]["reachable"] and s["zones"]["a"]["reachable"] and not s["zones"]["b"]["reachable"]
|
||||
assert s["dispatches"][0]["text"] == "x"
|
||||
assert s["dispatches"][0]["zones"]["b"]["status"] == "unreachable"
|
||||
|
||||
|
||||
def test_planlagt_med_soner_fordeles(c, sentral, tmp_path):
|
||||
disp, sent = sentral
|
||||
r = c.post("/api/schedule", json={"text": "Planlagt", "at": time.time() + 600, "zones": ["a", "her"]})
|
||||
sid = r.get_json()["id"]
|
||||
assert r.get_json()["zones"] == ["a", "her"]
|
||||
# overlever reload
|
||||
assert sched.Schedule(announcer.schedule.path).get(sid).zones == ["a", "her"]
|
||||
|
||||
announcer.schedule.get(sid).at = time.time() - 10
|
||||
announcer.scheduler_tick()
|
||||
assert announcer.schedule.get(sid).status == "queued"
|
||||
assert "fordeles til a, her" in announcer.schedule.get(sid).note
|
||||
d = disp.run_once(timeout=1)
|
||||
assert d.schedule_id == sid
|
||||
assert sent == [("a", "Planlagt")]
|
||||
assert announcer.schedule.get(sid).status == "sent"
|
||||
assert announcer.schedule.get(sid).note == "a: kø #5, her: kø #1" # rekkefølgen som ble bedt om
|
||||
|
||||
|
||||
def test_planlagt_der_alle_soner_er_nede_blir_failed(c, sentral):
|
||||
disp, _ = sentral
|
||||
sid = c.post("/api/schedule", json={"text": "x", "at": time.time() + 600, "zones": ["b"]}).get_json()["id"]
|
||||
announcer.schedule.get(sid).at = time.time() - 10
|
||||
announcer.scheduler_tick()
|
||||
disp.run_once(timeout=1)
|
||||
assert announcer.schedule.get(sid).status == "failed"
|
||||
assert "b: nede" in announcer.schedule.get(sid).note
|
||||
|
||||
|
||||
def test_planlagt_til_sone_som_er_fjernet_fra_config(c, sentral):
|
||||
"""Planen ble lagt før sonen ble tatt ut av config."""
|
||||
sid = c.post("/api/schedule", json={"text": "x", "at": time.time() + 600}).get_json()["id"]
|
||||
announcer.schedule.get(sid).zones = ["borte"]
|
||||
announcer.schedule.get(sid).at = time.time() - 10
|
||||
announcer.scheduler_tick()
|
||||
assert announcer.schedule.get(sid).status == "failed"
|
||||
assert "Ukjent sone" in announcer.schedule.get(sid).note
|
||||
|
||||
|
||||
# --- ekte transport: send_to_zone mot en levende /api/play -------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def live_sone(app, tmp_path):
|
||||
"""Kjører test-appen som sone på en ekte port, så multipart-koden i
|
||||
send_to_zone møter Flask sin parser og ikke bare seg selv."""
|
||||
from werkzeug.serving import make_server
|
||||
with_auth(tmp_path, MESH_CFG)
|
||||
srv = make_server("127.0.0.1", 0, app, threaded=True)
|
||||
t = threading.Thread(target=srv.serve_forever, daemon=True)
|
||||
t.start()
|
||||
yield mesh.Zone("live", f"http://127.0.0.1:{srv.server_port}")
|
||||
srv.shutdown()
|
||||
|
||||
|
||||
def test_ekte_runde_sentral_til_sone(live_sone, tmp_path):
|
||||
wav = tiny_wav(tmp_path / "ut.wav")
|
||||
d = mesh.Dispatch("rt1", "Over nettet", "finn", 1.0, "none", {}, user="Kari", subject="jwt:7", method="jwt")
|
||||
|
||||
status, item_id, err = mesh.send_to_zone(live_sone, wav, d, secret="mesh-hemmelighet-for-test", timeout=3)
|
||||
assert (status, err) == ("queued", "")
|
||||
item = announcer.player.history[0]
|
||||
assert item.id == item_id
|
||||
assert item.text == "Over nettet" and item.user == "Kari"
|
||||
assert item.wav.read_bytes() == wav.read_bytes()
|
||||
|
||||
st = mesh.fetch_zone_status(live_sone, secret="mesh-hemmelighet-for-test", timeout=3)
|
||||
assert st["reachable"] is True and st["queued"] == 1
|
||||
|
||||
|
||||
def test_ekte_runde_med_feil_nokkel(live_sone, tmp_path):
|
||||
wav = tiny_wav(tmp_path / "ut.wav")
|
||||
d = mesh.Dispatch("rt2", "x", "finn", 1.0, "none", {})
|
||||
status, item_id, err = mesh.send_to_zone(live_sone, wav, d, secret="feil", timeout=3)
|
||||
assert status == "rejected" and item_id is None
|
||||
assert err.startswith("401") and "mesh-nøkkel" in err
|
||||
assert mesh.fetch_zone_status(live_sone, secret="feil", timeout=3)["reachable"] is False
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue