This commit is contained in:
team3
2026-09-17 15:40:28 +02:00
parent a1684cf54f
commit ea54a86db4
30 changed files with 3680 additions and 989 deletions

View File

@@ -12,6 +12,8 @@ _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")
# Der Hostmodus hängt am Schlüssel — in den Tests ein fester
os.environ["HOST_SCHLUESSEL"] = "test-host-schluessel"
import pytest # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
@@ -85,6 +87,21 @@ def zuschauer():
return TestClient(main.app)
HOST_SCHLUESSEL = "test-host-schluessel"
@pytest.fixture
def hostmodus():
"""Schaltet einen Client per Hostschlüssel frei."""
def an(client):
return client.post(
"/api/host", json={"an": True, "schluessel": HOST_SCHLUESSEL}
)
return an
@pytest.fixture
def film():
"""Testfilm direkt in der Datenbank, ohne TMDB. Gibt die id zurück."""

View File

@@ -0,0 +1,156 @@
"""Das Buch der Abende: Wertungen als Momentaufnahme, Kommentare als
Diskussion — verschachtelt, so tief man will."""
def abend(client, film, movie_id=1):
"""Host trägt einen Film ein, gibt die watched_id zurück."""
return client.post("/api/watched", json={"movie_id": film(movie_id)}).json()["id"]
def wertung(client, watched_id, stars=4):
client.post(f"/api/watched/{watched_id}/rating", json={"stars": stars})
film = next(f for f in client.get("/api/watched").json() if f["id"] == watched_id)
return next(r for r in film["ratings"] if r["mine"])
def film_von(client, watched_id):
return next(f for f in client.get("/api/watched").json() if f["id"] == watched_id)
def kommentar(client, watched_id, text, parent_id=None):
antwort = client.post(
f"/api/watched/{watched_id}/notes", json={"text": text, "parent_id": parent_id}
)
assert antwort.status_code == 200, antwort.text
return antwort.json()["id"]
def test_laufende_nummer_zaehlt_vom_ersten_abend(client, film):
erster = abend(client, film, 1)
client.patch(f"/api/watched/{erster}", json={"watched_at": "2026-01-01"})
zweiter = abend(client, film, 2)
client.patch(f"/api/watched/{zweiter}", json={"watched_at": "2026-01-08"})
liste = client.get("/api/watched").json()
# Jüngster zuerst, trägt aber die höchste Nummer
assert [(f["id"], f["nummer"]) for f in liste] == [(zweiter, 2), (erster, 1)]
def test_katalogdaten_haengen_am_abend(client):
from app import db
with db.connect() as conn:
db.upsert(conn, {"id": 5, "title": "Alien", "backdrop_path": "/b.jpg",
"images": ["/s1.jpg"], "runtime": 117, "genres": ["Horror"]})
watched_id = client.post("/api/watched", json={"movie_id": 5}).json()["id"]
f = film_von(client, watched_id)
assert f["backdrop_path"] == "/b.jpg"
assert f["images"] == ["/s1.jpg"]
assert f["runtime"] == 117
assert f["genres"] == ["Horror"]
def test_kommentare_bilden_einen_baum(client, client2, film):
watched_id = abend(client, film)
oben = kommentar(client, watched_id, "War super.")
antwort = kommentar(client2, watched_id, "Fand ich nicht.", oben)
tiefer = kommentar(client, watched_id, "Doch!", antwort)
noch_einer = kommentar(client2, watched_id, "Anderes Thema.")
notes = film_von(client, watched_id)["notes"]
assert [n["text"] for n in notes] == ["War super.", "Anderes Thema."]
assert notes[0]["replies"][0]["text"] == "Fand ich nicht."
assert notes[0]["replies"][0]["name"] == "Ben"
assert notes[0]["replies"][0]["mine"] is False
assert notes[0]["replies"][0]["replies"][0]["text"] == "Doch!"
assert notes[0]["replies"][0]["replies"][0]["mine"] is True
assert notes[1]["replies"] == []
assert (oben, antwort, tiefer, noch_einer) == tuple(sorted((oben, antwort, tiefer, noch_einer)))
def test_zehn_ebenen_tief(client, film):
watched_id = abend(client, film)
vater = None
for stufe in range(10):
vater = kommentar(client, watched_id, f"Stufe {stufe}", vater)
knoten = film_von(client, watched_id)["notes"][0]
tiefe = 0
while knoten["replies"]:
knoten = knoten["replies"][0]
tiefe += 1
assert tiefe == 9
assert knoten["text"] == "Stufe 9"
def test_leerer_text_oder_fremder_vater_werden_abgelehnt(client, film):
watched_id = abend(client, film, 1)
anderer = abend(client, film, 2)
dort = kommentar(client, anderer, "woanders")
assert client.post(f"/api/watched/{watched_id}/notes", json={"text": " "}).status_code == 400
# Der Vater gehört zu einem anderen Abend
assert client.post(
f"/api/watched/{watched_id}/notes", json={"text": "x", "parent_id": dort}
).status_code == 400
assert client.post(
f"/api/watched/{watched_id}/notes", json={"text": "x", "parent_id": 9999}
).status_code == 400
def test_wertungen_und_kommentare_sind_unabhaengig(client, film):
watched_id = abend(client, film)
wertung(client, watched_id, 5)
kommentar(client, watched_id, "Diskussion.")
f = film_von(client, watched_id)
assert f["ratings"][0]["stars"] == 5
assert "text" not in f["ratings"][0]
assert [n["text"] for n in f["notes"]] == ["Diskussion."]
def test_eigenen_kommentar_loeschen_fremden_nicht(client, client2, film):
watched_id = abend(client, film)
meiner = kommentar(client, watched_id, "meiner")
assert client2.delete(f"/api/watched-notes/{meiner}").status_code == 404
assert client.delete(f"/api/watched-notes/{meiner}").json()["removed"] == 1
assert film_von(client, watched_id)["notes"] == []
def test_loeschen_nimmt_den_ganzen_ast_mit(client, client2, film):
"""Eine Antwort ohne ihren Anlass ergäbe keinen Sinn."""
watched_id = abend(client, film)
oben = kommentar(client, watched_id, "a")
mitte = kommentar(client2, watched_id, "b", oben)
unten = kommentar(client, watched_id, "c", mitte)
daneben = kommentar(client2, watched_id, "d")
assert client.delete(f"/api/watched-notes/{oben}").json()["removed"] == 3
notes = film_von(client, watched_id)["notes"]
assert [n["id"] for n in notes] == [daneben]
def test_host_loescht_jeden_kommentar(client, zuschauer, hostmodus, film):
watched_id = abend(client, film)
note_id = kommentar(client, watched_id, "x")
hostmodus(zuschauer)
assert zuschauer.delete(f"/api/watched-notes/{note_id}").json()["removed"] == 1
def test_film_loeschen_raeumt_alles(client, client2, film):
from app import db
watched_id = abend(client, film)
wertung(client, watched_id, 5)
oben = kommentar(client2, watched_id, "frei")
kommentar(client, watched_id, "antwort", oben)
client.delete(f"/api/watched/{watched_id}")
with db.connect() as conn:
for tabelle in ("ratings", "watched_notes"):
assert conn.execute(f"SELECT COUNT(*) FROM {tabelle}").fetchone()[0] == 0, tabelle
def test_zuschauer_darf_nicht_schreiben(zuschauer, client, film):
watched_id = abend(client, film)
assert zuschauer.post(f"/api/watched/{watched_id}/notes", json={"text": "x"}).status_code == 403

