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
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful

- 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:
Disco DeDisco
2026-05-09 01:14:11 -04:00
parent 419e022140
commit 22d0507c3f
4 changed files with 199 additions and 7 deletions

View 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("&amp; 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("&amp; 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)