Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/community/_event_comments.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from ninja.responses import Status
from notifications.service import (
broadcast_event_comment_update,
notify_comment_reaction,
notify_comment_reply,
notify_event_comment,
)
Expand Down Expand Up @@ -340,6 +341,8 @@ def toggle_reaction(request, event_id: UUID, comment_id: UUID, payload: Reaction
existing.delete()
else:
EventCommentReaction.objects.create(comment=comment, user=user, emoji=payload.emoji)
comment.event = event
notify_comment_reaction(comment, user)
# The toggle endpoint returns the parent top-level comment so the FE can
# update either a top-level row (when comment is top-level) or the reply's
# row (when comment is a reply). When it's a reply, we return the parent.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Generated by Django 6.0.3 on 2026-07-24 16:16

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("notifications", "0015_alter_notification_notification_type"),
]

operations = [
migrations.AlterField(
model_name="notification",
name="notification_type",
field=models.CharField(
choices=[
("event_invite", "Event Invite"),
("event_cancelled", "Event Cancelled"),
("join_request", "Join Request"),
("cohost_added", "Co-host Added"),
("cohost_invite", "Co-host Invite"),
("cohost_invite_accepted", "Co-host Invite Accepted"),
("cohost_invite_declined", "Co-host Invite Declined"),
("cohost_removed", "Co-host Removed"),
("magic_link_request", "Magic Link Request"),
("waitlist_promoted", "Waitlist Promoted"),
("event_flagged", "Event Flagged"),
("comment_reply", "Comment Reply"),
("event_comment", "Event Comment"),
("comment_reaction", "Comment Reaction"),
("rsvp_declined_note", "RSVP Declined Note"),
("checkin_nudge", "Check-in Nudge"),
],
default="event_invite",
max_length=32,
),
),
]
1 change: 1 addition & 0 deletions backend/notifications/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class NotificationType(models.TextChoices):
EVENT_FLAGGED = "event_flagged", "Event Flagged"
COMMENT_REPLY = "comment_reply", "Comment Reply"
EVENT_COMMENT = "event_comment", "Event Comment"
COMMENT_REACTION = "comment_reaction", "Comment Reaction"
RSVP_DECLINED_NOTE = "rsvp_declined_note", "RSVP Declined Note"
CHECKIN_NUDGE = "checkin_nudge", "Check-in Nudge"

Expand Down
16 changes: 16 additions & 0 deletions backend/notifications/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,22 @@ def notify_comment_reply(reply) -> None:
_notify_users([str(parent_author_id)])


def notify_comment_reaction(comment, reactor: User) -> None:
"""Notify a comment's author that someone reacted to it. No-op for self-reactions."""
if str(comment.author_id) == str(reactor.pk):
return
reactor_name = visible_display_name(reactor, None)
event_title = comment.event.title
Notification.objects.create(
recipient_id=comment.author_id,
notification_type=NotificationType.COMMENT_REACTION,
event=comment.event,
related_user=reactor,
message=f"{reactor_name} reacted to your comment on {event_title}",
)
_notify_users([str(comment.author_id)])


def _event_recipient_ids(event, *, exclude: str) -> list[str]:
"""Host + co-hosts, excluding `exclude` (usually the actor triggering the notification)."""
recipient_ids: set[str] = set()
Expand Down
209 changes: 209 additions & 0 deletions backend/tests/test_event_comment_reactions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import json

import pytest
from community.models import Event, EventComment, EventCommentReaction, EventRSVP, RSVPStatus
from django.core.cache import cache
from ninja_jwt.tokens import RefreshToken
from notifications.models import Notification, NotificationType
from users.models import User

from tests.conftest import future_iso


@pytest.fixture
def event(db, test_user):
return Event.objects.create(
title="Test Event",
start_datetime=future_iso(days=30),
created_by=test_user,
)


@pytest.fixture
def rsvp_user(db):
return User.objects.create_user(
phone_number="+12025550303",
password="rsvppass123",
first_name="RSVP",
last_name="Member",
)


@pytest.fixture
def rsvp_headers(rsvp_user):
refresh = RefreshToken.for_user(rsvp_user)
return {"HTTP_AUTHORIZATION": f"Bearer {refresh.access_token}"} # type: ignore


@pytest.fixture
def event_with_rsvp(db, event, rsvp_user):
EventRSVP.objects.create(event=event, user=rsvp_user, status=RSVPStatus.ATTENDING)
return event


@pytest.mark.django_db
class TestReactionToggle:
def test_first_toggle_creates(self, api_client, rsvp_headers, event_with_rsvp, rsvp_user):
comment = EventComment.objects.create(event=event_with_rsvp, author=rsvp_user, body="hi")
response = api_client.post(
f"/api/community/events/{event_with_rsvp.id}/comments/{comment.id}/reactions/",
data=json.dumps({"emoji": "❤️"}),
content_type="application/json",
**rsvp_headers,
)
assert response.status_code == 200, response.content
body = response.json()
hearts = [r for r in body["reactions"] if r["emoji"] == "❤️"]
assert len(hearts) == 1
assert hearts[0]["count"] == 1
assert hearts[0]["reacted_by_me"] is True

def test_second_toggle_removes(self, api_client, rsvp_headers, event_with_rsvp, rsvp_user):
comment = EventComment.objects.create(event=event_with_rsvp, author=rsvp_user, body="hi")
EventCommentReaction.objects.create(comment=comment, user=rsvp_user, emoji="❤️")
response = api_client.post(
f"/api/community/events/{event_with_rsvp.id}/comments/{comment.id}/reactions/",
data=json.dumps({"emoji": "❤️"}),
content_type="application/json",
**rsvp_headers,
)
assert response.status_code == 200
assert response.json()["reactions"] == []

