announcer/tests/test_routes.py
Kent 7c521f8d1f Mesh: sonene melder seg selv; mDNS på LAN, NetBird-provisjonering
Sonene trenger ikke stå i [zones] på sentralen. En sone sender hjerteslag
hvert 30. s (POST /api/mesh/register med navn, egen adresse og
mesh-nøkkelen), dukker opp i lista med en gang, og tas ut etter 90 s uten
slag. Riktig nøkkel er godkjenningen. Statisk [zones] vinner over et navn
som melder seg (409) – en skrivefeil i config skal ikke kunne overstyres av
en boks på nettet.

Hvorfor push og ikke pull fra NetBird: netbird status --json gir navn, IP og
status per peer, men ikke grupper. Pull ville krevd en service-token mot
management-serveren og virket bare over NetBird. Push virker likt over
NetBird og LAN, uten token, og gir liveness gratis.

Sonen finner sentralen lagvis: ANNOUNCER_SENTRAL hvis satt (NetBird-DNS-navn,
konvensjon announcer-<navn>.skynet.corp), ellers mDNS (_announcer._tcp,
discovery.py). Egen adresse regnes ut som ruten mot sentralen – over NetBird
blir det wt0-adressen, på LAN LAN-adressen – med ANNOUNCER_URL som
overstyring. Hjerteslag hvert 5. s til første OK, så oppstart ikke tar et
halvt minutt.

Containeren bruker nå vertens nett. mDNS-multicast når ikke LAN-et fra
bridge-nettet, så det var ingen vei utenom – og det ga tilbake ufw: det
gamle Docker-smutthullet er borte, portregler virker igjen, og BIND_IP er
appens eget --host. Appen lytter alltid også på 127.0.0.1 for helsesjekken.

install.sh tar NETBIRD_SETUP_KEY: installerer NetBird, melder maskina inn
som announcer-<navn>, venter på wt0, binder til NetBird-adressen.
Management-URL leses fra /etc/netbird/config.json når den finnes.

Rollen avgjøres av config (Config.role): sentral hvis [zones] eller
ANNOUNCER_ROLE=sentral, sone hvis bare nøkkelen er satt, ellers som før.
Dispatcher og ZonePoller slår opp soner ved hver bruk, siden de kommer og
går. register følger, som play, aldri med «alt åpent». Flask sorterte
JSON-nøkler alfabetisk, så sonene kom i feil rekkefølge i /api/status –
app.json.sort_keys = False.

25 nye tester, 173 totalt. Verifisert i compose på host-nett: to soner
meldte seg innen 4 s uten noe [zones], fordeling til en av dem havnet i kø,
avahi-browse på verten ser sentralen, en stoppet sone ble tatt ut etter 90 s
og var tilbake 2 s etter start. Underveis fant live-testen at testkonfigen
fortsatt hadde statiske a/b – og sentralen avviste hjerteslagene med 409,
som den skal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 02:50:25 +02:00

