This commit is contained in:
team3
2026-09-16 17:00:35 +02:00
parent bfaf0313c1
commit cb78225e86
29 changed files with 1992 additions and 1227 deletions

91
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,91 @@
"""Testumgebung: eigene Datenbank, kein Netz.
Die Umgebung steht, bevor app.* importiert wird — config.py liest DB_PATH
beim Import. Die Schlüssel prüft config.py nur auf Vorhandensein.
"""
import os
import tempfile
from pathlib import Path
_ORDNER = Path(tempfile.mkdtemp(prefix="filmabend-tests-"))
os.environ["DB_PATH"] = str(_ORDNER / "test.db")
os.environ.setdefault("TMDB_TOKEN", "test")
os.environ.setdefault("MINIMAX_KEY", "test")
import pytest # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from app import db, features, main, suggestions, users, watched, wishlist # noqa: E402
# Zählt, ob der Startup doch einen Sync anstoßen wollte
_SYNC_AUFRUFE = []
async def kein_sync():
_SYNC_AUFRUFE.append(True)
@pytest.fixture(scope="session", autouse=True)
def app_start():
"""Startup einmal je Lauf. last_sync steht vorher, damit main.startup
keinen TMDB-Sync startet; guarded_sync ist zur Sicherheit ersetzt."""
db.init()
with db.connect() as conn:
db.set_meta(conn, "last_sync", "test")
main.guarded_sync = kein_sync
with TestClient(main.app):
yield
@pytest.fixture
def sync_aufrufe():
return _SYNC_AUFRUFE
@pytest.fixture(autouse=True)
def frische_db(app_start):
"""Jeder Test beginnt mit leeren Tabellen und ohne laufende Ziehung."""
db.DB_PATH.unlink(missing_ok=True)
for modul in (db, users, suggestions, watched, wishlist, features):
modul.init()
main.letzter_dreh.update(ergebnis=None, start=0.0)
# Ein Client je Person — jeder hält sein eigenes Cookie
@pytest.fixture
def client():
return TestClient(main.app)
@pytest.fixture
def client2():
return TestClient(main.app)
@pytest.fixture
def film():
"""Testfilm direkt in der Datenbank, ohne TMDB. Gibt die id zurück."""
def anlegen(movie_id, title=None):
with db.connect() as conn:
db.upsert(conn, {"id": movie_id, "title": title or f"Film {movie_id}"})
return movie_id
return anlegen
@pytest.fixture
def anmelden():
"""Client wählt einen Namen; legt ihn an, falls es ihn noch nicht gibt."""
def waehlen(client, name):
antwort = client.post("/api/users", json={"name": name})
if antwort.status_code == 200:
user_id = antwort.json()["id"]
else:
runde = client.get("/api/users").json()["items"]
user_id = next(u["id"] for u in runde if u["name"] == name)
return client.post("/api/users/waehlen", json={"user_id": user_id}).json()["mine"]
return waehlen

View File

@@ -0,0 +1,74 @@
"""Wünsche: Stimmen, Abhaken, Anmerkungen."""
from app import db
def wunsch(client, text="Dunkles Design"):
liste = client.post("/api/features", json={"text": text}).json()
return next(w for w in liste if w["text"] == text)
def test_leerer_wunsch_wird_abgelehnt(client):
assert client.post("/api/features", json={"text": " "}).status_code == 400
def test_stimme_toggelt(client):
w = wunsch(client)
assert w["mine"] is True
assert w["votes"] == 0
assert w["voted"] is False
assert client.post(f"/api/features/{w['id']}/vote").json() == {"voted": True}
[w] = client.get("/api/features").json()
assert w["votes"] == 1
assert w["voted"] is True
assert client.post(f"/api/features/{w['id']}/vote").json() == {"voted": False}
[w] = client.get("/api/features").json()
assert w["votes"] == 0
assert w["voted"] is False
def test_erledigt_rutscht_ans_ende(client):
erster = wunsch(client, "A")
wunsch(client, "B")
# Mit Stimme steht A vorn
client.post(f"/api/features/{erster['id']}/vote")
assert [w["text"] for w in client.get("/api/features").json()] == ["A", "B"]
antwort = client.patch(f"/api/features/{erster['id']}/done", json={"done": True})
assert antwort.json() == {"ok": True}
liste = client.get("/api/features").json()
assert [w["text"] for w in liste] == ["B", "A"]
assert liste[1]["done_at"] is not None
client.patch(f"/api/features/{erster['id']}/done", json={"done": False})
assert [w["text"] for w in client.get("/api/features").json()] == ["A", "B"]
def test_anmerkungen_anlegen_und_loeschen(client, client2):
w = wunsch(client)
antwort = client.post(f"/api/features/{w['id']}/notes", json={"text": "Mit Kontrast"})
assert antwort.json() == {"ok": True}
[w] = client.get("/api/features").json()
[notiz] = w["notes"]
assert notiz["text"] == "Mit Kontrast"
assert notiz["mine"] is True
[fremd] = client2.get("/api/features").json()
assert fremd["notes"][0]["mine"] is False
assert client.delete(f"/api/feature-notes/{notiz['id']}").json() == {"removed": 1}
assert client.get("/api/features").json()[0]["notes"] == []
def test_loeschen_raeumt_stimmen_und_anmerkungen_mit(client):
w = wunsch(client)
client.post(f"/api/features/{w['id']}/vote")
client.post(f"/api/features/{w['id']}/notes", json={"text": "Anmerkung"})
assert client.delete(f"/api/features/{w['id']}").json() == {"removed": 1}
assert client.get("/api/features").json() == []
with db.connect() as conn:
for tabelle in ("feature_votes", "feature_notes"):
assert conn.execute(f"SELECT COUNT(*) FROM {tabelle}").fetchone()[0] == 0

