diff --git a/src/apps/billboard/tests/integrated/test_views.py b/src/apps/billboard/tests/integrated/test_views.py index f59ff06..8d12776 100644 --- a/src/apps/billboard/tests/integrated/test_views.py +++ b/src/apps/billboard/tests/integrated/test_views.py @@ -85,6 +85,73 @@ class BillboardViewTest(TestCase): self.assertIsNone(response.context["recent_room"]) self.assertEqual(list(response.context["recent_events"]), []) + # ── recent_buds + recent_notes (applet feed) ────────────────────── + # Mirrors the recent_posts pattern: each in-grid applet now lists + # its own most-recent items (capped at 3) plus an empty-state row. + + def test_passes_recent_buds_context(self): + first = User.objects.create(email="first-bud@test.io") + second = User.objects.create(email="second-bud@test.io") + self.user.buds.add(first) + self.user.buds.add(second) + response = self.client.get("/billboard/") + # Most-recently-added bud is first + self.assertEqual(list(response.context["recent_buds"]), [second, first]) + + def test_recent_buds_capped_at_3(self): + added = [ + User.objects.create(email=f"bud{i}@test.io") for i in range(5) + ] + for u in added: + self.user.buds.add(u) + response = self.client.get("/billboard/") + recent = list(response.context["recent_buds"]) + self.assertEqual(len(recent), 3) + # Newest three in newest-first order + self.assertEqual(recent, list(reversed(added))[:3]) + + def test_recent_buds_empty_when_no_buds(self): + response = self.client.get("/billboard/") + self.assertEqual(list(response.context["recent_buds"]), []) + + def test_passes_recent_notes_context(self): + Note.objects.create( + user=self.user, slug="stargazer", + earned_at=timezone.now() - timezone.timedelta(days=2), + ) + Note.objects.create( + user=self.user, slug="super-schizo", + earned_at=timezone.now(), + ) + response = self.client.get("/billboard/") + slugs = [n.slug for n in response.context["recent_notes"]] + # Most-recently-earned first + self.assertEqual(slugs, ["super-schizo", "stargazer"]) + + def test_recent_notes_capped_at_3(self): + slugs = ["stargazer", "super-schizo", "super-nomad", "ladidah", "doodah"] + base = timezone.now() + for i, slug in enumerate(slugs): + Note.objects.create( + user=self.user, slug=slug, + earned_at=base - timezone.timedelta(hours=i), + ) + response = self.client.get("/billboard/") + names = [n.slug for n in response.context["recent_notes"]] + self.assertEqual(len(names), 3) + # Newest-first; oldest two trimmed + self.assertEqual(names, slugs[:3]) + + def test_recent_notes_excludes_other_users(self): + other = User.objects.create(email="other-note@test.io") + Note.objects.create(user=other, slug="stargazer", earned_at=timezone.now()) + response = self.client.get("/billboard/") + self.assertEqual(list(response.context["recent_notes"]), []) + + def test_recent_notes_empty_when_no_notes(self): + response = self.client.get("/billboard/") + self.assertEqual(list(response.context["recent_notes"]), []) + class SaveScrollPositionViewTest(TestCase): def setUp(self): @@ -602,3 +669,31 @@ class AbandonPostViewTest(TestCase): 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()) + + +class AnonymousPostViewerTest(TestCase): + """view_post has no @login_required (Percival ch.18 anonymous-post lab — + ownerless Posts are viewable by anyone). The gear menu's NVM target + reverses `my_posts user_id=request.user.id`, which previously exploded + w. NoReverseMatch when request.user.id was None (anonymous user). + Gear menu must be gated on is_authenticated; the post body still renders + for anonymous viewers of ownerless posts.""" + + def setUp(self): + from apps.billboard.models import Post + # Ownerless Post — matches the Percival anonymous-share contract + # exercised by apps.dashboard.tests.integrated.test_views.SharePostTest. + # No Line needed; the empty post still exercises the template render. + self.post = Post.objects.create(title="Public-ish") + + def test_anonymous_can_view_ownerless_post_without_500(self): + response = self.client.get( + reverse("billboard:view_post", args=[self.post.id]) + ) + self.assertEqual(response.status_code, 200) + + def test_anonymous_gets_no_gear_menu(self): + response = self.client.get( + reverse("billboard:view_post", args=[self.post.id]) + ) + self.assertNotIn(b"id_post_menu", response.content) diff --git a/src/apps/billboard/views.py b/src/apps/billboard/views.py index b0ee6d5..8d3ed14 100644 --- a/src/apps/billboard/views.py +++ b/src/apps/billboard/views.py @@ -31,6 +31,23 @@ def _recent_posts(user, limit=3): ) +def _recent_buds(user, limit=3): + """Most-recently-added buds, newest first. M2M has no explicit through + model, so we sort the auto-through table by its monotonic `id`.""" + through = User.buds.through # type: ignore[attr-defined] + rows = ( + through.objects + .filter(from_user=user) + .select_related("to_user") + .order_by("-id")[:limit] + ) + return [r.to_user for r in rows] + + +def _recent_notes(user, limit=3): + return user.notes.order_by("-earned_at")[:limit] + + def _billboard_context(user): my_rooms = rooms_for_user(user).order_by("-created_at") @@ -73,6 +90,8 @@ def _billboard_context(user): "applets": applet_context(user, "billboard"), "form": LineForm(), "recent_posts": _recent_posts(user), + "recent_buds": _recent_buds(user), + "recent_notes": _recent_notes(user), } @@ -251,6 +270,8 @@ def new_post(request): if request.user.is_authenticated: context["applets"] = applet_context(request.user, "billboard") context["recent_posts"] = _recent_posts(request.user) + context["recent_buds"] = _recent_buds(request.user) + context["recent_notes"] = _recent_notes(request.user) return render(request, "apps/billboard/billboard.html", context) diff --git a/src/static_src/scss/_billboard.scss b/src/static_src/scss/_billboard.scss index 6fcbe49..f2d3058 100644 --- a/src/static_src/scss/_billboard.scss +++ b/src/static_src/scss/_billboard.scss @@ -1,25 +1,80 @@ -// ── My Posts applet ──────────────────────────────────────────────────────── +// ── Shared scrollable applet list (.applet-list) ─────────────────────────── +// In-grid applet partials (My Posts, My Buds, My Notes, My Scrolls, My Games) +// + the dedicated applet-list pages (billbuds, billposts) all wrap items in a +// `