post.html header prose branches on viewer-vs-owner: invitees see "shared with me, @viewer the {title}" + "created by @owner the {title}" instead of the owner-centric "just me / shared between" lines; owner view unchanged — TDD
- billboard.views.view_post adds viewer_is_owner + other_recipients context vars. is_real_invitee = (auth AND post has owner AND viewer != owner). Anon viewers + ownerless-post legacy path fall through to owner-style rendering (which renders empty gracefully via the at_handle / display_name AnonymousUser guards).
- other_recipients = post.shared_with.exclude(viewer) when invitee; .all() otherwise.
- post.html .post-header branches:
• viewer_is_owner: existing prose ("just me, @owner …" / "shared between {recipients} & me, @owner …").
• sole invitee: "shared with me, @viewer the {viewer.title}" + "created by @owner the {owner.title}".
• multi invitee: "shared with {other_recipients}" + "& me, @viewer the {viewer.title}" + "created by @owner the {owner.title}".
- lyric_extras at_handle + display_name: guard against AnonymousUser (no .email attribute) — return "" rather than crash. Preserves the Percival ch. 18 anon-views-ownerless-post path.
- 12 new ITs in test_post_invitee_view (context vars: viewer_is_owner, other_recipients exclude/include; template prose: sole + multi invitee phrasing, owner unchanged).
- 878 IT regression + 8 post-html FT regression green (1 Marionette flake on multi-run that passes in isolation).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
152
src/apps/billboard/tests/integrated/test_post_invitee_view.py
Normal file
152
src/apps/billboard/tests/integrated/test_post_invitee_view.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""ITs for post.html invitee-vs-owner header rendering.
|
||||
|
||||
The "just me, @owner the {title}" / "shared between … & me, @owner …" lines
|
||||
were owner-centric (the legacy phrasing assumed the viewer is the post
|
||||
creator). For an invitee (a user in post.shared_with), that prose is
|
||||
confusing. This view branches the .post-header block:
|
||||
|
||||
• Owner viewing → unchanged existing prose.
|
||||
• Invitee viewing (sole) → "shared with me, @viewer the {title}" +
|
||||
"created by @owner the {owner_title}".
|
||||
• Invitee viewing (multi) → "shared with {other_recipients ...}" +
|
||||
"& me, @viewer the {title}" +
|
||||
"created by @owner the {owner_title}".
|
||||
|
||||
The view layer adds `viewer_is_owner` + `other_recipients` to the
|
||||
context; template branches on those.
|
||||
"""
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.billboard.models import Line, Post
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
class PostInviteeViewContextTest(TestCase):
|
||||
"""Context vars: viewer_is_owner + other_recipients."""
|
||||
|
||||
def setUp(self):
|
||||
self.owner = User.objects.create(email="owner@test.io", username="owner")
|
||||
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||
self.bob = User.objects.create(email="bob@test.io", username="bob")
|
||||
self.post = Post.objects.create(owner=self.owner, title="Coolio")
|
||||
Line.objects.create(post=self.post, text="seed", author=self.owner)
|
||||
|
||||
def test_owner_viewing_sets_viewer_is_owner_true(self):
|
||||
self.client.force_login(self.owner)
|
||||
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
|
||||
self.assertTrue(response.context["viewer_is_owner"])
|
||||
|
||||
def test_invitee_viewing_sets_viewer_is_owner_false(self):
|
||||
self.post.shared_with.add(self.alice)
|
||||
self.client.force_login(self.alice)
|
||||
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
|
||||
self.assertFalse(response.context["viewer_is_owner"])
|
||||
|
||||
def test_other_recipients_excludes_viewer(self):
|
||||
"""For an invitee, other_recipients = shared_with minus self."""
|
||||
self.post.shared_with.add(self.alice, self.bob)
|
||||
self.client.force_login(self.alice)
|
||||
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
|
||||
others = list(response.context["other_recipients"])
|
||||
self.assertIn(self.bob, others)
|
||||
self.assertNotIn(self.alice, others)
|
||||
|
||||
def test_other_recipients_empty_for_sole_invitee(self):
|
||||
self.post.shared_with.add(self.alice)
|
||||
self.client.force_login(self.alice)
|
||||
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
|
||||
self.assertEqual(list(response.context["other_recipients"]), [])
|
||||
|
||||
def test_other_recipients_for_owner_is_full_shared_with(self):
|
||||
"""Owner viewing: other_recipients includes everyone (no self exclusion)."""
|
||||
self.post.shared_with.add(self.alice, self.bob)
|
||||
self.client.force_login(self.owner)
|
||||
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
|
||||
others = list(response.context["other_recipients"])
|
||||
self.assertIn(self.alice, others)
|
||||
self.assertIn(self.bob, others)
|
||||
|
||||
|
||||
class PostInviteeViewTemplateTest(TestCase):
|
||||
"""Template prose: invitee branch shows "shared with me, …" /
|
||||
"created by @owner …" — does NOT show the owner-centric "just me" or
|
||||
"shared between"."""
|
||||
|
||||
def setUp(self):
|
||||
self.owner = User.objects.create(email="owner@test.io", username="owner")
|
||||
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||
self.bob = User.objects.create(email="bob@test.io", username="bob")
|
||||
self.post = Post.objects.create(owner=self.owner, title="Coolio")
|
||||
Line.objects.create(post=self.post, text="seed", author=self.owner)
|
||||
|
||||
def test_sole_invitee_sees_shared_with_me(self):
|
||||
self.post.shared_with.add(self.alice)
|
||||
self.client.force_login(self.alice)
|
||||
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
|
||||
body = response.content.decode()
|
||||
self.assertIn("shared with me", body)
|
||||
self.assertIn("@alice", body)
|
||||
|
||||
def test_sole_invitee_does_not_see_just_me_or_shared_between(self):
|
||||
"""Scope to .post-header — the bud-panel JS includes 'just me,' as
|
||||
a regex literal in inline script, so a body-wide string match
|
||||
false-positives."""
|
||||
import lxml.html
|
||||
self.post.shared_with.add(self.alice)
|
||||
self.client.force_login(self.alice)
|
||||
body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode()
|
||||
tree = lxml.html.fromstring(body)
|
||||
header_text = tree.cssselect(".post-header")[0].text_content()
|
||||
self.assertNotIn("just me,", header_text)
|
||||
self.assertNotIn("shared between", header_text)
|
||||
|
||||
def test_invitee_sees_created_by_owner(self):
|
||||
self.post.shared_with.add(self.alice)
|
||||
self.client.force_login(self.alice)
|
||||
body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode()
|
||||
self.assertIn("created by", body)
|
||||
self.assertIn("@owner", body)
|
||||
|
||||
def test_multi_invitee_sees_shared_with_others_then_amp_me(self):
|
||||
self.post.shared_with.add(self.alice, self.bob)
|
||||
self.client.force_login(self.alice)
|
||||
body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode()
|
||||
self.assertIn("shared with", body)
|
||||
self.assertIn("@bob", body)
|
||||
self.assertIn("& me", body)
|
||||
self.assertIn("@alice", body)
|
||||
|
||||
def test_multi_invitee_does_not_see_self_in_recipients_line(self):
|
||||
"""The recipients line lists OTHER invitees, not self."""
|
||||
self.post.shared_with.add(self.alice, self.bob)
|
||||
self.client.force_login(self.alice)
|
||||
body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode()
|
||||
# Coarse check: "shared with @bob" appears w/o @alice in same line
|
||||
# (since alice's "@alice" is on the "& me" line below). The full
|
||||
# body contains both, but the .post-shared-recipients line should
|
||||
# only list other_recipients (i.e., bob, not alice).
|
||||
# Use a narrower lxml-style assertion.
|
||||
import lxml.html
|
||||
tree = lxml.html.fromstring(body)
|
||||
recipients_p = tree.cssselect(".post-shared-recipients")
|
||||
self.assertEqual(len(recipients_p), 1)
|
||||
rec_text = recipients_p[0].text_content()
|
||||
self.assertIn("@bob", rec_text)
|
||||
self.assertNotIn("@alice", rec_text)
|
||||
|
||||
def test_owner_view_unchanged_when_recipients_present(self):
|
||||
"""Owner sees 'shared between' (old behavior)."""
|
||||
self.post.shared_with.add(self.alice)
|
||||
self.client.force_login(self.owner)
|
||||
body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode()
|
||||
self.assertIn("shared between", body)
|
||||
self.assertIn("& me", body)
|
||||
self.assertNotIn("created by", body)
|
||||
|
||||
def test_owner_view_just_me_when_no_recipients(self):
|
||||
"""Owner with no recipients: 'just me, …' (old behavior)."""
|
||||
self.client.force_login(self.owner)
|
||||
body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode()
|
||||
self.assertIn("just me,", body)
|
||||
self.assertNotIn("created by", body)
|
||||
Reference in New Issue
Block a user