View File

@@ -0,0 +1,6 @@
"""Der Startup läuft im Test ohne TMDB-Sync."""
def test_startup_startet_keinen_sync(client, sync_aufrufe):
assert client.get("/api/health").json() == {"ok": True}
assert sync_aufrufe == []

View File

@@ -0,0 +1,94 @@
"""Die Ziehung: Streifen, Stopps, Gewinner."""
from app import suggestions
from app.suggestions import BRUCH_DAUER, DREH_DAUER, STREIFEN_LAENGE, STREIFEN_ZIEL
def vorschlagen(client, movie_id):
antwort = client.post("/api/suggestions", json={"movie_id": movie_id})
assert antwort.status_code == 200
return antwort.json()
def test_zweiter_vorschlag_ersetzt_den_ersten(client, film):
vorschlagen(client, film(1))
neu = vorschlagen(client, film(2))
liste = client.get("/api/suggestions").json()
assert [e["movie_id"] for e in liste["items"]] == [2]
assert liste["mine"] == neu["id"]
def test_dreh_liefert_streifen_und_gewinner(client, client2, film):
vorschlagen(client, film(1))
vorschlagen(client2, film(2))
ergebnis = client.post("/api/spin").json()
assert ergebnis["started"] is True
assert [k["movie_id"] for k in ergebnis["karten"]] == [1, 2]
assert len(ergebnis["streifen"]) == STREIFEN_LAENGE
assert set(ergebnis["streifen"]) <= {0, 1}
stopps = ergebnis["stopps"]
assert len(stopps) in (1, 2)
assert all(set(s) == {"position", "versatz"} for s in stopps)
assert stopps[0]["position"] == STREIFEN_ZIEL
assert ergebnis["gewinner"] == ergebnis["karten"][ergebnis["index"]]
def test_ziehung_haelt_auf_dem_gewinner(client, client2, film):
vorschlagen(client, film(1))
vorschlagen(client2, film(2))
stopp_anzahlen = set()
for _ in range(60):
e = suggestions.ziehen()
stopps = e["stopps"]
stopp_anzahlen.add(len(stopps))
# Der letzte Halt zeigt den Gewinner
assert e["streifen"][stopps[-1]["position"]] == e["index"]
assert all(-0.5 < s["versatz"] < 0.5 for s in stopps)
if len(stopps) == 2:
# Bruch: ein paar Poster zurück, der erste Halt zeigt einen anderen
assert 1 <= stopps[0]["position"] - stopps[1]["position"] <= 4
assert e["streifen"][stopps[0]["position"]] != e["index"]
assert e["dauer"] == DREH_DAUER + BRUCH_DAUER
else:
assert e["dauer"] == DREH_DAUER
# Beide Fälle kamen vor
assert stopp_anzahlen == {1, 2}
def test_zweiter_dreh_waehrend_laufender_ziehung(client, film):
vorschlagen(client, film(1))
assert client.post("/api/spin").json()["started"] is True
assert client.post("/api/spin").json()["started"] is False
stand = client.get("/api/spin").json()
assert stand["running"] is True
assert stand["elapsed"] >= 0
assert stand["result"]["gewinner"]["movie_id"] == 1
def test_ohne_vorschlaege_kein_dreh(client):
assert client.post("/api/spin").status_code == 400
assert client.get("/api/spin").json() == {"running": False, "result": None}
def test_gesehene_filme_gewinnen_nicht(client, client2, film):
vorschlagen(client, film(1))
vorschlagen(client2, film(2))
client.post("/api/watched", json={"movie_id": 1})
for _ in range(50):
assert suggestions.ziehen()["gewinner"]["movie_id"] == 2
def test_runde_zuruecksetzen(client, film):
vorschlagen(client, film(1))
client.post("/api/spin")
assert client.delete("/api/suggestions/alle").json() == {"removed": 1}
assert client.get("/api/spin").json()["running"] is False
assert client.get("/api/suggestions").json()["items"] == []

