Forum: Anpinnen pro Thema/global + Admin-Berechtigung (v1303)

- Anpinnen-Scope: pin_scope ('global' | 'kategorie'). Global haelt oben in
  jeder Ansicht; Themen-Pin nur in der gefilterten Kategorie (nicht in 'Alle').
- Bugfix Berechtigung: Forum pruefte nur is_moderator -> Admins ohne das Flag
  wurden ausgesperrt. Neuer Helper _can_moderate() = rolle in (admin,moderator)
  ODER is_moderator, an allen 7 Forum-Checks + beiden Frontend-isMod-Gates.
- Thread-Detail-Toolbar (nur Admin/Mod): 'Global anpinnen' / 'Im Thema anpinnen'
  / 'Loesen' + Status- und Badge-Anzeige nach Scope.
- DB-Migration forum_threads.pin_scope (idempotent, Default 'global').
- Tests: tests/test_forum_pinning.py (Berechtigung + Scope-Sortierung).
This commit is contained in:
rene 2026-06-18 20:36:06 +02:00
parent 901df5468c
commit ac0814e687
9 changed files with 175 additions and 36 deletions

102
tests/test_forum_pinning.py Normal file
View file

@ -0,0 +1,102 @@
"""Forum-Anpinnen: Berechtigung (Admin/Moderator) + Scope-Sortierung.
Deckt zwei neue Verhalten ab:
1. Admins OHNE is_moderator-Flag dürfen anpinnen (Rolle zählt, nicht nur Flag).
2. pin_scope='global' hält oben in JEDER Ansicht, pin_scope='kategorie' nur in
der gefilterten Kategorie (nicht in der "Alle"-Liste).
Hinweise zu den Fixtures/Route-Constraints:
- `admin`-Fixture setzt rolle='admin' direkt in der DB, OHNE is_moderator.
- forum.create_thread hat 30s-Cooldown + 5-Threads/Stunde daher gestaffelte
client_time. Texte sind eindeutig (token_hex) gegen den Duplikat-Check.
- Kategorie 'tauschboerse' isoliert die Sortier-Tests von anderen Suiten;
Assertions vergleichen nur die RELATIVE Reihenfolge der eigenen Thread-IDs.
"""
from __future__ import annotations
import secrets
def _mk_thread(client, headers, kategorie, titel, client_time):
r = client.post(
"/api/forum/threads",
headers=headers,
json={
"kategorie": kategorie,
"titel": titel,
"text": f"{titel}: Hallo zusammen, ein Testbeitrag {secrets.token_hex(8)}.",
"client_time": client_time,
},
)
assert r.status_code == 201, f"create_thread failed: {r.status_code} {r.text}"
return r.json()
class TestForumPinPermission:
def test_admin_without_moderator_flag_can_pin(self, client, admin):
"""rolle='admin' ohne is_moderator darf anpinnen (Kernursache-Fix)."""
t = _mk_thread(client, admin["headers"], "tauschboerse", "Admin Pin", "2026-06-18T08:00:00")
r = client.patch(
f"/api/forum/threads/{t['id']}",
headers=admin["headers"],
json={"is_pinned": 1, "pin_scope": "global"},
)
assert r.status_code == 200, f"{r.status_code} {r.text}"
body = r.json()
assert body["is_pinned"] == 1
assert body["pin_scope"] == "global"
def test_normal_user_cannot_pin(self, client, user):
"""Normaler User → 403, Anpinnen bleibt Admin/Mod vorbehalten."""
t = _mk_thread(client, user["headers"], "tauschboerse", "User Pin", "2026-06-18T08:00:00")
r = client.patch(
f"/api/forum/threads/{t['id']}",
headers=user["headers"],
json={"is_pinned": 1, "pin_scope": "global"},
)
assert r.status_code == 403
def test_invalid_pin_scope_rejected(self, client, admin):
"""Ungültiger pin_scope → 400."""
t = _mk_thread(client, admin["headers"], "tauschboerse", "Bad Scope", "2026-06-18T08:00:00")
r = client.patch(
f"/api/forum/threads/{t['id']}",
headers=admin["headers"],
json={"is_pinned": 1, "pin_scope": "bogus"},
)
assert r.status_code == 400
class TestForumPinScopeSorting:
def test_scope_controls_where_thread_floats(self, client, admin):
h = admin["headers"]
t1 = _mk_thread(client, h, "tauschboerse", "Erster", "2026-06-18T09:00:00")
t2 = _mk_thread(client, h, "tauschboerse", "Zweiter", "2026-06-18T09:20:00")
t3 = _mk_thread(client, h, "tauschboerse", "Dritter", "2026-06-18T09:40:00")
ids = {t1["id"], t2["id"], t3["id"]}
def order(kategorie=None):
url = "/api/forum/threads?limit=200"
if kategorie:
url += f"&kategorie={kategorie}"
r = client.get(url, headers=h)
assert r.status_code == 200
return [x["id"] for x in r.json() if x["id"] in ids]
# Ohne Pin: neueste zuerst.
assert order() == [t3["id"], t2["id"], t1["id"]]
# Ältesten Thread GLOBAL anpinnen → oben in "Alle" UND in der Kategorie.
r = client.patch(f"/api/forum/threads/{t1['id']}", headers=h,
json={"is_pinned": 1, "pin_scope": "global"})
assert r.status_code == 200
assert order()[0] == t1["id"]
assert order("tauschboerse")[0] == t1["id"]
# Auf Themen-Pin umstellen → NUR in der Kategorie oben, in "Alle" wieder nach Datum.
r = client.patch(f"/api/forum/threads/{t1['id']}", headers=h,
json={"pin_scope": "kategorie"})
assert r.status_code == 200
assert order() == [t3["id"], t2["id"], t1["id"]] # "Alle": Themen-Pin zählt nicht
assert order("tauschboerse")[0] == t1["id"] # Kategorie: oben