update
This commit is contained in:
39
backend/app/backup.py
Normal file
39
backend/app/backup.py
Normal 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
|
||||
@@ -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
3
backend/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
pythonpath = .
|
||||
1
backend/requirements-dev.txt
Normal file
1
backend/requirements-dev.txt
Normal file
@@ -0,0 +1 @@
|
||||
pytest==8.4.2
|
||||
91
backend/tests/conftest.py
Normal file
91
backend/tests/conftest.py
Normal 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
|
||||
74
backend/tests/test_features.py
Normal file
74
backend/tests/test_features.py
Normal 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
|
||||
6
backend/tests/test_start.py
Normal file
6
backend/tests/test_start.py
Normal 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 == []
|
||||
94
backend/tests/test_suggestions.py
Normal file
94
backend/tests/test_suggestions.py
Normal 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"] == []
|
||||
46
backend/tests/test_users.py
Normal file
46
backend/tests/test_users.py
Normal 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
|
||||
63
backend/tests/test_watched.py
Normal file
63
backend/tests/test_watched.py
Normal 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
|
||||
Reference in New Issue
Block a user