From a1684cf54f87a97cfa72baa93e30e6e2fb69f60e Mon Sep 17 00:00:00 2001 From: team3 Date: Thu, 17 Sep 2026 12:14:40 +0200 Subject: [PATCH] update --- backend/app/main.py | 27 ++++++++ backend/tests/conftest.py | 38 +++++++---- backend/tests/test_users.py | 12 ++-- backend/tests/test_zuschauer.py | 74 +++++++++++++++++++++ frontend/src/App.vue | 13 +++- frontend/src/components/MerklisteTab.vue | 2 + frontend/src/components/MovieDetail.vue | 11 ++- frontend/src/components/VorschlagsListe.vue | 4 +- frontend/src/components/WatchedTab.vue | 14 +++- frontend/src/components/WheelTab.vue | 2 + frontend/src/components/WuenscheTab.vue | 13 +++- 11 files changed, 181 insertions(+), 29 deletions(-) create mode 100644 backend/tests/test_zuschauer.py diff --git a/backend/app/main.py b/backend/app/main.py index a725ab1..8c260cf 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -250,6 +250,25 @@ def sitzung(response: Response, sid): return neu +def mitglied(sid): + """Wer keinen Namen gewählt hat, darf zusehen, aber nichts eintragen. + + Vorschläge, Sammlungen, Wertungen und Wünsche gehören einer Person. + Ohne Namen hingen sie nur am Cookie: im geteilten Browser schriebe + jeder auf dasselbe Konto, und nach dem Löschen der Cookies wäre alles + weg. Die Knöpfe blendet die Oberfläche aus — das hier ist der Riegel. + + Der Host braucht dafür ebenfalls einen Namen. Seine eigenen Knöpfe + (drehen, eintragen, aufräumen) laufen über andere Routen. + """ + ich = users.besitzer(sid) + if ich is None: + raise HTTPException( + status_code=403, detail="Dafür musst du oben deinen Namen wählen." + ) + return ich + + # Der letzte Dreh, damit eine Seite mitten in der Animation nachziehen kann letzter_dreh = {"ergebnis": None, "start": 0.0} @@ -324,6 +343,7 @@ async def vorschlagen( filmabend_sid: str | None = Cookie(None), ): sid = sitzung(response, filmabend_sid) + mitglied(sid) movie = await film_holen(movie_id) # Ein zweiter Vorschlag ersetzt den ersten, statt abgelehnt zu werden eintrag = suggestions.ersetzen(sid, movie, users.name_von(sid)) @@ -336,6 +356,7 @@ async def vorschlagen( @app.delete("/api/suggestions") def vorschlag_loeschen(response: Response, filmabend_sid: str | None = Cookie(None)): sid = sitzung(response, filmabend_sid) + mitglied(sid) entfernt = suggestions.loeschen(sid) if entfernt: events.senden("suggestions") @@ -366,6 +387,7 @@ def wunsch_anlegen( filmabend_sid: str | None = Cookie(None), ): sid = sitzung(response, filmabend_sid) + mitglied(sid) liste = features.anlegen(sid, text) if liste is None: raise HTTPException(status_code=400, detail="Kein Text") @@ -397,6 +419,7 @@ def wunsch_abstimmen( ): """Ein Klick setzt die Stimme, der nächste nimmt sie zurück.""" sid = sitzung(response, filmabend_sid) + mitglied(sid) steht = features.abstimmen(feature_id, sid) if steht is None: raise HTTPException(status_code=404, detail="Wunsch nicht gefunden") @@ -421,6 +444,7 @@ def wunsch_anmerken( filmabend_sid: str | None = Cookie(None), ): sid = sitzung(response, filmabend_sid) + mitglied(sid) if not features.anmerken(feature_id, sid, text): raise HTTPException(status_code=400, detail="Kein Text oder Wunsch unbekannt") events.senden("features") @@ -451,6 +475,7 @@ async def merken( filmabend_sid: str | None = Cookie(None), ): sid = sitzung(response, filmabend_sid) + mitglied(sid) return wishlist.merken(sid, await film_holen(movie_id)) @@ -461,6 +486,7 @@ def vergessen( filmabend_sid: str | None = Cookie(None), ): sid = sitzung(response, filmabend_sid) + mitglied(sid) return {"removed": wishlist.vergessen(sid, movie_id)} @@ -554,6 +580,7 @@ def bewerten( filmabend_sid: str | None = Cookie(None), ): sid = sitzung(response, filmabend_sid) + mitglied(sid) stars = max(1, min(watched.MAX_STERNE, stars)) # Der Name kommt aus der Runde, nicht aus der Anfrage eintrag = watched.bewerten(watched_id, sid, stars, users.name_von(sid), text) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 88b105c..dc3844e 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -52,14 +52,36 @@ def frische_db(app_start): main.letzter_dreh.update(ergebnis=None, start=0.0) -# Ein Client je Person — jeder hält sein eigenes Cookie +def _anmelden(client, name): + """Namen anlegen, falls nötig, und für diesen Client wählen.""" + 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"] + + +# Ein Client je Person — jeder hält sein eigenes Cookie. Beide stehen in +# der Runde, denn ohne Namen darf niemand etwas eintragen. @pytest.fixture def client(): - return TestClient(main.app) + c = TestClient(main.app) + _anmelden(c, "Anna") + return c @pytest.fixture def client2(): + c = TestClient(main.app) + _anmelden(c, "Ben") + return c + + +@pytest.fixture +def zuschauer(): + """Ohne gewählten Namen: darf alles lesen, nichts schreiben.""" return TestClient(main.app) @@ -78,14 +100,4 @@ def film(): @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 + return _anmelden diff --git a/backend/tests/test_users.py b/backend/tests/test_users.py index ecb828d..40229e9 100644 --- a/backend/tests/test_users.py +++ b/backend/tests/test_users.py @@ -39,8 +39,10 @@ def test_namenswechsel_laesst_alte_eintraege_beim_alten_namen(client, anmelden, 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 +def test_abwaehlen_macht_wieder_zum_zuschauer(client, film): + client.post("/api/suggestions", json={"movie_id": film(1)}) + client.post("/api/users/waehlen", json={"user_id": None}) + assert client.get("/api/users").json()["mine"] is None + # Lesen geht weiter, Schreiben nicht mehr + assert client.get("/api/suggestions").json()["mine"] is None + assert client.post("/api/suggestions", json={"movie_id": film(2)}).status_code == 403 diff --git a/backend/tests/test_zuschauer.py b/backend/tests/test_zuschauer.py new file mode 100644 index 0000000..b0fafd4 --- /dev/null +++ b/backend/tests/test_zuschauer.py @@ -0,0 +1,74 @@ +"""Der Zuschauer: kein Name gewählt, also nur mitlesen. + +Gedacht für alle, die noch nicht in der Runde stehen. Sie sollen den +Katalog, das Rad und die Wertungen der anderen sehen, aber nichts +eintragen — sonst hinge ihr Kram allein am Cookie. +""" + +import pytest + +# Jede Zeile: wie die Anfrage aussieht, wenn ein Zuschauer sie stellt +SCHREIBEN = [ + ("post", "/api/suggestions", {"movie_id": 1}), + ("delete", "/api/suggestions", None), + ("post", "/api/wishlist", {"movie_id": 1}), + ("delete", "/api/wishlist/1", None), + ("post", "/api/features", {"text": "Dunkles Design"}), +] + + +@pytest.mark.parametrize("methode,pfad,daten", SCHREIBEN) +def test_zuschauer_darf_nicht_schreiben(zuschauer, film, methode, pfad, daten): + film(1) + # DELETE nimmt bei TestClient keinen json-Parameter + anfrage = getattr(zuschauer, methode) + antwort = anfrage(pfad, json=daten) if daten is not None else anfrage(pfad) + assert antwort.status_code == 403 + + +def test_zuschauer_darf_nicht_bewerten(zuschauer, client, film): + eintrag = client.post("/api/watched", json={"movie_id": film(1)}).json() + antwort = zuschauer.post( + f"/api/watched/{eintrag['id']}/rating", json={"stars": 5, "text": "stark"} + ) + assert antwort.status_code == 403 + assert zuschauer.get("/api/watched").json()[0]["count"] == 0 + + +def test_zuschauer_darf_nicht_abstimmen_oder_ergaenzen(zuschauer, client): + wunsch = client.post("/api/features", json={"text": "Dunkles Design"}).json()[0] + assert zuschauer.post(f"/api/features/{wunsch['id']}/vote").status_code == 403 + assert ( + zuschauer.post( + f"/api/features/{wunsch['id']}/notes", json={"text": "dazu"} + ).status_code + == 403 + ) + assert zuschauer.get("/api/features").json()[0]["votes"] == 0 + + +def test_zuschauer_darf_alles_lesen(zuschauer, client, film): + client.post("/api/suggestions", json={"movie_id": film(1)}) + client.post("/api/wishlist", json={"movie_id": film(2)}) + client.post("/api/features", json={"text": "Dunkles Design"}) + client.post("/api/watched", json={"movie_id": film(3)}) + + for pfad in ("/api/movies", "/api/suggestions", "/api/wishlist", + "/api/features", "/api/watched", "/api/users", "/api/spin"): + assert zuschauer.get(pfad).status_code == 200 + + # Fremdes gehört ihm nicht + assert zuschauer.get("/api/suggestions").json()["mine"] is None + assert zuschauer.get("/api/wishlist").json()[0]["mine"] is False + assert zuschauer.get("/api/features").json()[0]["mine"] is False + + +def test_host_kommt_ohne_namen_an_seine_knoepfe(zuschauer, client, film): + """Den Hostmodus kennt nur der Browser — seine Routen bleiben offen.""" + client.post("/api/suggestions", json={"movie_id": film(1)}) + eintrag = zuschauer.post("/api/watched", json={"movie_id": film(2)}).json() + assert zuschauer.patch( + f"/api/watched/{eintrag['id']}", json={"hidden": True} + ).status_code == 200 + assert zuschauer.post("/api/spin").json()["started"] is True + assert zuschauer.delete("/api/suggestions/alle").status_code == 200 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 1374b05..13f8cf4 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -105,6 +105,10 @@ watch(activeTab, async () => { leiste.querySelector('.active')?.scrollIntoView({ inline: 'center', block: 'nearest' }) }) +// Ohne gewählten Namen darf man alles lesen, aber nichts eintragen — +// für Zuschauer, die noch nicht in der Runde stehen +const angemeldet = computed(() => !!ich.value) + const selected = ref(null) // Der Katalog liefert nur sechs Bildpfade je Film. Beim Öffnen holen wir @@ -188,8 +192,8 @@ onMounted(load) > Filter - - Host + Host + @@ -236,6 +240,7 @@ onMounted(load) v-else-if="activeTab === 'wheel'" ref="rad" :host="host" + :angemeldet="angemeldet" :merkliste="merkliste" :mine="meinVorschlag?.id ?? null" :gesehen-ids="gesehenIds" @@ -246,12 +251,14 @@ onMounted(load) @@ -347,6 +355,7 @@ onMounted(load) :series="series" :mein-vorschlag="meinVorschlag" :host="host" + :angemeldet="angemeldet" :gesehen="gesehen.some((e) => e.movie_id === selected.id)" :gemerkt="merkliste.some((e) => e.mine && e.movie_id === selected.id)" @close="selected = null" diff --git a/frontend/src/components/MerklisteTab.vue b/frontend/src/components/MerklisteTab.vue index ea97512..29a19dd 100644 --- a/frontend/src/components/MerklisteTab.vue +++ b/frontend/src/components/MerklisteTab.vue @@ -4,6 +4,8 @@ import { posterUrl } from '../images' const props = defineProps({ items: { type: Array, default: () => [] }, + // Eine eigene Sammlung hat nur, wer in der Runde steht + angemeldet: Boolean, ich: { type: Object, default: null }, gesehenIds: { type: Set, default: () => new Set() }, }) diff --git a/frontend/src/components/MovieDetail.vue b/frontend/src/components/MovieDetail.vue index 2cc1f41..7f523e5 100644 --- a/frontend/src/components/MovieDetail.vue +++ b/frontend/src/components/MovieDetail.vue @@ -7,6 +7,8 @@ const props = defineProps({ series: { type: Array, default: () => [] }, meinVorschlag: { type: Object, default: null }, host: { type: Boolean, default: false }, + // Ohne gewählten Namen gibt es keine Sammlung und keinen Vorschlag + angemeldet: { type: Boolean, default: false }, gesehen: { type: Boolean, default: false }, gemerkt: { type: Boolean, default: false }, }) @@ -213,7 +215,7 @@ onUnmounted(() => { Ähnliche finden -