185
backend/tests/test_login.py Normal file
View File

@@ -0,0 +1,185 @@
"""Anmelden per Film.
Wer einen Anmeldefilm hinterlegt hat, muss ihn beim Namenswechsel in der
Suche finden. Ohne Film bleibt der Name offen für alle.
"""
def namen(client, name="Anna"):
"""Legt den Namen an und gibt seine Kennung zurück."""
antwort = client.post("/api/users", json={"name": name})
if antwort.status_code == 200:
return antwort.json()["id"]
runde = client.get("/api/users").json()["items"]
return next(u["id"] for u in runde if u["name"] == name)
def test_ohne_film_bleibt_der_name_offen(client2, film):
user_id = namen(client2, "Offen")
antwort = client2.post("/api/users/waehlen", json={"user_id": user_id})
assert antwort.status_code == 200
assert antwort.json()["mine"]["geschuetzt"] is False
def test_falscher_film_kommt_nicht_durch(client, client2, film):
user_id = namen(client, "Anna")
client.post(f"/api/users/{user_id}/schutz", json={"movie_id": film(42)})
# Ben kennt den Film nicht
assert (
client2.post(
"/api/users/waehlen", json={"user_id": user_id, "movie_id": film(7)}
).status_code
== 403
)
# Ganz ohne Film auch nicht
assert (
client2.post("/api/users/waehlen", json={"user_id": user_id}).status_code == 403
)
assert client2.get("/api/users").json()["mine"]["name"] == "Ben"
def test_richtiger_film_laesst_durch(client, client2, film):
user_id = namen(client, "Anna")
client.post(f"/api/users/{user_id}/schutz", json={"movie_id": film(42)})
antwort = client2.post(
"/api/users/waehlen", json={"user_id": user_id, "movie_id": 42}
)
assert antwort.status_code == 200
assert antwort.json()["mine"]["name"] == "Anna"
assert client2.get("/api/users").json()["mine"]["name"] == "Anna"
def test_der_film_verlaesst_den_server_nicht(client, film):
user_id = namen(client, "Anna")
client.post(f"/api/users/{user_id}/schutz", json={"movie_id": film(42)})
runde = client.get("/api/users").json()
anna = next(u for u in runde["items"] if u["id"] == user_id)
assert anna["geschuetzt"] is True
assert "auth_movie_id" not in anna
assert "auth_movie_id" not in runde["mine"]
def test_aufheben_macht_den_namen_wieder_frei(client, client2, film):
user_id = namen(client, "Anna")
client.post(f"/api/users/{user_id}/schutz", json={"movie_id": film(42)})
client.post(f"/api/users/{user_id}/schutz", json={"movie_id": None})
assert client.get("/api/users").json()["items"][0]["geschuetzt"] is False
assert (
client2.post("/api/users/waehlen", json={"user_id": user_id}).status_code == 200
)
def test_host_braucht_den_film_nicht(client, zuschauer, film, hostmodus):
user_id = namen(client, "Anna")
client.post(f"/api/users/{user_id}/schutz", json={"movie_id": film(42)})
hostmodus(zuschauer)
assert zuschauer.get("/api/users").json()["host"] is True
assert (
zuschauer.post("/api/users/waehlen", json={"user_id": user_id}).status_code
== 200
)
# Hostmodus aus: die Hürde steht wieder
zuschauer.post("/api/users/waehlen", json={"user_id": None})
zuschauer.post("/api/host", json={"an": False})
assert (
zuschauer.post("/api/users/waehlen", json={"user_id": user_id}).status_code
== 403
)
def test_wer_schon_angemeldet_ist_muss_nicht_nochmal(client, film):
user_id = namen(client, "Anna")
client.post("/api/users/waehlen", json={"user_id": user_id})
client.post(f"/api/users/{user_id}/schutz", json={"movie_id": film(42)})
# Derselbe Name in derselben Sitzung, etwa nach einem Neuladen
assert (
client.post("/api/users/waehlen", json={"user_id": user_id}).status_code == 200
)
def test_fremden_schutz_darf_man_nicht_setzen(client, client2, film):
anna = namen(client, "Anna")
assert (
client2.post(f"/api/users/{anna}/schutz", json={"movie_id": film(42)}).status_code
== 403
)
assert client2.get("/api/users").json()["items"][0]["geschuetzt"] is False
def test_eigenen_schutz_darf_man_setzen(client, film):
"""client ist als Anna angemeldet und ändert ihren eigenen Film."""
anna = client.get("/api/users").json()["mine"]["id"]
assert (
client.post(f"/api/users/{anna}/schutz", json={"movie_id": film(42)}).status_code
== 200
)
def test_hinterlegten_film_darf_man_nachschlagen(client, film):
"""Wer ihn ändern darf, darf ihn auch sehen — sonst rät man beim
Überschreiben."""
anna = client.get("/api/users").json()["mine"]["id"]
client.post(f"/api/users/{anna}/schutz", json={"movie_id": film(42, "Alien")})
antwort = client.get(f"/api/users/{anna}/schutz")
assert antwort.status_code == 200
assert antwort.json()["film"]["title"] == "Alien"
def test_ohne_film_kommt_nichts_zurueck(client):
anna = client.get("/api/users").json()["mine"]["id"]
assert client.get(f"/api/users/{anna}/schutz").json()["film"] is None
def test_fremden_film_darf_man_nicht_nachschlagen(client, client2, film):
anna = client.get("/api/users").json()["mine"]["id"]
client.post(f"/api/users/{anna}/schutz", json={"movie_id": film(42)})
assert client2.get(f"/api/users/{anna}/schutz").status_code == 403
def test_host_darf_jeden_film_nachschlagen(client, zuschauer, film, hostmodus):
anna = client.get("/api/users").json()["mine"]["id"]
client.post(f"/api/users/{anna}/schutz", json={"movie_id": film(42, "Alien")})
hostmodus(zuschauer)
assert zuschauer.get(f"/api/users/{anna}/schutz").json()["film"]["title"] == "Alien"
def test_ohne_schluessel_kein_hostmodus(zuschauer):
"""Die Tastenkombination allein reicht nicht mehr."""
assert zuschauer.post("/api/host", json={"an": True}).status_code == 403
assert zuschauer.get("/api/host").json()["host"] is False
def test_falscher_schluessel_kein_hostmodus(zuschauer):
antwort = zuschauer.post(
"/api/host", json={"an": True, "schluessel": "geraten"}
)
assert antwort.status_code == 403
assert zuschauer.get("/api/host").json()["host"] is False
def test_schluessel_schaltet_frei_und_bleibt(zuschauer, hostmodus):
assert hostmodus(zuschauer).json()["host"] is True
assert zuschauer.get("/api/host").json()["host"] is True
# Die Freischaltung hält, ein zweiter Aufruf braucht den Schlüssel nicht
assert zuschauer.post("/api/host", json={"an": True}).status_code == 200
assert zuschauer.get("/api/host").json()["host"] is True
def test_ausschalten_geht_ohne_schluessel(zuschauer, hostmodus):
hostmodus(zuschauer)
assert zuschauer.post("/api/host", json={"an": False}).json()["host"] is False
# Jetzt ist die Sitzung wieder gewöhnlich
assert zuschauer.post("/api/host", json={"an": True}).status_code == 403
def test_der_schluessel_gilt_nur_fuer_die_eigene_sitzung(zuschauer, client2, hostmodus):
hostmodus(zuschauer)
assert client2.get("/api/host").json()["host"] is False

