my-sea bud-invite Phase A: SeaInvite model + @mailman log + OK/BYE accept/decline — TDD
Phase A of the my-sea invite → @mailman → spectator → voice blueprint (magical-dancing-quasar.md). Pure Django; no new infra this phase (the coturn droplet lands in Phase C5). Mirrors the @taxman ledger shape throughout. - A1: SeaInvite model (gameboard) — single source of truth for a my-sea invite (owner / invitee / status / timestamps + OneToOne FK to its @mailman Line). is_expired / voice_active / is_present / expires_at properties; 12 UTs. created_at uses default=timezone.now (MySeaDraw precedent) for testable 24h expiry; a token deposit makes the invite non-expiring per spec. - A2: reserved @mailman system user — get_or_create_mailman + "mailman" added to RESERVED_USERNAMES + seed migration lyric/0015. Email domain confirmed w. user as mailman@earthmanrpg.local (matches adman/taxman). - A3: billboard KIND_MAIL_ACCEPTANCE on Post + Brief; extends the post_save unsolicited-line guard (_SYSTEM_AUTHOR_POST_KINDS) + migration billboard/0009. - A4: apps/billboard/mail.py log_sea_invite — appends one interactive Line + invitee Brief on the invitee's "Acceptances & rejections" Post, links the Line back onto the SeaInvite; "Listen!—@owner invites you to {poss} drawing table" prose via at_handle + resolve_pronouns. Unregistered invitee no-ops. - A5: post.html renders OK .btn-confirm / BYE .btn-abandon (PENDING) or a status badge (ACCEPTED / DECLINED / LEFT / EXPIRED) from line.sea_invite.status via new _partials/_invite_actions.html; 'mailman' added to the system-author |safe + read-only-input + bud-panel-suppression branches. - A6: real my_sea_invite (replaces the coming-soon stub) — resolves recipient, dedups outstanding PENDING/ACCEPTED, creates SeaInvite + logs the @mailman line; new my_sea_invite_accept / my_sea_invite_decline endpoints (invitee-only, redirect back to the invite-log Post; accept links invitee FK + stamps accepted_at). 16 ITs. - A7: updated MySeaBudBtnInviteTest (stub→real invite) + new MySeaInviteAcceptanceLogTest FT (invitee opens their log Post, sees the line + OK/BYE). Both green. 457 IT/UT green. Phase B (invitee spectator seat-2 + visitor token gate) + Phase C (WebRTC mesh voice + coturn droplet) to follow. 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:
183
src/apps/gameboard/tests/integrated/test_sea_invite_views.py
Normal file
183
src/apps/gameboard/tests/integrated/test_sea_invite_views.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""ITs for the my-sea invite endpoints + post.html OK/BYE render — Phase A
|
||||
(A5/A6) of [[my-sea-invite-voice-blueprint]].
|
||||
|
||||
Covers the real `my_sea_invite` (replaces the "coming soon" stub), the
|
||||
`my_sea_invite_accept` / `my_sea_invite_decline` transitions + auth gating,
|
||||
and the interactive OK/BYE / status-badge render in the invitee's
|
||||
"Acceptances & rejections" Post.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.billboard.mail import log_sea_invite
|
||||
from apps.billboard.models import Brief, Post
|
||||
from apps.gameboard.models import SeaInvite
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
class MySeaInviteViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.owner = User.objects.create(email="owner@test.io", username="discoman")
|
||||
self.bud = User.objects.create(email="bud@test.io", username="budster")
|
||||
self.client.force_login(self.owner)
|
||||
self.url = reverse("my_sea_invite")
|
||||
|
||||
def _invite(self, recipient):
|
||||
return self.client.post(
|
||||
self.url, data={"recipient": recipient},
|
||||
HTTP_ACCEPT="application/json",
|
||||
)
|
||||
|
||||
def test_invite_registered_bud_creates_pending_invite(self):
|
||||
resp = self._invite("budster")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
invite = SeaInvite.objects.get(owner=self.owner, invitee=self.bud)
|
||||
self.assertEqual(invite.status, SeaInvite.PENDING)
|
||||
self.assertEqual(invite.invitee_email, self.bud.email)
|
||||
|
||||
def test_invite_logs_mailman_line_and_brief_for_invitee(self):
|
||||
self._invite("budster")
|
||||
post = Post.objects.get(owner=self.bud, kind=Post.KIND_MAIL_ACCEPTANCE)
|
||||
self.assertEqual(post.lines.count(), 1)
|
||||
self.assertTrue(
|
||||
Brief.objects.filter(
|
||||
owner=self.bud, kind=Brief.KIND_MAIL_ACCEPTANCE,
|
||||
).exists()
|
||||
)
|
||||
invite = SeaInvite.objects.get(owner=self.owner, invitee=self.bud)
|
||||
self.assertEqual(invite.line, post.lines.first())
|
||||
|
||||
def test_invite_returns_brief_json(self):
|
||||
resp = self._invite("budster")
|
||||
data = resp.json()
|
||||
self.assertIsNotNone(data["brief"])
|
||||
self.assertEqual(data["recipient_display"], "budster")
|
||||
|
||||
def test_invite_by_email_resolves_user(self):
|
||||
self._invite("bud@test.io")
|
||||
self.assertTrue(
|
||||
SeaInvite.objects.filter(owner=self.owner, invitee=self.bud).exists()
|
||||
)
|
||||
|
||||
def test_duplicate_pending_invite_does_not_create_second(self):
|
||||
self._invite("budster")
|
||||
self._invite("budster")
|
||||
self.assertEqual(
|
||||
SeaInvite.objects.filter(owner=self.owner, invitee=self.bud).count(), 1
|
||||
)
|
||||
|
||||
def test_self_invite_creates_nothing(self):
|
||||
self._invite("discoman")
|
||||
self.assertFalse(SeaInvite.objects.filter(owner=self.owner).exists())
|
||||
|
||||
def test_empty_recipient_creates_nothing(self):
|
||||
resp = self._invite("")
|
||||
self.assertFalse(SeaInvite.objects.exists())
|
||||
self.assertIsNone(resp.json()["brief"])
|
||||
|
||||
|
||||
class MySeaInviteAcceptDeclineTest(TestCase):
|
||||
def setUp(self):
|
||||
self.owner = User.objects.create(email="owner@test.io", username="discoman")
|
||||
self.bud = User.objects.create(email="bud@test.io", username="budster")
|
||||
self.invite = SeaInvite.objects.create(
|
||||
owner=self.owner, invitee=self.bud, invitee_email=self.bud.email,
|
||||
)
|
||||
log_sea_invite(self.invite)
|
||||
self.invite.refresh_from_db()
|
||||
|
||||
def test_invitee_accept_flips_to_accepted(self):
|
||||
self.client.force_login(self.bud)
|
||||
resp = self.client.post(
|
||||
reverse("my_sea_invite_accept", args=[self.invite.id])
|
||||
)
|
||||
self.invite.refresh_from_db()
|
||||
self.assertEqual(self.invite.status, SeaInvite.ACCEPTED)
|
||||
self.assertIsNotNone(self.invite.accepted_at)
|
||||
self.assertEqual(self.invite.invitee, self.bud)
|
||||
self.assertEqual(resp.status_code, 302)
|
||||
|
||||
def test_non_invitee_cannot_accept(self):
|
||||
stranger = User.objects.create(email="x@test.io", username="x")
|
||||
self.client.force_login(stranger)
|
||||
resp = self.client.post(
|
||||
reverse("my_sea_invite_accept", args=[self.invite.id])
|
||||
)
|
||||
self.invite.refresh_from_db()
|
||||
self.assertEqual(self.invite.status, SeaInvite.PENDING)
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
|
||||
def test_invitee_decline_flips_to_declined(self):
|
||||
self.client.force_login(self.bud)
|
||||
self.client.post(reverse("my_sea_invite_decline", args=[self.invite.id]))
|
||||
self.invite.refresh_from_db()
|
||||
self.assertEqual(self.invite.status, SeaInvite.DECLINED)
|
||||
|
||||
def test_non_invitee_cannot_decline(self):
|
||||
stranger = User.objects.create(email="x@test.io", username="x")
|
||||
self.client.force_login(stranger)
|
||||
resp = self.client.post(
|
||||
reverse("my_sea_invite_decline", args=[self.invite.id])
|
||||
)
|
||||
self.invite.refresh_from_db()
|
||||
self.assertEqual(self.invite.status, SeaInvite.PENDING)
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
|
||||
def test_expired_invite_cannot_be_accepted(self):
|
||||
SeaInvite.objects.filter(pk=self.invite.pk).update(
|
||||
created_at=timezone.now() - timedelta(hours=25)
|
||||
)
|
||||
self.client.force_login(self.bud)
|
||||
self.client.post(reverse("my_sea_invite_accept", args=[self.invite.id]))
|
||||
self.invite.refresh_from_db()
|
||||
self.assertEqual(self.invite.status, SeaInvite.PENDING) # unchanged
|
||||
|
||||
|
||||
class MySeaInvitePostRenderTest(TestCase):
|
||||
"""post.html (A5) — the @mailman invite Line renders OK/BYE for PENDING +
|
||||
a status badge otherwise, all driven by `line.sea_invite.status`."""
|
||||
|
||||
def setUp(self):
|
||||
self.owner = User.objects.create(email="owner@test.io", username="discoman")
|
||||
self.bud = User.objects.create(email="bud@test.io", username="budster")
|
||||
self.invite = SeaInvite.objects.create(
|
||||
owner=self.owner, invitee=self.bud, invitee_email=self.bud.email,
|
||||
)
|
||||
self.post, self.line, _ = log_sea_invite(self.invite)
|
||||
self.invite.refresh_from_db()
|
||||
self.client.force_login(self.bud)
|
||||
self.post_url = reverse("billboard:view_post", args=[self.post.id])
|
||||
self.accept_url = reverse("my_sea_invite_accept", args=[self.invite.id])
|
||||
self.decline_url = reverse("my_sea_invite_decline", args=[self.invite.id])
|
||||
|
||||
def test_pending_invite_renders_ok_bye(self):
|
||||
content = self.client.get(self.post_url).content.decode()
|
||||
self.assertIn("invite-ok-btn", content)
|
||||
self.assertIn("invite-bye-btn", content)
|
||||
self.assertIn(self.accept_url, content)
|
||||
self.assertIn(self.decline_url, content)
|
||||
|
||||
def test_accepted_invite_renders_badge_not_buttons(self):
|
||||
self.invite.status = SeaInvite.ACCEPTED
|
||||
self.invite.accepted_at = timezone.now()
|
||||
self.invite.save()
|
||||
content = self.client.get(self.post_url).content.decode()
|
||||
self.assertIn("Accepted", content)
|
||||
self.assertNotIn(self.accept_url, content)
|
||||
|
||||
def test_declined_invite_renders_declined_badge(self):
|
||||
self.invite.status = SeaInvite.DECLINED
|
||||
self.invite.save()
|
||||
content = self.client.get(self.post_url).content.decode()
|
||||
self.assertIn("Declined", content)
|
||||
self.assertNotIn(self.accept_url, content)
|
||||
|
||||
def test_mailman_line_renders_as_system_with_handles(self):
|
||||
content = self.client.get(self.post_url).content.decode()
|
||||
self.assertIn("post-line--system", content)
|
||||
self.assertIn("@mailman", content) # author handle column
|
||||
self.assertIn("@discoman", content) # owner handle interpolated in prose
|
||||
Reference in New Issue
Block a user