Gjennomgang: Stopp under syntese, planstatus, waitress, tester på HTTP-laget

Sju ting fra en gjennomgang av det som ble bygd i går.

Stopp virket ikke mens lyden ble laget. Player.stop() drepte self.proc, men
under syntesen finnes ingen prosess ennå – det er de første sekundene av hver
edge-tts-melding – så knappen gjorde ingenting, og meldingen gikk ut etterpå.
Nå har køen statusen preparing, stop() setter stopped på den, og run_once()
sjekker det før den starter avspilling. UI-et viser «Lager lyd …» i stedet
for å late som det spilles i stillhet.

Planlagte sendinger løy om status. De ble markert «sendt» idet de ble lagt i
kø, og ble stående slik selv om avspillingen feilet. Player har fått en
on_finished-callback, og planen følger nå meldingen helt i mål: sent, failed
eller stopped – det som faktisk skjedde. Statusene i schedule.py er utvidet
tilsvarende (queued er «lagt i kø», sent er «spilt ferdig»).

Flask sin utviklingsserver er byttet ut med waitress. Én prosess med mange
tråder, med vilje: Player og planleggeren er singletons, så gunicorn med
flere workere ville gitt flere køer som snakker i munnen på hverandre.

HTTP-laget hadde null tester. import announcer startet tråder og dro inn
Piper, så rutene lot seg ikke prøve. Trådene startes nå i main(), og
run_once() driver køen ett steg om gangen. tests/test_routes.py dekker
rutene, valideringen, køen, Stopp i begge faser, revisjonsloggen og hele
planleggingsflyten gjennom HTTP. 30 nye tester, 110 totalt.

Kappløp i synthesize(): tmp-fila hadde fast navn, så to som forhåndslyttet
samme tekst samtidig skrev til samme fil, og den ene rename feilet. Unik
tmp-fil per kall.

verify_signature = false er fjernet. Det var en felle: én feilskrevet linje
i config, og alle tokens ble godtatt. Ingen trenger den.

docker compose up uten install.sh krasjet kryptisk ved første utrop, fordi
Docker lagde data/ som root. check_writable() sier nå klart fra ved oppstart,
med hva som må gjøres.

Verifisert live i container: Stopp under edge-tts-syntese gir stopped, ikke
done. Server-headeren er waitress sin, ikke werkzeugs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kent 2026-10-09 01:41:31 +02:00
parent 04b3712ef5
commit eb27a162d5
9 changed files with 565 additions and 81 deletions

View file

@ -22,7 +22,7 @@ Filer:
| `index.html` | hele sida, inline CSS/JS, ingen byggesteg |
| `config.example.toml` | kommentert mal; kopier til `config.toml` |
| `docs/crew-integration.md` | kontrakten mot crew-panelet |
| `tests/test_auth.py` + `test_schedule.py` | `uv run pytest` – 80 tester |
| `tests/` | `uv run pytest` – 110 tester: auth, planlegging og HTTP-laget |
| `Dockerfile` + `compose.yaml` | **drift** – appen kjøres i Docker |
| `.env.example` | bruker-ID, portbinding og hemmeligheter |
| `compose.test.yaml` | testoppsett med dummy-IdP, se under |
@ -254,6 +254,12 @@ satt i gang.
- `/api/announce` legger meldingen i én FIFO-kø; en bakgrunnstråd spiller én om
gangen med `pw-play`. `/api/stop` dreper gjeldende avspilling, `/api/clear`
tømmer køen.
- En melding går `queued → preparing → playing → done | stopped | failed`.
`preparing` er syntesen – de første sekundene av hver edge-tts-melding, der
det er stille. Stopp virker også der: `Player.stop()` setter statusen, og
`run_once()` sjekker den før den starter avspilling. En planlagt sending
følger med helt i mål via `on_finished`, så planen viser `sent`, `failed`
eller `stopped` – det som faktisk skjedde – ikke bare at den ble lagt i kø.
- `/api/status` krever `view`. Den var åpen før, og lekket både meldingstekster
og IP-ene til dem som hadde sendt noe. IP vises nå bare for den som har
`clear`; alle andre ser navnet.
@ -301,9 +307,14 @@ Vil Kent ha enda bedre lyd: ElevenLabs (nøkkel, best norsk) eller OpenAI
- `pkill -f announcer.py` fra et skall som selv har `announcer.py` i
kommandolinja dreper skallet. Gjelder utvikling med `uv run`; i drift er
det `docker compose`.
- `player = Player()` står på modulnivå, så avspillingstråden starter ved
`import announcer` – også i `install.sh`, som importerer modulen for å laste
ned stemmer. Harmløst, men greit å vite.
- Trådene (`player.start()`, `start_scheduler()`) startes i `main()`, ikke
ved import. Det er det som gjør at `tests/test_routes.py` kan kjøre rutene
med en test_client og drive køen ett steg om gangen med
`player.run_once()`. Starter du dem ved import igjen, mister du den
muligheten.
- **Én prosess.** `main()` kjører waitress med mange tråder, ikke gunicorn
med flere workere: `Player` og planleggeren er singletons i prosessen, og
flere workere ville gitt flere køer som snakker i munnen på hverandre.
- **Oppgradering:** `schedule` er en ny handling. Har et oppsett et eksplisitt
`[roles]` i `config.toml`, får `crew` den *ikke* automatisk – eksplisitt
konfigurasjon overstyrer standardverdiene, og da blir svaret 403 «Rollen din