View File

@@ -1,6 +1,5 @@
"""Wertungen: Sterne, Text, Schnitt."""
from app.watched import MAX_TEXT
def gesehen(client, film, movie_id=1):
@@ -9,42 +8,47 @@ def gesehen(client, film, movie_id=1):
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()
def test_wertung_ist_nur_sterne(client, film):
wid = client.post("/api/watched", json={"movie_id": film(1)}).json()["id"]
antwort = client.post(f"/api/watched/{wid}/rating", json={"stars": 4})
assert antwort.json() == {"watched_id": wid, "stars": 4}
eintrag = client.get("/api/watched").json()[0]
assert eintrag["mine"] == 4
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
assert "text" not in eintrag["ratings"][0]
assert "mine_text" not in eintrag
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"
def test_neue_sterne_ersetzen_die_alten(client, film):
wid = client.post("/api/watched", json={"movie_id": film(1)}).json()["id"]
client.post(f"/api/watched/{wid}/rating", json={"stars": 4})
client.post(f"/api/watched/{wid}/rating", json={"stars": 2})
eintrag = client.get("/api/watched").json()[0]
assert eintrag["mine"] == 2
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_alter_wertungstext_zieht_in_die_diskussion(client, film):
"""Vor dem Umbau hing ein Text an den Sternen. Beim Start wandert er
als Kommentar in die Diskussion, mit Verfasser und Datum."""
from app import db, watched
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)})
wid = client.post("/api/watched", json={"movie_id": film(1)}).json()["id"]
client.post(f"/api/watched/{wid}/rating", json={"stars": 5})
with db.connect() as conn:
conn.execute("UPDATE ratings SET text = 'Alte Rezension'")
watched.init()
[eintrag] = client.get("/api/watched").json()
assert len(eintrag["mine_text"]) == MAX_TEXT
f = client.get("/api/watched").json()[0]
assert [n["text"] for n in f["notes"]] == ["Alte Rezension"]
assert f["notes"][0]["name"] == "Anna"
assert f["notes"][0]["mine"] is True
with db.connect() as conn:
assert conn.execute("SELECT COUNT(*) FROM ratings WHERE text IS NOT NULL").fetchone()[0] == 0
# Ein zweiter Start zieht nichts doppelt um
watched.init()
assert len(client.get("/api/watched").json()[0]["notes"]) == 1
def test_fremde_wertung_ist_nicht_meine(client, client2, film):

