announcer/tests/test_routes.py
Kent c4a15799dd 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>
2026-10-09 02:22:05 +02:00

573 lines
23 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