View file

@ -298,13 +298,19 @@ def synthesize(text: str, voice_name: str, speed: float, chime: str) -> Path:
pcm += chunk.audio_int16_bytes
pcm += b"\x00\x00" * int(rate * 0.3)
tmp = path.with_suffix(".tmp")
# Unik tmp-fil per kall. Med et fast navn ville to som forhåndslytter
# samme tekst samtidig skrevet til samme fil, og den ene rename feilet.
# Taper den ene kappløpet om selve path, er innholdet uansett likt.
import tempfile
with tempfile.NamedTemporaryFile(suffix=".tmp", dir=CACHE_DIR, delete=False) as fh:
tmp = Path(fh.name)
with wave.open(str(tmp), "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(rate)
w.writeframes(bytes(pcm))
tmp.rename(path)
os.replace(tmp, path)
return path
@ -325,8 +331,14 @@ class Item:
subject: str = "" # stabil id, f.eks. "jwt:kent"
method: str = "none"
created: float = field(default_factory=time.time)
status: str = "queued" # queued | playing | done | stopped | failed
# queued -> preparing (syntese) -> playing -> done | stopped | failed
status: str = "queued"
error: str = ""
schedule_id: str = "" # satt når meldingen kom fra planleggeren
@property
def active(self) -> bool:
return self.status in ("preparing", "playing")
def public(self, *, show_ip: bool = False) -> dict:
return {
@ -360,17 +372,32 @@ def player_cmd(path: Path) -> list[str]:
class Player:
def __init__(self) -> None:
"""Én FIFO-kø, én avspilling om gangen.
Tråden startes ikke her, men i start(), som main() kaller. Da kan rutene
testes med en test_client uten at noe begynner å spille, og run_once()
lar en test kjøre køen ett steg om gangen.
"""
def __init__(self, on_finished=None) -> None: # noqa: ANN001
self.q: queue.Queue[Item] = queue.Queue()
self.history: list[Item] = []
self.current: Item | None = None
self.proc: subprocess.Popen | None = None
self.lock = threading.Lock()
self.seq = 0
threading.Thread(target=self._loop, daemon=True, name="player").start()
# Kalles med hvert ferdige Item (done/stopped/failed). Det er slik
# planleggeren får vite hvordan det gikk, uten at Player vet om den.
self.on_finished = on_finished
self._thread: threading.Thread | None = None
def start(self) -> None:
if self._thread is None:
self._thread = threading.Thread(target=self._loop, daemon=True, name="player")
self._thread.start()
def enqueue(self, text: str, voice: str, speed: float, chime: str, sender: str,
who: auth.Principal | None = None) -> Item:
who: auth.Principal | None = None, schedule_id: str = "") -> Item:
with self.lock:
self.seq += 1
item = Item(
@ -378,6 +405,7 @@ class Player:
user=who.name if who else "",
subject=who.subject if who else "",
method=who.method if who else "none",
schedule_id=schedule_id,
)
self.history.append(item)
del self.history[:-50]
@ -385,11 +413,18 @@ class Player:
return item
def stop(self) -> None:
"""Stopper det som spilles – eller det som fortsatt lages.
Under syntesen finnes det ingen prosess å drepe ennå; det er de første
sekundene av hver edge-tts-melding. Før var Stopp uten virkning da,
og meldingen gikk ut etterpå. Nå settes statusen, og _loop sjekker
den før den starter avspilling.
"""
with self.lock:
if self.current and self.current.active:
self.current.status = "stopped"
if self.proc and self.proc.poll() is None:
self.proc.terminate()
if self.current:
self.current.status = "stopped"
def clear(self) -> None:
while True:
@ -406,22 +441,32 @@ class Player:
"current": self.current.public(show_ip=show_ip) if self.current else None,
"queued": [i.public(show_ip=show_ip) for i in self.history if i.status == "queued"],
"history": [i.public(show_ip=show_ip) for i in reversed(self.history)
if i.status not in ("queued", "playing")][:15],
if not i.active and i.status != "queued"][:15],
}
def _loop(self) -> None:
while True:
item = self.q.get()
self.run_once()
def run_once(self, timeout: float | None = None) -> Item | None:
"""Tar neste melding fra køen og spiller den ferdig. Returnerer den."""
try:
item = self.q.get(timeout=timeout)
except queue.Empty:
return None
if item.status != "queued":
continue
item.status = "playing"
return item
item.status = "preparing"
with self.lock:
self.current = item
try:
path = synthesize(item.text, item.voice, item.speed, item.chime)
cmd = player_cmd(path)
with self.lock:
self.proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
if item.status != "preparing":
return item # stoppet mens lyden ble laget
item.status = "playing"
self.proc = subprocess.Popen(player_cmd(path), stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE)
_, err = self.proc.communicate()
if item.status == "playing":
if self.proc.returncode == 0:
@ -440,9 +485,12 @@ class Player:
audit("played", id=item.id, status=item.status, error=item.error,
subject=item.subject, name=item.user, method=item.method,
ip=item.sender, text=item.text, voice=item.voice)
player = Player()
if self.on_finished:
try:
self.on_finished(item)
except Exception: # noqa: BLE001
log.exception("on_finished feilet")
return item
# --------------------------------------------------------------------------
@ -458,15 +506,31 @@ schedule = sched.Schedule(
TICK = max(5.0, float(CFG.get("schedule", "tick_seconds", 20)))
def _scheduler() -> None:
"""Flytter forfalte meldinger inn i den vanlige køen.
def _scheduled_finished(item: Item) -> None:
"""Players on_finished: lar planen følge meldingen helt i mål.
Egen tråd, og den gjør med vilje ingenting annet: avspilling, syntese og
feilhåndtering er Players jobb, slik at en planlagt melding oppfører seg
nøyaktig som en sendt for hånd når den først er i kø.
Før sto en planlagt sending som «sendt» i det den ble lagt i kø, og ble
stående slik selv om avspillingen feilet. Nå ender den som sent, failed
eller stopped – det som faktisk skjedde.
"""
if not item.schedule_id:
return
status = {"done": "sent", "failed": "failed", "stopped": "stopped"}.get(item.status)
if status:
schedule.mark(item.schedule_id, status, item.error or f"kø #{item.id}")
player = Player(on_finished=_scheduled_finished)
def scheduler_tick() -> None:
"""Ett steg: flytter forfalte meldinger inn i den vanlige køen.
Gjør med vilje ingenting annet. Avspilling, syntese og feilhåndtering er
Players jobb, slik at en planlagt melding oppfører seg nøyaktig som en
sendt for hånd når den først er i kø. Egen funksjon, så tester kan
kalle den uten tråd.
"""
while True:
try:
send, missed = schedule.due()
for item in missed:
schedule.mark(item.id, "missed", "maskina var ikke i gang i tide")
@ -478,12 +542,19 @@ def _scheduler() -> None:
item.text, item.voice, item.speed, item.chime,
sender="planlagt",
who=auth.Principal(item.subject, item.user, (), item.method, frozenset()),
schedule_id=item.id,
)
schedule.mark(item.id, "sent", f"kø #{queued.id}")
schedule.mark(item.id, "queued", f"kø #{queued.id}")
log.info("planlagt #%s lagt i kø som #%d", item.id, queued.id)
audit("scheduled_fire", id=item.id, queue_id=queued.id,
subject=item.subject, name=item.user, method=item.method,
text=item.text, voice=item.voice, at=item.at)
def _scheduler_loop() -> None:
while True:
try:
scheduler_tick()
except Exception: # noqa: BLE001
# En feil her skal ikke drepe tråden; da ville alle senere
# planlagte sendinger stilltiende blitt borte.
@ -491,7 +562,8 @@ def _scheduler() -> None:
time.sleep(TICK)
threading.Thread(target=_scheduler, daemon=True, name="scheduler").start()
def start_scheduler() -> None:
threading.Thread(target=_scheduler_loop, daemon=True, name="scheduler").start()
# --------------------------------------------------------------------------
@ -721,6 +793,29 @@ def not_found(e): # noqa: ANN001
return e
def check_writable() -> None:
"""Sier klart fra hvis datamappene ikke kan skrives, før noe annet.
Det vanlige tilfellet: `docker compose up` uten install.sh, så Docker
laget data/ som root mens containeren kjører som vanlig bruker. Uten
denne sjekken feiler det først ved første utrop, med en traceback.
"""
for name, path in (("cache", CACHE_DIR), ("logs", LOG_PATH.parent),
("instance", CFG.path("state")), ("schedule", SCHEDULE_PATH.parent)):
try:
path.mkdir(parents=True, exist_ok=True)
probe = path / ".skrivetest"
probe.touch()
probe.unlink()
except OSError as e:
raise SystemExit(
f"Får ikke skrevet til {path} ({e}).\n"
f"Kjører du i Docker: data/ må eies av brukeren i PUID/PGID i .env, "
f"ikke root. Fiks med sudo chown -R $(id -u):$(id -g) data/ "
f"eller kjør ./install.sh, som lager mappene riktig."
) from e
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--host", default=CFG.get("server", "host", "0.0.0.0"))
@ -728,15 +823,24 @@ def main() -> None:
args = ap.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logging.getLogger("werkzeug").setLevel(logging.WARNING)
check_writable()
ensure_voices()
load_voice("no_NO-talesyntese-medium") # varm opp reserven
if CFG.source:
log.info("konfigurasjon fra %s", CFG.source)
methods = ", ".join(m["method"] for m in AUTH.describe()["methods"]) or "åpen (ingen innlogging)"
venter = len(schedule.snapshot()["pending"])
player.start()
start_scheduler()
log.info("klar på http://%s:%d (auth: %s, sink: %s, planlagt: %d)",
args.host, args.port, methods, SINK or "standard", venter)
app.run(host=args.host, port=args.port, threaded=True)
# waitress, ikke Flask sin utviklingsserver – og ikke gunicorn med flere
# workere: Player og planleggeren er singletons i denne prosessen, så
# flere workere ville gitt flere køer som snakker i munnen på hverandre.
# Én prosess, mange tråder.
from waitress import serve
serve(app, host=args.host, port=args.port, threads=8, ident="announcer")
if __name__ == "__main__":

15
auth.py
View file

@ -124,7 +124,6 @@ class TokenVerifier:
self.secret = str(section.get("secret", "")).strip()
self.jwks_url = str(section.get("jwks_url", "")).strip()
self.leeway = int(section.get("leeway", 30))
self.verify_signature = bool(section.get("verify_signature", True))
# Tom liste: utled av hvordan nøkkelen kommer. Delt hemmelighet er
# symmetrisk (HS256), JWKS er asymmetrisk (RS256/ES256).
algs = [str(a) for a in section.get("algorithms") or []]
@ -143,9 +142,6 @@ class TokenVerifier:
def verify(self, token: str) -> dict:
import jwt
if not self.verify_signature:
# Bare for et crew-panel bak en proxy som alt har validert tokenet.
return jwt.decode(token, options={"verify_signature": False})
if not (self.secret or self.jwks_url):
raise AuthError("JWT er ikke konfigurert")
try:
@ -306,9 +302,6 @@ class OidcProvider(Provider):
"secret": self.client_secret,
"jwks_url": meta.get("jwks_uri", ""),
"algorithms": [],
"verify_signature": bool(
self.cfg.get("auth.oidc", "verify_signature", True)
),
}
)
return self._verifier
@ -626,6 +619,14 @@ def logout():
return redirect("/")
def reconfigure(cfg: Config) -> Authenticator:
"""Bytter ut leverandørene uten å røre Flask-appen. For tester som vil
prøve samme app med og uten innlogging."""
global _auth
_auth = Authenticator(cfg)
return _auth
def install(app, cfg: Config) -> Authenticator: # noqa: ANN001
"""Kobler autentisering på Flask-appen. Returnerer Authenticatoren."""
global _auth

View file

@ -112,7 +112,6 @@ DEFAULTS: dict[str, Any] = {
"jwks_url": "",
"end_session_endpoint": "",
"label": "Logg inn",
"verify_signature": True,
},
"roles": {
"anonymous": [],

View file

@ -76,6 +76,7 @@
font: inherit; font-size: 13px; padding: 0 4px; }
ul.list li button.x:hover { color: var(--danger); }
ul.list li.cancelled .s, ul.list li.missed .s { color: var(--warn); }
.now .t { color: var(--muted); font-size: 13px; }
</style>
</head>
<body>
@ -422,7 +423,8 @@ btnAnnounce.addEventListener('click', async () => {
$('#btnStop').addEventListener('click', () => api('/api/stop').then(poll).catch(onError));
$('#btnClear').addEventListener('click', () => api('/api/clear').then(poll).catch(onError));
const STATUS = { done: 'sendt', failed: 'feilet', stopped: 'stoppet', queued: 'i kø' };
const STATUS = { done: 'sendt', failed: 'feilet', stopped: 'stoppet', queued: 'i kø',
preparing: 'lager lyd', playing: 'spiller' };
function hhmm(ts) { return new Date(ts * 1000).toLocaleTimeString('no', { hour: '2-digit', minute: '2-digit' }); }
function li(it) {
const l = document.createElement('li'); l.className = it.status;
@ -434,7 +436,8 @@ function li(it) {
l.querySelector('.s').textContent = (STATUS[it.status] || it.status) + (it.error ? ' · ' + it.error : '');
return l;
}
const SCHED_STATUS = { sent: 'sendt', missed: 'gikk glipp av', cancelled: 'avbrutt' };
const SCHED_STATUS = { queued: 'i kø', sent: 'sendt', failed: 'feilet', stopped: 'stoppet',
missed: 'gikk glipp av', cancelled: 'avbrutt' };
function schedLi(it) {
const l = document.createElement('li');
l.className = it.status;
@ -468,7 +471,14 @@ async function poll() {
const rows = [...sc.pending, ...sc.done];
$('#schedcard').classList.toggle('hidden', rows.length === 0);
$('#sched').replaceChildren(...rows.map(schedLi));
if (s.current) { now.className = 'now'; now.innerHTML = '<span class="pulse"></span>'; now.append(document.createTextNode(s.current.text)); }
if (s.current) {
// «Lager lyd» er de første sekundene av hver edge-tts-melding: det
// er stille, og uten dette så det ut som avspillingen hang.
const preparing = s.current.status === 'preparing';
now.className = 'now' + (preparing ? ' idle' : '');
now.innerHTML = preparing ? '<span class="t">Lager lyd … </span>' : '<span class="pulse"></span>';
now.append(document.createTextNode(s.current.text));
}
else { now.className = 'now idle'; now.textContent = (cfg.app && cfg.app.idle_text) || ''; }
$('#queue').replaceChildren(...s.queued.map(li));
$('#history').replaceChildren(...s.history.map(li));

View file

@ -8,6 +8,7 @@ dependencies = [
"flask>=3.1.3",
"piper-tts>=1.8.0",
"pyjwt[crypto]>=2.10",
"waitress>=3.0",
]
[dependency-groups]

View file

@ -37,7 +37,9 @@ DEFAULT_MAX_AHEAD = 30 * 86400.0
# Hvor mange ferdige (sendt/hoppet over/avbrutt) vi beholder for visning.
KEEP_DONE = 50
STATUSES = ("pending", "sent", "missed", "cancelled")
# pending -> queued (lagt i kø) -> sent | failed | stopped
# -> missed (forfalt mens maskina var av) -> cancelled (avbrutt)
STATUSES = ("pending", "queued", "sent", "failed", "stopped", "missed", "cancelled")
@dataclass
@ -52,7 +54,7 @@ class Scheduled:
subject: str = ""
method: str = "none"
created: float = field(default_factory=time.time)
status: str = "pending" # pending | sent | missed | cancelled
status: str = "pending" # se STATUSES
note: str = ""
def public(self) -> dict:

345
tests/test_routes.py Normal file
View file

@ -0,0 +1,345 @@
"""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)

11
uv.lock generated
View file

@ -155,6 +155,7 @@ dependencies = [
{ name = "flask" },
{ name = "piper-tts" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "waitress" },
]
[package.dev-dependencies]
@ -168,6 +169,7 @@ requires-dist = [
{ name = "flask", specifier = ">=3.1.3" },
{ name = "piper-tts", specifier = ">=1.8.0" },
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.10" },
{ name = "waitress", specifier = ">=3.0" },
]
[package.metadata.requires-dev]
@ -1030,6 +1032,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "waitress"
version = "3.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/cb/04ddb054f45faa306a230769e868c28b8065ea196891f09004ebace5b184/waitress-3.0.2.tar.gz", hash = "sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f", size = 179901, upload-time = "2024-11-16T20:02:35.195Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/57/a27182528c90ef38d82b636a11f606b0cbb0e17588ed205435f8affe3368/waitress-3.0.2-py3-none-any.whl", hash = "sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e", size = 56232, upload-time = "2024-11-16T20:02:33.858Z" },
]
[[package]]
name = "werkzeug"
version = "3.1.9"