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

39
backend/app/backup.py Normal file
View File

@@ -0,0 +1,39 @@
"""Nächtliche Kopie der Datenbank.
Sammlungen, Wertungen und Wünsche liegen nur in dieser einen Datei. Die
Kopie entsteht über SQLites eigene Backup-Schnittstelle — die ist auch
dann konsistent, wenn gerade jemand schreibt. Es bleiben die letzten
vierzehn Stände, jeder Tag einer.
"""
import logging
import sqlite3
from datetime import date
from .config import DB_PATH
log = logging.getLogger("app.backup")
BEHALTEN = 14
def sichern():
ordner = DB_PATH.parent / "backups"
ordner.mkdir(parents=True, exist_ok=True)
ziel = ordner / f"{DB_PATH.stem}-{date.today():%Y-%m-%d}{DB_PATH.suffix}"
quelle = sqlite3.connect(DB_PATH)
kopie = sqlite3.connect(ziel)
try:
quelle.backup(kopie)
finally:
kopie.close()
quelle.close()
# Die Namen sortieren nach Datum, das Alte steht vorn
alte = sorted(ordner.glob(f"{DB_PATH.stem}-*{DB_PATH.suffix}"))[:-BEHALTEN]
for alt in alte:
alt.unlink()
log.info("Datenbank gesichert: %s", ziel.name)
return ziel

View File

@@ -10,6 +10,7 @@ from fastapi.middleware.gzip import GZipMiddleware
from fastapi.staticfiles import StaticFiles
from . import (
backup,
db,
events,
features,
@@ -69,6 +70,11 @@ app.add_middleware(
)
async def sicherung():
# Die Kopie blockiert kurz — nicht im Ereignisschleifen-Thread
await asyncio.to_thread(backup.sichern)
async def guarded_sync():
if sync_lock.locked():
log.info("Sync läuft bereits")
@@ -85,6 +91,9 @@ async def startup():
watched.init()
wishlist.init()
features.init()
# Erst die Sicherung, dann der Sync — so liegt vor jedem Katalog-Update
# ein sauberer Stand
scheduler.add_job(sicherung, "cron", hour=4, minute=0, id="daily-backup")
scheduler.add_job(guarded_sync, "cron", hour=4, minute=30, id="daily-sync")
scheduler.start()
if not db.get_meta("last_sync"):

3
backend/pytest.ini Normal file
View File

@@ -0,0 +1,3 @@
[pytest]
testpaths = tests
pythonpath = .

View File

@@ -0,0 +1 @@
pytest==8.4.2

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