buddies sprint phase 1: User.buddies M2M(self,symm=False) + my_buddies aperture page + add_buddy JSON endpoint + buddy btn slide-out — TDD; My Contacts applet renamed → My Buddies (slug + name + partial)
- lyric/0004 adds User.buddies = ManyToManyField('self', symmetrical=False, blank=True, related_name='added_as_buddy'). Asymmetric one-way add: A.buddies.add(B) doesn't reciprocate. Reverse via B.added_as_buddy.all() — load-bearing for the future "buddy changed username" snapshot-accept flow noted in design.
- applets/0006 renames slug my-contacts → my-buddies + name 'Contacts' → 'My Buddies'. Existing migrations 0003/0004 untouched (historical artifacts).
- billboard.views.my_buddies + add_buddy:
• my_buddies: GET /billboard/my-buddies/ → renders the aperture page with request.user.buddies.all().
• add_buddy: POST /billboard/buddies/add → JSON {buddy: {id, username, email}|null}. Privacy: returns null when email isn't a registered User OR is the requester's own; never leaks membership. Idempotent on re-add (M2M dedup).
- templates:
• _applet-my-contacts.html → _applet-my-buddies.html (heading + link to /billboard/my-buddies/).
• my_buddies.html — bottom-anchored aperture list of buddies w. {% empty %} fallback "No buddies yet."
• _buddy_add_panel.html — bottom-left handshake btn + slide-out, mirrors _buddy_panel.html (post share) but POSTs to add_buddy and appends to #id_buddies_list. Skips append if data-buddy-id already in DOM (race-safe). Drops the .buddy-entry--empty row on first add.
- SCSS: page-billbuddies joins the body-class aperture trio; .buddies-page extends %billboard-page-base + flex-column + bottom-anchor for #id_buddies_list. id_applet_my_contacts → id_applet_my_buddies (test references + grid placement).
- tests: new test_buddies.py — 14 ITs covering UserBuddiesM2MTest (asymmetric, idempotent), MyBuddiesViewTest (lists own buddies only, anon redirect), AddBuddyViewTest (registered/unregistered/self/idempotent/email-fallback/405). Existing test_views/test_billboard/test_game_kit references swapped to my-buddies. New test_my_buddies.py FT — 4 tests: pre-existing buddies render, empty state, add via panel appends entry w. username, unregistered silent no-op.
- 841 ITs (+14) + 4 my_buddies FTs green.
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:
@@ -26,7 +26,7 @@ class BillboardScrollTest(FunctionalTest):
|
||||
super().setUp()
|
||||
for slug, name, cols, rows in [
|
||||
("my-scrolls", "My Scrolls", 4, 3),
|
||||
("my-contacts", "Contacts", 4, 3),
|
||||
("my-buddies", "My Buddies", 4, 3),
|
||||
("most-recent-scroll", "Most Recent Scroll", 8, 6),
|
||||
]:
|
||||
Applet.objects.get_or_create(
|
||||
@@ -193,7 +193,7 @@ class BillscrollPositionTest(FunctionalTest):
|
||||
class BillboardAppletsTest(FunctionalTest):
|
||||
"""
|
||||
FT: billboard page renders three applets in the grid — My Scrolls,
|
||||
My Contacts, and Most Recent Scroll — with a functioning gear menu.
|
||||
My Buddies, and Most Recent Scroll — with a functioning gear menu.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
@@ -203,7 +203,7 @@ class BillboardAppletsTest(FunctionalTest):
|
||||
self.room = Room.objects.create(name="Arcane Assembly", owner=self.founder)
|
||||
for slug, name, cols, rows in [
|
||||
("my-scrolls", "My Scrolls", 4, 3),
|
||||
("my-contacts", "Contacts", 4, 3),
|
||||
("my-buddies", "My Buddies", 4, 3),
|
||||
("most-recent-scroll", "Most Recent Scroll", 8, 6),
|
||||
]:
|
||||
Applet.objects.get_or_create(
|
||||
@@ -219,7 +219,7 @@ class BillboardAppletsTest(FunctionalTest):
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_applet_my_scrolls")
|
||||
)
|
||||
self.browser.find_element(By.ID, "id_applet_my_contacts")
|
||||
self.browser.find_element(By.ID, "id_applet_my_buddies")
|
||||
self.browser.find_element(By.ID, "id_applet_most_recent_scroll")
|
||||
|
||||
def test_billboard_my_scrolls_lists_rooms(self):
|
||||
@@ -278,7 +278,7 @@ class BillboardAppletsTest(FunctionalTest):
|
||||
lambda: self.browser.find_element(By.ID, "id_billboard_applet_menu")
|
||||
)
|
||||
contacts_cb = menu.find_element(
|
||||
By.CSS_SELECTOR, "input[value='my-contacts']"
|
||||
By.CSS_SELECTOR, "input[value='my-buddies']"
|
||||
)
|
||||
self.browser.execute_script("arguments[0].click()", contacts_cb)
|
||||
menu.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
|
||||
@@ -286,7 +286,7 @@ class BillboardAppletsTest(FunctionalTest):
|
||||
# Contacts is hidden; Most Recent Scroll + My Scrolls keep their content (bug #2)
|
||||
self.wait_for(
|
||||
lambda: self.assertEqual(
|
||||
self.browser.find_elements(By.ID, "id_applet_my_contacts"),
|
||||
self.browser.find_elements(By.ID, "id_applet_my_buddies"),
|
||||
[],
|
||||
)
|
||||
)
|
||||
@@ -305,7 +305,7 @@ class BillboardAppletsTest(FunctionalTest):
|
||||
)
|
||||
# The freshly-rendered menu must reflect DB state (Contacts unchecked)
|
||||
contacts_cb = menu.find_element(
|
||||
By.CSS_SELECTOR, "input[value='my-contacts']"
|
||||
By.CSS_SELECTOR, "input[value='my-buddies']"
|
||||
)
|
||||
self.assertFalse(contacts_cb.is_selected())
|
||||
most_recent_scroll_cb = menu.find_element(
|
||||
@@ -322,7 +322,7 @@ class BillboardAppletsTest(FunctionalTest):
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
self.browser.find_elements(By.ID, "id_applet_my_contacts"),
|
||||
self.browser.find_elements(By.ID, "id_applet_my_buddies"),
|
||||
[],
|
||||
)
|
||||
|
||||
@@ -332,7 +332,7 @@ class BillboardAppletsTest(FunctionalTest):
|
||||
lambda: self.browser.find_element(By.ID, "id_applet_my_scrolls")
|
||||
)
|
||||
self.assertEqual(
|
||||
self.browser.find_elements(By.ID, "id_applet_my_contacts"),
|
||||
self.browser.find_elements(By.ID, "id_applet_my_buddies"),
|
||||
[],
|
||||
)
|
||||
self.assertEqual(
|
||||
|
||||
@@ -134,7 +134,7 @@ class PronounsAppletFlowTest(FunctionalTest):
|
||||
# Billboard applets — page renders blank without these
|
||||
for slug, name, cols, rows in [
|
||||
("my-scrolls", "My Scrolls", 4, 3),
|
||||
("my-contacts", "Contacts", 4, 3),
|
||||
("my-buddies", "My Buddies", 4, 3),
|
||||
("most-recent-scroll", "Most Recent Scroll", 8, 6),
|
||||
]:
|
||||
Applet.objects.get_or_create(
|
||||
|
||||
75
src/functional_tests/test_my_buddies.py
Normal file
75
src/functional_tests/test_my_buddies.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""FT for the My Buddies page — buddy btn + slide-out add flow.
|
||||
|
||||
Phase 1 of the buddies sprint: explicit add via my_buddies.html. Phase 2
|
||||
will layer autocomplete (sky-place-style top-3 username suggestions) and
|
||||
implicit auto-add on post-share / gate-invite.
|
||||
"""
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
|
||||
from apps.lyric.models import User
|
||||
|
||||
from .base import FunctionalTest
|
||||
|
||||
|
||||
class MyBuddiesPageTest(FunctionalTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.gamer = User.objects.create(email="me@test.io", username="me")
|
||||
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||
self.create_pre_authenticated_session("me@test.io")
|
||||
|
||||
def test_renders_existing_buddies(self):
|
||||
"""Pre-existing buddies show up as entries on first render."""
|
||||
self.gamer.buddies.add(self.alice)
|
||||
self.browser.get(self.live_server_url + "/billboard/my-buddies/")
|
||||
entry = self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".buddy-entry .buddy-name")
|
||||
)
|
||||
self.assertEqual(entry.text, "alice")
|
||||
|
||||
def test_empty_state_when_no_buddies(self):
|
||||
self.browser.get(self.live_server_url + "/billboard/my-buddies/")
|
||||
empty = self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".buddy-entry--empty")
|
||||
)
|
||||
self.assertIn("No buddies yet", empty.text)
|
||||
|
||||
def test_add_buddy_via_buddy_btn_appends_entry(self):
|
||||
self.browser.get(self.live_server_url + "/billboard/my-buddies/")
|
||||
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
|
||||
btn.click()
|
||||
|
||||
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
|
||||
recipient.send_keys("alice@test.io")
|
||||
ok = self.browser.find_element(By.CSS_SELECTOR, "#id_buddy_panel .btn.btn-confirm")
|
||||
ok.click()
|
||||
|
||||
# New entry appears w. alice's username (not the bare email)
|
||||
self.wait_for(lambda: self.assertEqual(
|
||||
self.browser.find_element(
|
||||
By.CSS_SELECTOR, f".buddy-entry[data-buddy-id='{self.alice.id}'] .buddy-name"
|
||||
).text,
|
||||
"alice",
|
||||
))
|
||||
# Server-side persisted
|
||||
self.wait_for(lambda: self.assertIn(
|
||||
self.alice, list(self.gamer.buddies.all())
|
||||
))
|
||||
|
||||
def test_add_unregistered_email_is_silent_noop(self):
|
||||
self.browser.get(self.live_server_url + "/billboard/my-buddies/")
|
||||
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
|
||||
btn.click()
|
||||
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
|
||||
recipient.send_keys("ghost@test.io")
|
||||
ok = self.browser.find_element(By.CSS_SELECTOR, "#id_buddy_panel .btn.btn-confirm")
|
||||
ok.click()
|
||||
|
||||
# Wait for the panel close (a positive signal the request landed)
|
||||
self.wait_for(lambda: self.assertNotIn(
|
||||
"active", btn.get_attribute("class")
|
||||
))
|
||||
# No entries beyond the empty-state row
|
||||
entries = self.browser.find_elements(By.CSS_SELECTOR, ".buddy-entry:not(.buddy-entry--empty)")
|
||||
self.assertEqual(len(entries), 0)
|
||||
Reference in New Issue
Block a user