View File

@@ -0,0 +1,46 @@
"""Besitz: Einträge hängen am Namen, ohne Namen an der Sitzung."""
def test_gleicher_name_in_anderem_browser_sieht_eigene_eintraege(
client, client2, anmelden, film
):
anmelden(client, "Anna")
vorschlag = client.post("/api/suggestions", json={"movie_id": film(1)}).json()
client.post("/api/wishlist", json={"movie_id": film(2)})
client.post("/api/features", json={"text": "Dunkles Design"})
# Anna am zweiten Gerät
anmelden(client2, "Anna")
assert client2.get("/api/suggestions").json()["mine"] == vorschlag["id"]
assert client2.get("/api/wishlist").json()[0]["mine"] is True
assert client2.get("/api/features").json()[0]["mine"] is True
# Ben nicht
anmelden(client2, "Ben")
assert client2.get("/api/suggestions").json()["mine"] is None
assert client2.get("/api/wishlist").json()[0]["mine"] is False
assert client2.get("/api/features").json()[0]["mine"] is False
def test_namenswechsel_laesst_alte_eintraege_beim_alten_namen(client, anmelden, film):
anmelden(client, "Anna")
als_anna = client.post("/api/suggestions", json={"movie_id": film(1)}).json()
assert als_anna["name"] == "Anna"
anmelden(client, "Ben")
assert client.get("/api/suggestions").json()["mine"] is None
# Bens Vorschlag ersetzt Annas nicht — einer je Name, nicht je Browser
als_ben = client.post("/api/suggestions", json={"movie_id": film(2)}).json()
liste = client.get("/api/suggestions").json()
assert [e["name"] for e in liste["items"]] == ["Anna", "Ben"]
assert liste["mine"] == als_ben["id"]
anmelden(client, "Anna")
assert client.get("/api/suggestions").json()["mine"] == als_anna["id"]
def test_ohne_namen_zaehlt_die_sitzung(client, client2, film):
vorschlag = client.post("/api/suggestions", json={"movie_id": film(1)}).json()
assert vorschlag["name"] is None
assert client.get("/api/suggestions").json()["mine"] == vorschlag["id"]
assert client2.get("/api/suggestions").json()["mine"] is None

View File

@@ -0,0 +1,63 @@
"""Wertungen: Sterne, Text, Schnitt."""
from app.watched import MAX_TEXT
def gesehen(client, film, movie_id=1):
"""Film anlegen und als gesehen eintragen. Gibt die watched-id zurück."""
film(movie_id)
return client.post("/api/watched", json={"movie_id": movie_id}).json()["id"]
def test_wertung_mit_text(client, film):
wid = gesehen(client, film)
antwort = client.post(f"/api/watched/{wid}/rating", json={"stars": 4, "text": "Gruselig"})
assert antwort.json() == {"watched_id": wid, "stars": 4, "text": "Gruselig"}
[eintrag] = client.get("/api/watched").json()
assert eintrag["average"] == 4.0
assert eintrag["count"] == 1
assert eintrag["mine"] == 4
assert eintrag["mine_text"] == "Gruselig"
assert eintrag["ratings"][0]["mine"] is True
def test_text_none_behaelt_leer_loescht(client, film):
wid = gesehen(client, film)
client.post(f"/api/watched/{wid}/rating", json={"stars": 4, "text": "Gruselig"})
# Nur die Sterne nachjustiert: der Satz bleibt, es bleibt eine Stimme
client.post(f"/api/watched/{wid}/rating", json={"stars": 3})
[eintrag] = client.get("/api/watched").json()
assert eintrag["mine"] == 3
assert eintrag["mine_text"] == "Gruselig"
assert eintrag["count"] == 1
client.post(f"/api/watched/{wid}/rating", json={"stars": 3, "text": ""})
[eintrag] = client.get("/api/watched").json()
assert eintrag["mine_text"] is None
def test_text_wird_gekuerzt(client, film):
wid = gesehen(client, film)
client.post(f"/api/watched/{wid}/rating", json={"stars": 5, "text": "x" * (MAX_TEXT + 100)})
[eintrag] = client.get("/api/watched").json()
assert len(eintrag["mine_text"]) == MAX_TEXT
def test_fremde_wertung_ist_nicht_meine(client, client2, film):
wid = gesehen(client, film)
client.post(f"/api/watched/{wid}/rating", json={"stars": 4})
[eintrag] = client2.get("/api/watched").json()
assert eintrag["mine"] is None
assert eintrag["count"] == 1
assert eintrag["ratings"][0]["mine"] is False
client2.post(f"/api/watched/{wid}/rating", json={"stars": 2})
[eintrag] = client2.get("/api/watched").json()
assert eintrag["average"] == 3.0
assert eintrag["count"] == 2
assert eintrag["mine"] == 2