diff --git a/src/apps/billboard/static/apps/billboard/bud-btn.js b/src/apps/billboard/static/apps/billboard/bud-btn.js index 800af4a..624c37d 100644 --- a/src/apps/billboard/static/apps/billboard/bud-btn.js +++ b/src/apps/billboard/static/apps/billboard/bud-btn.js @@ -4,12 +4,23 @@ // • _bud_add_panel.html — My Buds add (POSTs to billboard:add_bud) // // Owns: csrf cookie read, open/close + .bud-open html-class, button click, -// Escape, click-outside, Enter-in-input, OK POST + JSON routing. +// Escape, click-outside, Enter-in-input, OK POST + JSON routing, and the +// `data.already_present` duplicate-guard branch (error Brief instead of +// the normal onSuccess append). // -// Each caller drives it with `bindBudBtn({submitUrl, autocompleteUrl?, onSuccess})`. -// `onSuccess(data)` does the panel-specific DOM updates (line append, chip -// append, bud-entry append, Brief banner — whatever the response carries). -// _close({clear: true}) fires automatically on a successful response. +// Each caller drives it with: +// bindBudBtn({ +// submitUrl, +// autocompleteUrl?, +// onSuccess(data), // success path: line/chip/entry/Brief +// duplicateTargetSelector?(data), // selector for .bud-duplicate-flash +// }) +// +// `onSuccess(data)` does the panel-specific DOM updates on the new-row path. +// `duplicateTargetSelector(data)` returns a CSS selector for the existing +// element to highlight when the FYI button on the error Brief is clicked +// (.bud-name / .post-recipient / .gate-slot.filled — varies by page). +// _close({clear: true}) fires automatically on every successful response. // // `autocompleteUrl` enables bud-autocomplete on the input (post-share + // gatekeeper panels) by binding bud-autocomplete.js to #id_bud_suggestions. @@ -87,7 +98,21 @@ }) .then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); }) .then(function (data) { - if (typeof opts.onSuccess === 'function') opts.onSuccess(data); + if (data && data.already_present) { + // Skip onSuccess — there's nothing to append. Show the + // error Brief instead. target_selector resolves at call + // time from the caller's per-page callback. + if (window.Brief) { + var sel = (typeof opts.duplicateTargetSelector === 'function') + ? opts.duplicateTargetSelector(data) : null; + Brief.showDuplicateBanner({ + display_name: data.recipient_display, + target_selector: sel, + }); + } + } else if (typeof opts.onSuccess === 'function') { + opts.onSuccess(data); + } _close({ clear: true }); }) .catch(function () { diff --git a/src/apps/billboard/tests/integrated/test_buds.py b/src/apps/billboard/tests/integrated/test_buds.py index e92d4e7..257c057 100644 --- a/src/apps/billboard/tests/integrated/test_buds.py +++ b/src/apps/billboard/tests/integrated/test_buds.py @@ -239,3 +239,84 @@ class SharePostImplicitAutoAddTest(TestCase): """Privacy: unregistered email doesn't touch the buds graph.""" self._share("ghost@test.io") self.assertEqual(self.sharer.buds.count(), 0) + + +class AddBudAlreadyPresentTest(TestCase): + """Duplicate-add guard: add_bud's JSON response distinguishes "newly + added" from "already a bud" so the bud-btn JS can render an error + Brief titled `@ is already present` instead of the normal + bud-entry append. `recipient_user_id` is the highlight target id that + the Brief FYI button toggles `.bud-duplicate-flash` onto.""" + + def setUp(self): + self.user = User.objects.create(email="me@test.io", username="me") + self.client.force_login(self.user) + self.alice = User.objects.create(email="alice@test.io", username="alice") + self.user.buds.add(self.alice) + + def _add(self, recipient): + return self.client.post( + reverse("billboard:add_bud"), {"recipient": recipient}, + ) + + def test_already_present_true_when_candidate_is_already_a_bud(self): + self.assertTrue(self._add("alice@test.io").json()["already_present"]) + + def test_already_present_false_for_new_bud(self): + User.objects.create(email="bob@test.io", username="bob") + self.assertFalse(self._add("bob@test.io").json()["already_present"]) + + def test_already_present_false_for_unregistered_email(self): + self.assertFalse(self._add("ghost@test.io").json()["already_present"]) + + def test_recipient_display_carries_username_on_duplicate(self): + self.assertEqual(self._add("alice@test.io").json()["recipient_display"], "alice") + + def test_recipient_user_id_carries_alice_id_on_duplicate(self): + self.assertEqual( + self._add("alice@test.io").json()["recipient_user_id"], + str(self.alice.id), + ) + + def test_username_typed_recipient_also_detects_duplicate(self): + """A user typing 'alice' (no @) resolves the same way as the email.""" + self.assertTrue(self._add("alice").json()["already_present"]) + + +class SharePostAlreadyPresentTest(TestCase): + """Duplicate-share guard mirrors AddBudAlreadyPresentTest — when the + recipient is already in post.shared_with, response carries + already_present + recipient_user_id for the .post-recipient highlight.""" + + def setUp(self): + from apps.billboard.models import Post + self.sharer = User.objects.create(email="sharer@test.io", username="sharer") + self.client.force_login(self.sharer) + self.alice = User.objects.create(email="alice@test.io", username="alice") + self.post = Post.objects.create(owner=self.sharer) + self.post.shared_with.add(self.alice) + + def _share(self, recipient): + return self.client.post( + reverse("billboard:share_post", args=[self.post.id]), + data={"recipient": recipient}, + HTTP_ACCEPT="application/json", + ) + + def test_already_present_true_when_recipient_in_shared_with(self): + self.assertTrue(self._share("alice@test.io").json()["already_present"]) + + def test_already_present_false_for_new_recipient(self): + User.objects.create(email="bob@test.io", username="bob") + self.assertFalse(self._share("bob@test.io").json()["already_present"]) + + def test_recipient_user_id_present_on_duplicate(self): + self.assertEqual( + self._share("alice@test.io").json()["recipient_user_id"], + str(self.alice.id), + ) + + def test_recipient_display_already_present_on_duplicate(self): + """`recipient_display` already exists on the success path; on + duplicate the same field must carry the matched user's handle.""" + self.assertEqual(self._share("alice@test.io").json()["recipient_display"], "alice") diff --git a/src/apps/billboard/views.py b/src/apps/billboard/views.py index 8b945ab..1449ab9 100644 --- a/src/apps/billboard/views.py +++ b/src/apps/billboard/views.py @@ -392,12 +392,16 @@ def share_post(request, post_id): # list exposes; doesn't widen the privacy surface beyond what the # post detail page already shows publicly. recipient_display = None + recipient_user_id = None if recipient is not None: recipient_display = recipient.username or recipient.email + recipient_user_id = str(recipient.id) return JsonResponse({ "brief": brief.to_banner_dict() if brief is not None else None, "line_text": line_text, "recipient_display": recipient_display, + "recipient_user_id": recipient_user_id, + "already_present": is_reshare, }) messages.success(request, "An invite has been sent if that address is registered.") @@ -446,16 +450,28 @@ def add_bud(request): candidate = _resolve_recipient(request.POST.get("recipient")) bud = None + already_present = False + recipient_display = None + recipient_user_id = None if candidate is not None and candidate != request.user: - if candidate not in request.user.buds.all(): + already_present = candidate in request.user.buds.all() + if not already_present: request.user.buds.add(candidate) + display = candidate.username or candidate.email bud = { "id": str(candidate.id), - "username": candidate.username or candidate.email, + "username": display, "email": candidate.email, } + recipient_display = display + recipient_user_id = str(candidate.id) - return JsonResponse({"bud": bud}) + return JsonResponse({ + "bud": bud, + "already_present": already_present, + "recipient_display": recipient_display, + "recipient_user_id": recipient_user_id, + }) @login_required(login_url="/") diff --git a/src/apps/dashboard/static/apps/dashboard/note.js b/src/apps/dashboard/static/apps/dashboard/note.js index 11f0eda..d292eea 100644 --- a/src/apps/dashboard/static/apps/dashboard/note.js +++ b/src/apps/dashboard/static/apps/dashboard/note.js @@ -69,13 +69,54 @@ const Brief = (() => { showBanner(data && data.brief); } + // Error-variant banner for "@ is already present". Distinct + // from showBanner because it's purely client-side (no Brief DB row); + // title carries the recipient handle; there's no date/square/post_url; + // FYI is a ' + + ''; + + banner.querySelector('.note-banner__nvm').addEventListener('click', function () { + banner.remove(); + }); + banner.querySelector('.note-banner__fyi').addEventListener('click', function () { + if (opts.target_selector) { + var target = document.querySelector(opts.target_selector); + if (target) target.classList.add('bud-duplicate-flash'); + } + banner.remove(); + }); + + var anchor = document.getElementById('id_brief_banner_anchor') + || document.querySelector('h2'); + if (anchor && anchor.parentNode) { + anchor.parentNode.insertBefore(banner, anchor.nextSibling); + } else { + document.body.insertBefore(banner, document.body.firstChild); + } + } + function _esc(str) { var d = document.createElement('div'); d.textContent = str || ''; return d.innerHTML; } - return { showBanner: showBanner, handleSaveResponse: handleSaveResponse }; + return { + showBanner: showBanner, + showDuplicateBanner: showDuplicateBanner, + handleSaveResponse: handleSaveResponse, + }; })(); // Backwards-compat shim — to be removed once the codebase uniformly uses Brief. diff --git a/src/apps/epic/tests/integrated/test_invite_gamer.py b/src/apps/epic/tests/integrated/test_invite_gamer.py index 5ea184f..a0b649e 100644 --- a/src/apps/epic/tests/integrated/test_invite_gamer.py +++ b/src/apps/epic/tests/integrated/test_invite_gamer.py @@ -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", + ) diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index 83cf9da..b62a1c6 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -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) diff --git a/src/functional_tests/test_bud_btn.py b/src/functional_tests/test_bud_btn.py index c411ba1..e6aa8c6 100644 --- a/src/functional_tests/test_bud_btn.py +++ b/src/functional_tests/test_bud_btn.py @@ -379,3 +379,49 @@ class PostHtmlAperturePageClassTest(FunctionalTest): "page-billboard" in cls or "page-billpost" in cls, f"my_posts.html body class missing aperture marker: {cls!r}", ) + + +class BudBtnDuplicateShareErrorTest(FunctionalTest): + """Re-sharing a post w. someone already in post.shared_with triggers + the error Brief titled `@ is already present`. FYI on the + Brief dismisses + adds .bud-duplicate-flash to the existing + .post-recipient[data-user-id=…] chip.""" + + def setUp(self): + super().setUp() + self.sharer = User.objects.create(email="bud@test.io", username="bud") + self.alice = User.objects.create(email="alice@test.io", username="alice") + self.post = _seed_a_post(self.sharer) + self.post.shared_with.add(self.alice) + self.create_pre_authenticated_session("bud@test.io") + self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/") + + def test_duplicate_share_shows_error_brief_and_fyi_flashes_chip(self): + btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn")) + btn.click() + recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient")) + recipient.send_keys("alice@test.io") + self.browser.find_element(By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm").click() + + title = self.wait_for(lambda: self.browser.find_element( + By.CSS_SELECTOR, ".note-banner--duplicate .note-banner__title" + )) + self.assertEqual(title.text, "@alice is already present") + + # Existing chip carries data-user-id so the FYI highlight can find it + chip = self.browser.find_element( + By.CSS_SELECTOR, f".post-recipient[data-user-id='{self.alice.id}']" + ) + self.assertNotIn("bud-duplicate-flash", chip.get_attribute("class") or "") + + # FYI dismisses + applies flash class + self.browser.find_element( + By.CSS_SELECTOR, ".note-banner--duplicate .note-banner__fyi" + ).click() + self.wait_for(lambda: self.assertEqual( + self.browser.find_elements(By.CSS_SELECTOR, ".note-banner--duplicate"), + [], + )) + self.wait_for(lambda: self.assertIn( + "bud-duplicate-flash", chip.get_attribute("class") or "" + )) diff --git a/src/functional_tests/test_gatekeeper_bud_btn.py b/src/functional_tests/test_gatekeeper_bud_btn.py index 80ca300..7f9a62a 100644 --- a/src/functional_tests/test_gatekeeper_bud_btn.py +++ b/src/functional_tests/test_gatekeeper_bud_btn.py @@ -10,7 +10,7 @@ epic:invite_gamer w. Accept: application/json — server returns from selenium.webdriver.common.by import By from apps.billboard.models import Brief -from apps.epic.models import Room, RoomInvite +from apps.epic.models import GateSlot, Room, RoomInvite, TableSeat from apps.lyric.models import User from .base import FunctionalTest @@ -114,3 +114,64 @@ class GatekeeperBudBtnAsyncInviteTest(FunctionalTest): self.wait_for(lambda: self.assertIn( self.alice, list(self.owner.buds.all()) )) + + +class GatekeeperBudBtnDuplicateInviteErrorTest(FunctionalTest): + """Re-inviting a recipient already seated in the room triggers the + error Brief titled `@ is already present`. FYI on the Brief + dismisses + adds .bud-duplicate-flash to the existing + .gate-slot.filled[data-user-id=…] element. Pending-but-unseated + duplicates also surface the Brief but FYI has no slot to highlight.""" + + def setUp(self): + super().setUp() + self.owner = User.objects.create(email="owner@test.io", username="owner") + self.alice = User.objects.create(email="alice@test.io", username="alice") + self.room = Room.objects.create(name="Dup Room", owner=self.owner) + # Seat alice via a GateSlot — _gate_positions renders .gate-slot.filled + # cells from GateSlot records (TableSeat spins up later at SIG SELECT), + # so the duplicate-highlight target lives there during gatekeeper phase. + GateSlot.objects.create( + room=self.room, gamer=self.alice, slot_number=1, + status=GateSlot.FILLED, + ) + self.room_url = f"{self.live_server_url}/gameboard/room/{self.room.id}/gate/" + self.create_pre_authenticated_session("owner@test.io") + self.browser.get(self.room_url) + + def test_duplicate_invite_shows_error_brief_and_fyi_flashes_slot(self): + btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn")) + btn.click() + recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient")) + recipient.send_keys("alice@test.io") + self.browser.find_element(By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm").click() + + title = self.wait_for(lambda: self.browser.find_element( + By.CSS_SELECTOR, ".note-banner--duplicate .note-banner__title" + )) + self.assertEqual(title.text, "@alice is already present") + + # No new RoomInvite or Brief persisted server-side on duplicate + self.assertFalse(RoomInvite.objects.filter( + room=self.room, invitee_email="alice@test.io", + ).exists()) + self.assertEqual( + Brief.objects.filter(owner=self.owner, kind=Brief.KIND_GAME_INVITE).count(), + 0, + ) + + slot = self.browser.find_element( + By.CSS_SELECTOR, f".gate-slot.filled[data-user-id='{self.alice.id}']" + ) + self.assertNotIn("bud-duplicate-flash", slot.get_attribute("class") or "") + + self.browser.find_element( + By.CSS_SELECTOR, ".note-banner--duplicate .note-banner__fyi" + ).click() + self.wait_for(lambda: self.assertEqual( + self.browser.find_elements(By.CSS_SELECTOR, ".note-banner--duplicate"), + [], + )) + self.wait_for(lambda: self.assertIn( + "bud-duplicate-flash", slot.get_attribute("class") or "" + )) diff --git a/src/functional_tests/test_jasmine.py b/src/functional_tests/test_jasmine.py index 7680197..43bc406 100644 --- a/src/functional_tests/test_jasmine.py +++ b/src/functional_tests/test_jasmine.py @@ -1,3 +1,5 @@ +import re + from selenium.webdriver.common.by import By from .base import FunctionalTest @@ -8,7 +10,10 @@ class JasmineTest(FunctionalTest): def check_results(): result = self.browser.find_element(By.CSS_SELECTOR, ".jasmine-overall-result") - if "0 failures" not in result.text: + # Word-boundary anchor — Jasmine 6 reports as "N specs, X failures". + # Plain `"0 failures" in text` matches "10 failures", "20 failures", + # etc., letting up to 99 failed specs slip past as green. + if not re.search(r"(? { }); + +// ───────────────────────────────────────────────────────────────────────────── +// +// Brief.showDuplicateBanner — the "already added" error variant. Distinct +// from showBanner because: the server doesn't persist a Brief row for these +// (transient, client-only), title is rendered as `@ is already +// present`, there's no date/square/post_url, and the FYI button toggles +// `.bud-duplicate-flash` onto a caller-supplied target element instead of +// navigating. +// +// API under test: +// Brief.showDuplicateBanner({ display_name, target_selector? }) +// null / missing display_name → no banner +// target_selector matches an element → FYI click adds .bud-duplicate-flash +// target_selector missing or matches nothing → FYI just dismisses +// +// ───────────────────────────────────────────────────────────────────────────── + +describe('Brief.showDuplicateBanner', () => { + + let fixture; + + beforeEach(() => { + fixture = document.createElement('div'); + fixture.id = 'dup-fixture'; + fixture.innerHTML = + '

