bud panels duplicate-add guard: server-side already_present flag + client-side error Brief w. FYI flash highlight on the existing entry — for each of the three #id_bud_btn panels (My Buds / post-share / gatekeeper-invite), the JSON response from add_bud / share_post / invite_gamer now carries {already_present, recipient_display, recipient_user_id}; bud-btn.js branches on already_present → calls new Brief.showDuplicateBanner({display_name, target_selector}) instead of the normal onSuccess append; banner title reads @<username> is already present, NVM dismisses, FYI dismisses AND eases in the .bud-duplicate-flash class (color: var(--terUser); text-shadow: 0 0 .5em var(--ninUser); transition: 600ms) onto the existing element (.bud-entry .bud-name / .post-recipient[data-user-id=…] / .gate-slot.filled[data-user-id=…]); gatekeeper "already present" = recipient is either GateSlot.FILLED + gamer OR has TableSeat OR has a pending RoomInvite (highlight target only set when seated — pending invites have no visible slot); .post-recipient chips + .gate-slot.filled cells gain data-user-id so the FYI selector can find them; my_buds.html now loads note.js via the {% block scripts %} pattern (Brief module is required by the duplicate banner path); bonus: latent test_jasmine.py bug fixed — "0 failures" in result.text matched "10 failures" / "20 failures" / etc, silently passing up to 99 failed specs; replaced w. re.search(r"(?<!\d)0 failures\b", …) (caught my new red specs, would've caught any prior Jasmine regression); 18 new ITs + 10 new Jasmine specs + 3 new FTs (one per panel) — TDD
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:
@@ -141,3 +141,88 @@ class InviteGamerLegacyFormTest(TestCase):
|
||||
self.assertTrue(RoomInvite.objects.filter(
|
||||
room=self.room, invitee_email="alice@test.io",
|
||||
).exists())
|
||||
|
||||
|
||||
class InviteGamerAlreadyPresentTest(TestCase):
|
||||
"""Duplicate-invite guard: a gatekeeper invite gets `already_present:
|
||||
true` in its JSON response when the recipient is either (a) already
|
||||
seated in the room or (b) has a pending RoomInvite. `recipient_user_id`
|
||||
is set only when seated — pending invitees have no visible
|
||||
`.gate-slot.filled` DOM element to highlight, so the FYI button just
|
||||
dismisses without easing in the flash class."""
|
||||
|
||||
def setUp(self):
|
||||
from apps.epic.models import GateSlot, TableSeat
|
||||
self.GateSlot = GateSlot
|
||||
self.TableSeat = TableSeat
|
||||
self.owner = User.objects.create(email="owner@test.io", username="owner")
|
||||
self.client.force_login(self.owner)
|
||||
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||
self.room = Room.objects.create(name="Bingo", owner=self.owner)
|
||||
|
||||
def _invite(self, recipient):
|
||||
return self.client.post(
|
||||
reverse("epic:invite_gamer", args=[self.room.id]),
|
||||
data={"recipient": recipient},
|
||||
HTTP_ACCEPT="application/json",
|
||||
)
|
||||
|
||||
def test_already_present_true_when_recipient_is_seated_via_gate_slot(self):
|
||||
"""Gatekeeper phase: 'seated' means a GateSlot.FILLED w. gamer set."""
|
||||
self.GateSlot.objects.create(
|
||||
room=self.room, gamer=self.alice, slot_number=1,
|
||||
status=self.GateSlot.FILLED,
|
||||
)
|
||||
self.assertTrue(self._invite("alice@test.io").json()["already_present"])
|
||||
|
||||
def test_already_present_true_when_recipient_has_table_seat(self):
|
||||
"""Post-gatekeeper phase: TableSeat exists once slots transition."""
|
||||
self.TableSeat.objects.create(room=self.room, gamer=self.alice, slot_number=1)
|
||||
self.assertTrue(self._invite("alice@test.io").json()["already_present"])
|
||||
|
||||
def test_recipient_user_id_carries_alice_id_when_seated(self):
|
||||
self.TableSeat.objects.create(room=self.room, gamer=self.alice, slot_number=1)
|
||||
body = self._invite("alice@test.io").json()
|
||||
self.assertEqual(body["recipient_user_id"], str(self.alice.id))
|
||||
|
||||
def test_already_present_true_when_recipient_has_pending_room_invite(self):
|
||||
RoomInvite.objects.create(
|
||||
room=self.room, inviter=self.owner,
|
||||
invitee_email="alice@test.io", status=RoomInvite.PENDING,
|
||||
)
|
||||
self.assertTrue(self._invite("alice@test.io").json()["already_present"])
|
||||
|
||||
def test_recipient_user_id_null_when_only_invited_not_seated(self):
|
||||
"""Pending invites have no visible slot element to highlight."""
|
||||
RoomInvite.objects.create(
|
||||
room=self.room, inviter=self.owner,
|
||||
invitee_email="alice@test.io", status=RoomInvite.PENDING,
|
||||
)
|
||||
self.assertIsNone(self._invite("alice@test.io").json()["recipient_user_id"])
|
||||
|
||||
def test_already_present_false_for_brand_new_recipient(self):
|
||||
self.assertFalse(self._invite("alice@test.io").json()["already_present"])
|
||||
|
||||
def test_brief_is_null_on_duplicate(self):
|
||||
"""No new Brief on duplicate — the client renders the error Brief
|
||||
from the already_present flag instead."""
|
||||
self.TableSeat.objects.create(room=self.room, gamer=self.alice, slot_number=1)
|
||||
self.assertIsNone(self._invite("alice@test.io").json()["brief"])
|
||||
|
||||
def test_no_new_room_invite_on_duplicate(self):
|
||||
RoomInvite.objects.create(
|
||||
room=self.room, inviter=self.owner,
|
||||
invitee_email="alice@test.io", status=RoomInvite.PENDING,
|
||||
)
|
||||
self._invite("alice@test.io")
|
||||
self.assertEqual(
|
||||
RoomInvite.objects.filter(room=self.room, invitee_email="alice@test.io").count(),
|
||||
1,
|
||||
)
|
||||
|
||||
def test_recipient_display_carries_username_on_duplicate(self):
|
||||
self.TableSeat.objects.create(room=self.room, gamer=self.alice, slot_number=1)
|
||||
self.assertEqual(
|
||||
self._invite("alice@test.io").json()["recipient_display"],
|
||||
"alice",
|
||||
)
|
||||
|
||||
@@ -699,36 +699,62 @@ def invite_gamer(request, room_id):
|
||||
# username-typed invite doesn't store the raw username as if it were
|
||||
# an email); falls back to the raw input for unregistered addresses.
|
||||
invitee_email = candidate.email if candidate else raw
|
||||
RoomInvite.objects.get_or_create(
|
||||
room=room,
|
||||
inviter=request.user,
|
||||
invitee_email=invitee_email,
|
||||
defaults={"status": RoomInvite.PENDING},
|
||||
)
|
||||
|
||||
# Buds graph: symmetric auto-add on registered recipients (mirrors
|
||||
# share_post). Idempotent on M2M; no-op on unregistered recipients.
|
||||
if candidate is not None:
|
||||
request.user.buds.add(candidate)
|
||||
candidate.buds.add(request.user)
|
||||
|
||||
# Brief: confirmation banner for the inviter. Brief.post stays null;
|
||||
# banner FYI navigates to the room's gatekeeper page via Brief.room.
|
||||
brief = Brief.objects.create(
|
||||
owner=request.user,
|
||||
post=None,
|
||||
room=room,
|
||||
kind=Brief.KIND_GAME_INVITE,
|
||||
title="Invite sent",
|
||||
# Duplicate-invite guard: "already present" = recipient is either
|
||||
# already seated in the room OR has a (pending/accepted) RoomInvite.
|
||||
# During gatekeeper phase the visible `.gate-slot.filled` cells are
|
||||
# GateSlot-driven (TableSeats spin up later at SIG SELECT), so check
|
||||
# both — GateSlot.FILLED catches the in-phase case, TableSeat catches
|
||||
# the post-phase case. Seated recipients carry recipient_user_id so
|
||||
# the client can find the .gate-slot.filled[data-user-id="X"]
|
||||
# highlight target; pending invitees have no visible slot, so
|
||||
# recipient_user_id stays null.
|
||||
already_seated = candidate is not None and (
|
||||
GateSlot.objects.filter(
|
||||
room=room, gamer=candidate, status=GateSlot.FILLED,
|
||||
).exists()
|
||||
or TableSeat.objects.filter(room=room, gamer=candidate).exists()
|
||||
)
|
||||
already_invited = RoomInvite.objects.filter(
|
||||
room=room, invitee_email=invitee_email,
|
||||
).exists()
|
||||
already_present = already_seated or already_invited
|
||||
|
||||
brief = None
|
||||
if not already_present:
|
||||
RoomInvite.objects.create(
|
||||
room=room,
|
||||
inviter=request.user,
|
||||
invitee_email=invitee_email,
|
||||
status=RoomInvite.PENDING,
|
||||
)
|
||||
# Buds graph: symmetric auto-add on registered recipients (mirrors
|
||||
# share_post). Idempotent on M2M; no-op on unregistered recipients.
|
||||
if candidate is not None:
|
||||
request.user.buds.add(candidate)
|
||||
candidate.buds.add(request.user)
|
||||
# Brief: confirmation banner for the inviter. Brief.post stays
|
||||
# null; banner FYI navigates to the room's gatekeeper page via
|
||||
# Brief.room.
|
||||
brief = Brief.objects.create(
|
||||
owner=request.user,
|
||||
post=None,
|
||||
room=room,
|
||||
kind=Brief.KIND_GAME_INVITE,
|
||||
title="Invite sent",
|
||||
)
|
||||
|
||||
recipient_user_id = str(candidate.id) if already_seated else None
|
||||
|
||||
if is_ajax:
|
||||
recipient_display = None
|
||||
if candidate is not None:
|
||||
recipient_display = candidate.username or candidate.email
|
||||
return JsonResponse({
|
||||
"brief": brief.to_banner_dict(),
|
||||
"brief": brief.to_banner_dict() if brief is not None else None,
|
||||
"recipient_display": recipient_display,
|
||||
"recipient_user_id": recipient_user_id,
|
||||
"already_present": already_present,
|
||||
})
|
||||
|
||||
return redirect("epic:gatekeeper", room_id=room_id)
|
||||
|
||||
Reference in New Issue
Block a user