def test_stacking_different_emojis(self, api_client, rsvp_headers, event_with_rsvp, rsvp_user):
comment = EventComment.objects.create(event=event_with_rsvp, author=rsvp_user, body="hi")
api_client.post(
f"/api/community/events/{event_with_rsvp.id}/comments/{comment.id}/reactions/",
data=json.dumps({"emoji": "❤️"}),
content_type="application/json",
**rsvp_headers,
)
response = api_client.post(
f"/api/community/events/{event_with_rsvp.id}/comments/{comment.id}/reactions/",
data=json.dumps({"emoji": "🔥"}),
content_type="application/json",
**rsvp_headers,
)
emojis = {r["emoji"] for r in response.json()["reactions"]}
assert emojis == {"❤️", "🔥"}

def test_invalid_emoji(self, api_client, rsvp_headers, event_with_rsvp, rsvp_user):
comment = EventComment.objects.create(event=event_with_rsvp, author=rsvp_user, body="hi")
response = api_client.post(
f"/api/community/events/{event_with_rsvp.id}/comments/{comment.id}/reactions/",
data=json.dumps({"emoji": "🦊"}),
content_type="application/json",
**rsvp_headers,
)
assert response.status_code == 422
assert response.json()["detail"][0]["code"] == "comment.invalid_emoji"

def test_reaction_requires_rsvp(self, api_client, event):
# A bystander (not creator, not co-host, not admin, no RSVP).
bystander = User.objects.create_user(
phone_number="+12025551010",
password="bystanderpass",
first_name="Bystander",
)
refresh = RefreshToken.for_user(bystander)
headers = {"HTTP_AUTHORIZATION": f"Bearer {refresh.access_token}"} # type: ignore
comment = EventComment.objects.create(event=event, author=event.created_by, body="hi")
response = api_client.post(
f"/api/community/events/{event.id}/comments/{comment.id}/reactions/",
data=json.dumps({"emoji": "❤️"}),
content_type="application/json",
**headers,
)
assert response.status_code == 403

def test_reaction_notifies_comment_author(
self, api_client, rsvp_headers, event_with_rsvp, rsvp_user
):
other_user = User.objects.create_user(
phone_number="+12025552020",
password="otherpass123",
first_name="Other",
last_name="Member",
)
EventRSVP.objects.create(
event=event_with_rsvp, user=other_user, status=RSVPStatus.ATTENDING
)
other_refresh = RefreshToken.for_user(other_user)
other_headers = {"HTTP_AUTHORIZATION": f"Bearer {other_refresh.access_token}"} # type: ignore
comment = EventComment.objects.create(event=event_with_rsvp, author=rsvp_user, body="hi")
response = api_client.post(
f"/api/community/events/{event_with_rsvp.id}/comments/{comment.id}/reactions/",
data=json.dumps({"emoji": "❤️"}),
content_type="application/json",
**other_headers,
)
assert response.status_code == 200, response.content
notification = Notification.objects.get(recipient=rsvp_user)
assert notification.notification_type == NotificationType.COMMENT_REACTION
assert notification.related_user_id == other_user.id # ty: ignore[unresolved-attribute]

def test_self_reaction_does_not_notify(
self, api_client, rsvp_headers, event_with_rsvp, rsvp_user
):
comment = EventComment.objects.create(event=event_with_rsvp, author=rsvp_user, body="hi")
response = api_client.post(
f"/api/community/events/{event_with_rsvp.id}/comments/{comment.id}/reactions/",
data=json.dumps({"emoji": "❤️"}),
content_type="application/json",
**rsvp_headers,
)
assert response.status_code == 200, response.content
assert not Notification.objects.filter(
recipient=rsvp_user, notification_type=NotificationType.COMMENT_REACTION
).exists()

def test_removing_reaction_does_not_notify(
self, api_client, rsvp_headers, event_with_rsvp, rsvp_user
):
other_user = User.objects.create_user(
phone_number="+12025552021",
password="otherpass123",
first_name="Other",
last_name="Member",
)
EventRSVP.objects.create(
event=event_with_rsvp, user=other_user, status=RSVPStatus.ATTENDING
)
other_refresh = RefreshToken.for_user(other_user)
other_headers = {"HTTP_AUTHORIZATION": f"Bearer {other_refresh.access_token}"} # type: ignore
comment = EventComment.objects.create(event=event_with_rsvp, author=rsvp_user, body="hi")
EventCommentReaction.objects.create(comment=comment, user=other_user, emoji="❤️")
response = api_client.post(
f"/api/community/events/{event_with_rsvp.id}/comments/{comment.id}/reactions/",
data=json.dumps({"emoji": "❤️"}),
content_type="application/json",
**other_headers,
)
assert response.status_code == 200, response.content
assert not Notification.objects.filter(
recipient=rsvp_user, notification_type=NotificationType.COMMENT_REACTION
).exists()

def test_rate_limit_kicks_in(self, api_client, rsvp_headers, event_with_rsvp, rsvp_user):
"""11th write in 60s should 429. Toggles back-and-forth to avoid the
unique-constraint blocking the second create — toggle on, off, on, off..."""
cache.clear()
comment = EventComment.objects.create(event=event_with_rsvp, author=rsvp_user, body="hi")
url = f"/api/community/events/{event_with_rsvp.id}/comments/{comment.id}/reactions/"
for _ in range(10):
r = api_client.post(
url,
data=json.dumps({"emoji": "❤️"}),
content_type="application/json",
**rsvp_headers,
)
assert r.status_code == 200, r.content
# 11th request hits the limit
r = api_client.post(
url,
data=json.dumps({"emoji": "❤️"}),
content_type="application/json",
**rsvp_headers,
)
assert r.status_code == 429
assert r.json()["detail"][0]["code"] == "rate.limited"
Loading
Loading