Dash

' + + '
    ' + + '
  • ' + + 'alice' + + '
  • ' + + '
'; + document.body.appendChild(fixture); + }); + + afterEach(() => { + document.querySelectorAll('.note-banner').forEach(b => b.remove()); + fixture.remove(); + }); + + it('D1: missing display_name → no banner', () => { + Brief.showDuplicateBanner({}); + expect(document.querySelector('.note-banner')).toBeNull(); + }); + + it('D2: title reads "@ is already present"', () => { + Brief.showDuplicateBanner({ display_name: 'alice' }); + const title = document.querySelector('.note-banner__title'); + expect(title).not.toBeNull(); + expect(title.textContent).toBe('@alice is already present'); + }); + + it('D3: banner carries the .note-banner--duplicate modifier class', () => { + Brief.showDuplicateBanner({ display_name: 'alice' }); + const b = document.querySelector('.note-banner'); + expect(b).not.toBeNull(); + expect(b.classList.contains('note-banner--duplicate')).toBeTrue(); + }); + + it('D4: duplicate banner has no .note-banner__description or .note-banner__timestamp', () => { + Brief.showDuplicateBanner({ display_name: 'alice' }); + expect(document.querySelector('.note-banner__description')).toBeNull(); + expect(document.querySelector('.note-banner__timestamp')).toBeNull(); + }); + + it('D5: FYI button is a