648 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tester for HTTP-laget i announcer.py: rutene, køen og planleggeren.
Fram til nå var alt dette uprøvd, fordi `import announcer` startet tråder og
dro inn Piper. Nå starter trådene først i main(), og syntese og avspilling
byttes ut her – så kan køen kjøres ett steg om gangen med run_once().
"""
from __future__ import annotations
import json
import os
import sys
import tempfile
import threading
import time
import wave
from pathlib import Path
import jwt as pyjwt
import pytest
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
# Må settes før import: announcer.py leser konfigurasjonen på modulnivå.
_TMP = Path(tempfile.mkdtemp(prefix="announcer-routes-"))
for _k, _v in {
"ANNOUNCER_CONFIG": "/finnes-ikke.toml",
"ANNOUNCER_CACHE": _TMP / "cache",
"ANNOUNCER_LOG": _TMP / "logs" / "a.jsonl",
"ANNOUNCER_STATE": _TMP / "instance",
"ANNOUNCER_SCHEDULE": _TMP / "scheduled.json",
"ANNOUNCER_VOICES": _TMP / "voices",
"ANNOUNCER_CHIMES": _TMP / "chimes",
}.items():
os.environ[_k] = str(_v)
import announcer # noqa: E402
import auth # noqa: E402
import config # noqa: E402
import schedule as sched # noqa: E402
SECRET = "test-hemmelighet-som-er-lang-nok-for-hmac-sha256-ok"
def tiny_wav(path: Path) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
with wave.open(str(path), "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(8000)
w.writeframes(b"\x00\x00" * 80)
return path
@pytest.fixture
def app(tmp_path, monkeypatch):
"""Frisk kø, frisk plan, falsk syntese og «avspilling» som bare lykkes."""
monkeypatch.setattr(announcer, "LOG_PATH", tmp_path / "a.jsonl")
monkeypatch.setattr(announcer, "CACHE_DIR", tmp_path / "cache")
monkeypatch.setattr(announcer, "schedule", sched.Schedule(tmp_path / "s.json", grace=120))
monkeypatch.setattr(announcer, "player", announcer.Player(on_finished=announcer._scheduled_finished))
monkeypatch.setattr(announcer, "synthesize",
lambda text, voice, speed, chime: tiny_wav(tmp_path / "cache" / "x.wav"))
monkeypatch.setattr(announcer, "player_cmd", lambda path: ["true"])
auth.reconfigure(config.load("/finnes-ikke.toml"))
announcer.app.config["TESTING"] = True
return announcer.app
@pytest.fixture
def c(app):
return app.test_client()
def audit_lines(tmp_path) -> list[dict]:
p = tmp_path / "a.jsonl"
return [json.loads(l) for l in p.read_text().splitlines()] if p.exists() else []
def with_auth(tmp_path, toml: str) -> None:
p = tmp_path / "c.toml"
p.write_text(f'[paths]\nstate = "{tmp_path}"\n\n{toml}')
auth.reconfigure(config.load(p))
def token(sub: str, name: str, roles: list[str]) -> dict:
t = pyjwt.encode({"iss": "https://crew.test", "aud": "announcer", "sub": sub,
"name": name, "roles": roles, "exp": time.time() + 600},
SECRET, algorithm="HS256")
return {"Authorization": f"Bearer {t}"}
JWT_CFG = f'[auth.jwt]\nsecret = "{SECRET}"\naudience = "announcer"\nissuer = "https://crew.test"\n'
# --------------------------------------------------------------------------
# Grunnleggende
# --------------------------------------------------------------------------
def test_sida_og_konfig(c):
assert c.get("/").status_code == 200
d = c.get("/api/config").get_json()
assert d["app"]["title"] == "Announcer"
assert d["auth"]["enabled"] is False
assert "schedule" in d["auth"]["can"]
def test_ukjent_api_rute_svarer_json(c):
r = c.get("/api/finnes-ikke")
assert r.status_code == 404
assert r.get_json()["error"] == "Finnes ikke"
@pytest.mark.parametrize("body, feil", [
({}, "Tom melding"),
({"text": " "}, "Tom melding"),
({"text": "x" * 601}, "Maks 600 tegn"),
({"text": "hei", "voice": "finnes-ikke"}, "Ukjent stemme"),
({"text": "hei", "chime": "finnes-ikke"}, "Ukjent jingle"),
])
def test_ugyldig_melding_gir_400(c, body, feil):
r = c.post("/api/announce", json=body)
assert r.status_code == 400
assert r.get_json()["error"] == feil
def test_hastighet_klemmes_inn(c):
c.post("/api/announce", json={"text": "fort", "speed": 9})
c.post("/api/announce", json={"text": "sakte", "speed": 0.1})
assert [i.speed for i in announcer.player.history] == [1.6, 0.6]
def test_whitespace_normaliseres(c):
c.post("/api/announce", json={"text": " hei på\n\ndeg "})
assert announcer.player.history[0].text == "hei på deg"
# --------------------------------------------------------------------------
# Køen
# --------------------------------------------------------------------------
def test_announce_legger_i_ko_og_spiller(c, tmp_path):
r = c.post("/api/announce", json={"text": "Hallo", "chime": "none"})
assert r.status_code == 200
assert r.get_json()["status"] == "queued"
item = announcer.player.run_once(timeout=1)
assert item.status == "done"
actions = [l["action"] for l in audit_lines(tmp_path)]
assert actions == ["announce", "played"]
assert audit_lines(tmp_path)[1]["status"] == "done"
def test_feilet_avspilling_markeres(c, monkeypatch):
monkeypatch.setattr(announcer, "player_cmd", lambda path: ["false"])
c.post("/api/announce", json={"text": "x"})
assert announcer.player.run_once(timeout=1).status == "failed"
def test_syntesefeil_markeres(c, monkeypatch):
def boom(*a):
raise RuntimeError("edge-tts nede")
monkeypatch.setattr(announcer, "synthesize", boom)
c.post("/api/announce", json={"text": "x"})
item = announcer.player.run_once(timeout=1)
assert item.status == "failed"
assert "edge-tts nede" in item.error
def test_stopp_under_syntese_hindrer_avspilling(c, monkeypatch):
"""Bug nr. 1 fra gjennomgangen: Stopp gjorde ingenting før lyden var
laget, og meldingen gikk ut likevel. Nå skal den aldri nå avspilling."""
spilt = []
def synth_som_blir_stoppet(text, voice, speed, chime):
announcer.player.stop() # noen trykker Stopp midt i syntesen
return tiny_wav(announcer.CACHE_DIR / "x.wav")
monkeypatch.setattr(announcer, "synthesize", synth_som_blir_stoppet)
monkeypatch.setattr(announcer, "player_cmd", lambda p: spilt.append(p) or ["true"])
c.post("/api/announce", json={"text": "feil melding"})
item = announcer.player.run_once(timeout=1)
assert item.status == "stopped"
assert spilt == [], "avspilling skulle aldri startet"
def test_stopp_under_avspilling(c, monkeypatch):
monkeypatch.setattr(announcer, "player_cmd", lambda p: ["sleep", "5"])
c.post("/api/announce", json={"text": "lang"})
t = threading.Thread(target=announcer.player.run_once, kwargs={"timeout": 1})
t.start()
for _ in range(50): # vent til prosessen faktisk kjører
if announcer.player.proc is not None:
break
time.sleep(0.02)
r = c.post("/api/stop")
t.join(timeout=3)
assert r.get_json() == {"ok": True}
assert announcer.player.history[0].status == "stopped"
assert not t.is_alive(), "sleep 5 skulle vært drept"
def test_tom_ko(c):
for i in range(3):
c.post("/api/announce", json={"text": f"m{i}"})
assert c.post("/api/clear").status_code == 200
assert [i.status for i in announcer.player.history] == ["stopped"] * 3
assert announcer.player.run_once(timeout=0.2) is None, "køen skal være tom"
assert c.get("/api/status").get_json()["queued"] == []
def test_status_viser_ko_og_historikk(c):
c.post("/api/announce", json={"text": "en"})
c.post("/api/announce", json={"text": "to"})
announcer.player.run_once(timeout=1)
s = c.get("/api/status").get_json()
assert s["current"] is None
assert [i["text"] for i in s["queued"]] == ["to"]
assert [i["text"] for i in s["history"]] == ["en"]
assert "scheduled" in s and "now" in s
def test_ip_vises_bare_for_den_som_kan_tomme_koen(c, tmp_path):
with_auth(tmp_path, JWT_CFG)
crew = token("7", "Kari", ["crew"])
adm = token("1", "Kent", ["admin"])
c.post("/api/announce", json={"text": "x"}, headers=crew)
assert c.get("/api/status", headers=crew).get_json()["queued"][0]["sender"] == ""
assert c.get("/api/status", headers=adm).get_json()["queued"][0]["sender"] == "127.0.0.1"
def test_401_i_pin_modus_sier_logg_inn(c, tmp_path):
with_auth(tmp_path, '[auth.pin]\npin = "1234"\n')
r = c.post("/api/announce", json={"text": "x"})
assert r.status_code == 401
assert r.get_json() == {"error": "Feil eller manglende PIN.", "login": True}
assert c.get("/api/status").status_code == 401
# --------------------------------------------------------------------------
# Planlagte sendinger gjennom HTTP og planleggeren
# --------------------------------------------------------------------------
def test_planlegging_og_fyring(c, tmp_path):
at = time.time() + 600
r = c.post("/api/schedule", json={"text": "Senere", "chime": "none", "at": at})
assert r.status_code == 200
sid = r.get_json()["id"]
assert [i["id"] for i in c.get("/api/status").get_json()["scheduled"]["pending"]] == [sid]
# Ikke forfalt: ingenting skjer
announcer.scheduler_tick()
assert announcer.player.history == []
# Skru klokka fram: forfalt, innenfor nådefristen
announcer.schedule.get(sid).at = time.time() - 10
announcer.scheduler_tick()
assert announcer.schedule.get(sid).status == "queued"
assert announcer.player.history[0].schedule_id == sid
# Bug nr. 2: statusen skal følge avspillingen helt i mål
announcer.player.run_once(timeout=1)
assert announcer.schedule.get(sid).status == "sent"
assert [l["action"] for l in audit_lines(tmp_path)] == ["schedule", "scheduled_fire", "played"]
def test_planlagt_som_feiler_i_avspilling_star_som_feilet(c, monkeypatch):
monkeypatch.setattr(announcer, "player_cmd", lambda p: ["false"])
sid = c.post("/api/schedule", json={"text": "x", "at": time.time() + 600}).get_json()["id"]
announcer.schedule.get(sid).at = time.time() - 10
announcer.scheduler_tick()
announcer.player.run_once(timeout=1)
assert announcer.schedule.get(sid).status == "failed"
def test_planlagt_som_gikk_glipp_av_spilles_ikke(c, tmp_path):
sid = c.post("/api/schedule", json={"text": "for sent", "at": time.time() + 600}).get_json()["id"]
announcer.schedule.get(sid).at = time.time() - 3600
announcer.scheduler_tick()
assert announcer.schedule.get(sid).status == "missed"
assert announcer.player.history == []
assert [l["action"] for l in audit_lines(tmp_path)] == ["schedule", "missed"]
@pytest.mark.parametrize("body, feil", [
({"text": "x"}, "Mangler eller ugyldig tidspunkt"),
({"text": "x", "at": "snart"}, "Mangler eller ugyldig tidspunkt"),
({"text": "x", "at": 1}, "Tidspunktet er passert"),
({"text": "x", "at": time.time() + 400 * 86400}, "Mer enn 30 dager fram i tid"),
({"text": "", "at": time.time() + 60}, "Tom melding"),
])
def test_ugyldig_planlegging(c, body, feil):
r = c.post("/api/schedule", json=body)
assert r.status_code == 400
assert r.get_json()["error"] == feil
def test_avbryt_egen_planlagt(c, tmp_path):
sid = c.post("/api/schedule", json={"text": "x", "at": time.time() + 600}).get_json()["id"]
assert c.post("/api/schedule/cancel", json={"id": sid}).status_code == 200
assert announcer.schedule.get(sid).status == "cancelled"
assert c.post("/api/schedule/cancel", json={"id": sid}).status_code == 404
assert c.post("/api/schedule/cancel", json={"id": "nope"}).status_code == 404
def test_andres_planlagte_krever_clear(c, tmp_path):
with_auth(tmp_path, JWT_CFG)
kari = token("7", "Kari", ["crew"])
ola = token("8", "Ola", ["crew"])
kent = token("1", "Kent", ["admin"])
sid = c.post("/api/schedule", json={"text": "x", "at": time.time() + 600}, headers=kari).get_json()["id"]
r = c.post("/api/schedule/cancel", json={"id": sid}, headers=ola)
assert r.status_code == 403
assert "Kari" in r.get_json()["error"]
assert c.post("/api/schedule/cancel", json={"id": sid}, headers=kari).status_code == 200
sid2 = c.post("/api/schedule", json={"text": "y", "at": time.time() + 600}, headers=kari).get_json()["id"]
assert c.post("/api/schedule/cancel", json={"id": sid2}, headers=kent).status_code == 200
def test_schedule_krever_rettigheten(c, tmp_path):
with_auth(tmp_path, JWT_CFG + '\n[roles]\nlytter = ["view", "announce"]\n')
h = token("9", "Lytter", ["lytter"])
assert c.post("/api/announce", json={"text": "x"}, headers=h).status_code == 200
assert c.post("/api/schedule", json={"text": "x", "at": time.time() + 60}, headers=h).status_code == 403
# --------------------------------------------------------------------------
# Oppstart
# --------------------------------------------------------------------------
def test_uskrivbar_datamappe_gir_klar_melding(tmp_path, monkeypatch):
locked = tmp_path / "laast"
locked.mkdir()
locked.chmod(0o500)
monkeypatch.setattr(announcer, "CACHE_DIR", locked / "cache")
with pytest.raises(SystemExit) as e:
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
# --------------------------------------------------------------------------
# Hjerteslag inn på sentralen
# --------------------------------------------------------------------------
@pytest.fixture
def registry(app, tmp_path, monkeypatch):
with_auth(tmp_path, MESH_CFG)
reg = mesh.Registry({"fast": mesh.Zone("fast", "http://fast")}, local=mesh.Zone("her"), stale_seconds=90)
disp = mesh.Dispatcher(reg.zones, synthesize=lambda *a: announcer.synthesize(*a),
play_local=announcer._play_local, send=lambda z, p, d: ("queued", 1, ""),
timeout=1, on_finished=announcer._dispatch_finished)
poller = mesh.ZonePoller(reg.zones, fetch=lambda z: {"reachable": True, "queued": 0, "current": None},
interval=99, local_status=lambda: {"queued": 0, "current": None})
monkeypatch.setattr(announcer, "REGISTRY", reg)
monkeypatch.setattr(announcer, "DISPATCHER", disp)
monkeypatch.setattr(announcer, "POLLER", poller)
monkeypatch.setattr(announcer, "ROLE", "sentral")
return reg
def test_register_krever_nokkel(c, registry):
assert c.post("/api/mesh/register", json={"name": "a", "url": "http://a"}).status_code == 401
r = c.post("/api/mesh/register", json={"name": "a", "url": "http://a"}, headers={"X-Mesh-Key": "feil"})
assert r.status_code == 401
def test_register_tar_sonen_i_bruk_med_en_gang(c, registry):
r = c.post("/api/mesh/register", json={"name": "a", "url": "http://10.0.0.7:8090", "version": "0.3"}, headers=KEY)
assert r.status_code == 200
assert r.get_json()["name"] == "a" and r.get_json()["stale_seconds"] == 90
assert registry.zones()["a"].url == "http://10.0.0.7:8090"
s = c.get("/api/status").get_json()
assert list(s["zones"]) == ["her", "fast", "a"]
assert s["zones"]["a"]["source"] == "registered" and s["zones"]["a"]["last_seen"]
assert s["zones"]["fast"]["source"] == "static"
assert s["mesh"] == {"role": "sentral", "name": announcer.MESH_NAME, "heartbeat": None}
# og kan sendes til
assert c.post("/api/announce", json={"text": "x", "zones": ["a"]}).status_code == 200
@pytest.mark.parametrize("body, status, feil", [
({"name": "fast", "url": "http://x"}, 409, "er satt i"),
({"name": "her", "url": "http://x"}, 409, "eget navn"),
({"name": "", "url": "http://x"}, 400, "ugyldig sonenavn"),
({"name": "a", "url": "ikke-url"}, 400, "http"),
({}, 400, "ugyldig sonenavn"),
])
def test_register_avviser(c, registry, body, status, feil):
r = c.post("/api/mesh/register", json=body, headers=KEY)
assert r.status_code == status and feil in r.get_json()["error"]
def test_register_pa_en_som_ikke_er_sentral(c, tmp_path):
with_auth(tmp_path, MESH_CFG)
r = c.post("/api/mesh/register", json={"name": "a", "url": "http://a"}, headers=KEY)
assert r.status_code == 404 and "ikke sentral" in r.get_json()["error"]
def test_register_gjennom_ekte_hjerteslag(live_sone, tmp_path, monkeypatch):
"""Heartbeat -> post_register -> levende /api/mesh/register."""
reg = mesh.Registry({}, local=mesh.Zone("her"))
monkeypatch.setattr(announcer, "REGISTRY", reg)
hb = mesh.Heartbeat(name="pi-i-skapet", port=8093, secret="mesh-hemmelighet-for-test",
sentral=lambda: live_sone.url, version="0.3")
assert hb.beat_once() is True, hb.last_error
assert reg.zones()["pi-i-skapet"].url == "http://127.0.0.1:8093"
assert reg.info()["pi-i-skapet"]["version"] == "0.3"
hb_feil = mesh.Heartbeat(name="x", port=1, secret="feil", sentral=lambda: live_sone.url)
assert hb_feil.beat_once() is False and "401" in hb_feil.last_error