"""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