View File

@@ -0,0 +1,110 @@
"""Die Sammlungen.
Alle sehen alle, ändern darf jeder nur die eigene. Die Karten im
Sammlungen-Reiter zeigen dieselben Daten wie im Katalog — die holt die
Liste per Join nach.
"""
from app import db
def katalogfilm(movie_id, **felder):
"""Film mit allem, was die Detailkarte zeigt."""
with db.connect() as conn:
db.upsert(
conn,
{
"id": movie_id,
"title": "Alien",
"year": 1979,
"teaser": "Im Weltall hört dich niemand schreien.",
"overview": "Die Nostromo fängt ein Signal auf.",
"runtime": 117,
"vote_average": 8.2,
"vote_count": 14000,
"genres": ["Horror", "Science Fiction"],
"images": ["/a.jpg", "/b.jpg"],
**felder,
},
)
return movie_id
def test_karte_bekommt_die_katalogdaten(client):
client.post("/api/wishlist", json={"movie_id": katalogfilm(42)})
eintrag = client.get("/api/wishlist").json()[0]
assert eintrag["teaser"].startswith("Im Weltall")
assert eintrag["runtime"] == 117
assert eintrag["vote_average"] == 8.2
assert eintrag["vote_count"] == 14000
assert eintrag["genres"] == ["Horror", "Science Fiction"]
assert eintrag["images"] == ["/a.jpg", "/b.jpg"]
def test_film_ausserhalb_des_katalogs_bleibt_spaerlich(client, film):
"""Titel und Poster stehen in der Sammlung selbst, der Rest fehlt."""
client.post("/api/wishlist", json={"movie_id": film(7, "Irgendwas")})
eintrag = client.get("/api/wishlist").json()[0]
assert eintrag["title"] == "Irgendwas"
assert eintrag["teaser"] is None
assert eintrag["genres"] == []
assert eintrag["images"] == []
def test_merken_und_wieder_vergessen(client):
movie_id = katalogfilm(42)
client.post("/api/wishlist", json={"movie_id": movie_id})
assert len(client.get("/api/wishlist").json()) == 1
assert client.delete(f"/api/wishlist/{movie_id}").json()["removed"] == 1
assert client.get("/api/wishlist").json() == []
def test_zweimal_merken_legt_nur_einen_an(client):
movie_id = katalogfilm(42)
client.post("/api/wishlist", json={"movie_id": movie_id})
client.post("/api/wishlist", json={"movie_id": movie_id})
assert len(client.get("/api/wishlist").json()) == 1
def test_fremde_sammlung_sieht_man_aber_raeumt_sie_nicht(client, client2):
movie_id = katalogfilm(42)
client.post("/api/wishlist", json={"movie_id": movie_id})
# Ben sieht Annas Eintrag, aber er gehört ihm nicht
eintrag = client2.get("/api/wishlist").json()[0]
assert eintrag["name"] == "Anna"
assert eintrag["mine"] is False
# Sein Löschversuch trifft nur die eigene Sammlung, also nichts
assert client2.delete(f"/api/wishlist/{movie_id}").json()["removed"] == 0
assert len(client.get("/api/wishlist").json()) == 1
def test_jeder_hat_seine_eigene_sammlung(client, client2):
movie_id = katalogfilm(42)
client.post("/api/wishlist", json={"movie_id": movie_id})
client2.post("/api/wishlist", json={"movie_id": movie_id})
eintraege = client.get("/api/wishlist").json()
assert len(eintraege) == 2
assert sorted(e["name"] for e in eintraege) == ["Anna", "Ben"]
def test_sync_verschont_was_jemand_gemerkt_hat(client):
"""Der Katalog kennt nur Netflix und den Kanon. Gemerkte Filme müssen
trotzdem drinbleiben — sonst verlieren ihre Karten alle Daten."""
gemerkt = katalogfilm(42)
egal = katalogfilm(99, title="Niemandsfilm")
client.post("/api/wishlist", json={"movie_id": gemerkt})
with db.connect() as conn:
# Der Sync behält nur, was er gerade geholt hat — hier gar nichts
weg = db.drop_missing(conn, [])
uebrig = {r["id"] for r in conn.execute("SELECT id FROM movies")}
assert weg == 1
assert uebrig == {gemerkt}
assert egal not in uebrig