diff --git a/src/apps/billboard/tests/integrated/test_views.py b/src/apps/billboard/tests/integrated/test_views.py index 0d2938d..f59ff06 100644 --- a/src/apps/billboard/tests/integrated/test_views.py +++ b/src/apps/billboard/tests/integrated/test_views.py @@ -481,3 +481,124 @@ class PostLineRelativeTimestampTest(TestCase): response.content.decode(), r'class="post-line-time"[^>]*>\s*\d{2}\s\w{3}\s*<', ) + + +class DeletePostViewTest(TestCase): + """billboard:delete_post — owner can hard-delete; non-owners are no-op; + note_unlock Posts are protected (defence-in-depth alongside the menu + branch that doesn't render DEL on admin-Posts).""" + + def setUp(self): + from apps.billboard.models import Line, Post + self.Post = Post + self.owner = User.objects.create(email="del-owner@test.io") + self.other = User.objects.create(email="del-other@test.io") + self.post = Post.objects.create( + owner=self.owner, kind=Post.KIND_USER_POST, title="X", + ) + Line.objects.create(post=self.post, text="x", author=self.owner) + + def test_owner_post_redirects_to_my_posts(self): + self.client.force_login(self.owner) + response = self.client.post( + reverse("billboard:delete_post", args=[self.post.id]) + ) + self.assertRedirects( + response, + reverse("billboard:my_posts", args=[self.owner.id]), + fetch_redirect_response=False, + ) + + def test_owner_post_deletes_post(self): + self.client.force_login(self.owner) + self.client.post(reverse("billboard:delete_post", args=[self.post.id])) + self.assertFalse(self.Post.objects.filter(id=self.post.id).exists()) + + def test_non_owner_cannot_delete(self): + self.client.force_login(self.other) + self.client.post(reverse("billboard:delete_post", args=[self.post.id])) + self.assertTrue(self.Post.objects.filter(id=self.post.id).exists()) + + def test_get_does_not_delete(self): + self.client.force_login(self.owner) + self.client.get(reverse("billboard:delete_post", args=[self.post.id])) + self.assertTrue(self.Post.objects.filter(id=self.post.id).exists()) + + def test_note_unlock_post_is_protected(self): + # Even the owner can't DEL a system thread — the gear menu doesn't + # render DEL for note_unlock, but the view is hardened in case the + # POST is forged. + admin_post = self.Post.objects.create( + owner=self.owner, kind=self.Post.KIND_NOTE_UNLOCK, title="Notes", + ) + self.client.force_login(self.owner) + self.client.post(reverse("billboard:delete_post", args=[admin_post.id])) + self.assertTrue(self.Post.objects.filter(id=admin_post.id).exists()) + + +class AbandonPostViewTest(TestCase): + """billboard:abandon_post — invitee removes themselves from + post.shared_with; owner unaffected; other invitees unaffected; admin + Posts protected from BYE.""" + + def setUp(self): + from apps.billboard.models import Line, Post + self.Post = Post + self.owner = User.objects.create(email="abandon-owner@test.io") + self.invitee = User.objects.create(email="abandon-invitee@test.io") + self.other = User.objects.create(email="abandon-other@test.io") + self.post = Post.objects.create( + owner=self.owner, kind=Post.KIND_USER_POST, title="Shared", + ) + Line.objects.create(post=self.post, text="x", author=self.owner) + self.post.shared_with.add(self.invitee, self.other) + + def test_invitee_redirects_to_my_posts(self): + self.client.force_login(self.invitee) + response = self.client.post( + reverse("billboard:abandon_post", args=[self.post.id]) + ) + self.assertRedirects( + response, + reverse("billboard:my_posts", args=[self.invitee.id]), + fetch_redirect_response=False, + ) + + def test_invitee_is_removed_from_shared_with(self): + self.client.force_login(self.invitee) + self.client.post(reverse("billboard:abandon_post", args=[self.post.id])) + self.post.refresh_from_db() + self.assertNotIn(self.invitee, self.post.shared_with.all()) + + def test_post_survives_invitee_abandonment(self): + self.client.force_login(self.invitee) + self.client.post(reverse("billboard:abandon_post", args=[self.post.id])) + self.post.refresh_from_db() + self.assertEqual(self.post.owner, self.owner) + self.assertIn(self.other, self.post.shared_with.all()) + + def test_get_does_not_remove(self): + self.client.force_login(self.invitee) + self.client.get(reverse("billboard:abandon_post", args=[self.post.id])) + self.post.refresh_from_db() + self.assertIn(self.invitee, self.post.shared_with.all()) + + def test_non_invitee_post_is_no_op(self): + random_user = User.objects.create(email="random@test.io") + self.client.force_login(random_user) + self.client.post(reverse("billboard:abandon_post", args=[self.post.id])) + self.post.refresh_from_db() + self.assertIn(self.invitee, self.post.shared_with.all()) + self.assertIn(self.other, self.post.shared_with.all()) + + def test_note_unlock_post_is_protected(self): + # Admin Posts have no recipients to begin with, but harden the view + # so a forged BYE can't strip shared_with anyway. + admin_post = self.Post.objects.create( + owner=self.owner, kind=self.Post.KIND_NOTE_UNLOCK, title="Notes", + ) + admin_post.shared_with.add(self.invitee) + self.client.force_login(self.invitee) + self.client.post(reverse("billboard:abandon_post", args=[admin_post.id])) + admin_post.refresh_from_db() + self.assertIn(self.invitee, admin_post.shared_with.all()) diff --git a/src/apps/billboard/urls.py b/src/apps/billboard/urls.py index 5035368..460edfe 100644 --- a/src/apps/billboard/urls.py +++ b/src/apps/billboard/urls.py @@ -17,6 +17,8 @@ urlpatterns = [ path("new-post", views.new_post, name="new_post"), path("post//", views.view_post, name="view_post"), path("post//share-post", views.share_post, name="share_post"), + path("post//delete", views.delete_post, name="delete_post"), + path("post//abandon", views.abandon_post, name="abandon_post"), path("users//", views.my_posts, name="my_posts"), path("my-buds/", views.my_buds, name="my_buds"), path("buds/add", views.add_bud, name="add_bud"), diff --git a/src/apps/billboard/views.py b/src/apps/billboard/views.py index b9e389b..b0ee6d5 100644 --- a/src/apps/billboard/views.py +++ b/src/apps/billboard/views.py @@ -328,6 +328,24 @@ def my_posts(request, user_id): }) +@login_required +def delete_post(request, post_id): + if request.method == "POST": + post = Post.objects.get(id=post_id) + if request.user == post.owner and post.kind != Post.KIND_NOTE_UNLOCK: + post.delete() + return redirect("billboard:my_posts", user_id=request.user.id) + + +@login_required +def abandon_post(request, post_id): + if request.method == "POST": + post = Post.objects.get(id=post_id) + if post.kind != Post.KIND_NOTE_UNLOCK: + post.shared_with.remove(request.user) + return redirect("billboard:my_posts", user_id=request.user.id) + + def share_post(request, post_id): our_post = Post.objects.get(id=post_id) is_ajax = "application/json" in request.headers.get("Accept", "") diff --git a/src/functional_tests/test_bill_post_gear.py b/src/functional_tests/test_bill_post_gear.py new file mode 100644 index 0000000..5a53b84 --- /dev/null +++ b/src/functional_tests/test_bill_post_gear.py @@ -0,0 +1,150 @@ +"""FT — post.html gear menu (NVM / DEL / BYE). + +User-authored Posts grow a #id_gear_btn that opens #id_post_menu with: + - NVM (everyone) → navigates to billboard:my_posts + - DEL (owner) → hard-deletes the Post + - BYE (invitee) → removes the viewer from post.shared_with + +Admin-Posts (kind=NOTE_UNLOCK) get the gear menu too but with NVM only — +DEL + BYE don't apply to system-authored threads. + +Mirrors apps/gameboard/_partials/_room_gear.html behaviour; see +test_game_room_gatekeeper.py {owner_can_delete_room, gamer_can_abandon_room} +for the room-side analogues. +""" +from selenium.webdriver.common.by import By + +from apps.billboard.models import Line, Post +from apps.drama.models import Note +from apps.lyric.models import User + +from .base import FunctionalTest + + +class OwnerPostGearTest(FunctionalTest): + """Owner of a user-Post sees NVM + DEL in the gear menu; DEL nukes + the Post and redirects to /billboard/users//.""" + + def setUp(self): + super().setUp() + self.owner = User.objects.create(email="owner@test.io") + self.post = Post.objects.create( + owner=self.owner, kind=Post.KIND_USER_POST, title="Doomed post", + ) + Line.objects.create(post=self.post, text="line 1", author=self.owner) + self.create_pre_authenticated_session("owner@test.io") + + def test_owner_can_delete_post_via_gear_menu(self): + self.browser.get( + self.live_server_url + f"/billboard/post/{self.post.id}/" + ) + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".gear-btn") + ).click() + self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, "#id_post_menu .btn-danger" + ) + ).click() + self.confirm_guard() + + self.wait_for(lambda: self.assertIn( + f"/billboard/users/{self.owner.id}/", self.browser.current_url + )) + self.assertFalse(Post.objects.filter(id=self.post.id).exists()) + + def test_gear_menu_for_owner_shows_NVM_and_DEL_but_not_BYE(self): + self.browser.get( + self.live_server_url + f"/billboard/post/{self.post.id}/" + ) + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".gear-btn") + ).click() + menu = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_post_menu") + ) + self.assertTrue(menu.find_elements(By.CSS_SELECTOR, ".btn-cancel")) + self.assertTrue(menu.find_elements(By.CSS_SELECTOR, ".btn-danger")) + self.assertFalse(menu.find_elements(By.CSS_SELECTOR, ".btn-abandon")) + + +class InviteePostGearTest(FunctionalTest): + """Invitee on a user-Post sees NVM + BYE; BYE removes the viewer + from post.shared_with (owner + other invitees keep the Post).""" + + def setUp(self): + super().setUp() + self.owner = User.objects.create(email="owner@test.io") + self.invitee = User.objects.create(email="invitee@test.io") + self.other = User.objects.create(email="other@test.io") + self.post = Post.objects.create( + owner=self.owner, kind=Post.KIND_USER_POST, title="Shared post", + ) + Line.objects.create(post=self.post, text="line 1", author=self.owner) + self.post.shared_with.add(self.invitee, self.other) + self.create_pre_authenticated_session("invitee@test.io") + + def test_invitee_can_abandon_post_via_gear_menu(self): + self.browser.get( + self.live_server_url + f"/billboard/post/{self.post.id}/" + ) + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".gear-btn") + ).click() + self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, "#id_post_menu .btn-abandon" + ) + ).click() + self.confirm_guard() + + self.wait_for(lambda: self.assertIn( + f"/billboard/users/{self.invitee.id}/", self.browser.current_url + )) + self.post.refresh_from_db() + self.assertNotIn(self.invitee, self.post.shared_with.all()) + # Post survives — owner + other invitee unaffected + self.assertEqual(self.post.owner, self.owner) + self.assertIn(self.other, self.post.shared_with.all()) + + def test_gear_menu_for_invitee_shows_NVM_and_BYE_but_not_DEL(self): + self.browser.get( + self.live_server_url + f"/billboard/post/{self.post.id}/" + ) + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".gear-btn") + ).click() + menu = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_post_menu") + ) + self.assertTrue(menu.find_elements(By.CSS_SELECTOR, ".btn-cancel")) + self.assertTrue(menu.find_elements(By.CSS_SELECTOR, ".btn-abandon")) + self.assertFalse(menu.find_elements(By.CSS_SELECTOR, ".btn-danger")) + + +class AdminPostGearTest(FunctionalTest): + """Admin-Post (kind=NOTE_UNLOCK) shows the gear menu with NVM only — + DEL + BYE don't apply to system-authored threads.""" + + def setUp(self): + super().setUp() + self.gamer = User.objects.create(email="gamer@test.io") + Note.grant_if_new(self.gamer, "stargazer") + self.admin_post = Post.objects.get( + owner=self.gamer, kind=Post.KIND_NOTE_UNLOCK, + ) + self.create_pre_authenticated_session("gamer@test.io") + + def test_admin_post_gear_menu_has_NVM_but_no_DEL_no_BYE(self): + self.browser.get( + self.live_server_url + f"/billboard/post/{self.admin_post.id}/" + ) + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".gear-btn") + ).click() + menu = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_post_menu") + ) + self.assertTrue(menu.find_elements(By.CSS_SELECTOR, ".btn-cancel")) + self.assertFalse(menu.find_elements(By.CSS_SELECTOR, ".btn-danger")) + self.assertFalse(menu.find_elements(By.CSS_SELECTOR, ".btn-abandon")) diff --git a/src/static_src/scss/_applets.scss b/src/static_src/scss/_applets.scss index f8d4795..d0a8a95 100644 --- a/src/static_src/scss/_applets.scss +++ b/src/static_src/scss/_applets.scss @@ -82,6 +82,7 @@ #id_game_kit_menu { @extend %applet-menu; } #id_wallet_applet_menu { @extend %applet-menu; } #id_room_menu { @extend %applet-menu; } +#id_post_menu { @extend %applet-menu; } #id_billboard_applet_menu { @extend %applet-menu; } #id_billscroll_menu { @extend %applet-menu; } @@ -90,6 +91,7 @@ .dashboard-page, .wallet-page, .room-page, +.post-page, .billboard-page, .billscroll-page { > .gear-btn { @@ -104,6 +106,7 @@ #id_game_applet_menu, #id_game_kit_menu, #id_wallet_applet_menu, +#id_post_menu, #id_billboard_applet_menu, #id_billscroll_menu { position: fixed; @@ -121,6 +124,7 @@ .dashboard-page, .wallet-page, .room-page, + .post-page, .billboard-page, .billscroll-page { > .gear-btn { @@ -135,6 +139,7 @@ #id_game_kit_menu, #id_wallet_applet_menu, #id_room_menu, + #id_post_menu, #id_billboard_applet_menu, #id_billscroll_menu { right: calc((var(--sidebar-w) - 3rem) / 2); diff --git a/src/templates/apps/billboard/_partials/_post_gear.html b/src/templates/apps/billboard/_partials/_post_gear.html new file mode 100644 index 0000000..f4a9de1 --- /dev/null +++ b/src/templates/apps/billboard/_partials/_post_gear.html @@ -0,0 +1,22 @@ +{# Gear menu on post.html — mirrors apps/gameboard/_partials/_room_gear.html. #} +{# All Posts get the gear w. NVM (back to my_posts). #} +{# User-Posts add DEL (owner) or BYE (invitee). #} +{# Admin-Posts (kind=NOTE_UNLOCK) intentionally have NVM only — DEL + BYE #} +{# don't apply to system-authored threads. #} + +{% include "apps/applets/_partials/_gear.html" with menu_id="id_post_menu" %} diff --git a/src/templates/apps/billboard/post.html b/src/templates/apps/billboard/post.html index 1c8770c..a1f1a64 100644 --- a/src/templates/apps/billboard/post.html +++ b/src/templates/apps/billboard/post.html @@ -88,6 +88,10 @@ {% if post.kind != 'note_unlock' %} {% include "apps/billboard/_partials/_bud_panel.html" %} {% endif %} + + {# Gear btn (bottom-right) + menu — NVM always; DEL (owner) / BYE #} + {# (invitee) gated to user-Posts. #} + {% include "apps/billboard/_partials/_post_gear.html" %} {% endblock content %}