Files
python-tdd/src/apps/gameboard/tests/unit/test_sea_invite.py
Disco DeDisco fb8563eed2 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>
2026-05-27 13:14:06 -04:00

100 lines
4.5 KiB
Python

"""UT for the `SeaInvite` status machine + derived properties — Phase A1 of
[[my-sea-invite-voice-blueprint]].
`SeaInvite` is the single source of truth for the my-sea bud-invite
relationship. These tests pin the four derived properties the OK/BYE render,
seat-2 glow, spectator guard, and voice window all read from:
expires_at — created_at + 24h
is_expired — PENDING only, past expiry, AND no token deposited
voice_active — voice_until set + in the future
is_present — ACCEPTED + token deposited + not yet LEFT
All assertions run on unsaved instances (no FK / DB touched), so this is a
`SimpleTestCase` — the properties are pure functions of the row's own fields.
"""
from datetime import timedelta
from django.test import SimpleTestCase
from django.utils import timezone
from apps.gameboard.models import SeaInvite
class SeaInviteStatusMachineTest(SimpleTestCase):
def _invite(self, **kwargs):
"""Build an unsaved SeaInvite with PENDING/now defaults overridable."""
defaults = {"status": SeaInvite.PENDING, "created_at": timezone.now()}
defaults.update(kwargs)
return SeaInvite(**defaults)
# ── status default ───────────────────────────────────────────────────
def test_default_status_is_pending(self):
self.assertEqual(SeaInvite().status, SeaInvite.PENDING)
# ── expires_at ───────────────────────────────────────────────────────
def test_expires_at_is_24h_after_created(self):
created = timezone.now()
inv = self._invite(created_at=created)
self.assertEqual(inv.expires_at, created + timedelta(hours=24))
# ── is_expired ───────────────────────────────────────────────────────
def test_is_expired_true_for_stale_pending_without_deposit(self):
inv = self._invite(created_at=timezone.now() - timedelta(hours=25))
self.assertTrue(inv.is_expired)
def test_is_expired_false_within_window(self):
inv = self._invite(created_at=timezone.now() - timedelta(hours=1))
self.assertFalse(inv.is_expired)
def test_deposit_makes_invite_non_expiring(self):
# Per user-spec: once a token is deposited the invite never expires,
# even though it's well past the 24h PENDING window.
inv = self._invite(
created_at=timezone.now() - timedelta(hours=25),
token_deposited_at=timezone.now() - timedelta(hours=20),
)
self.assertFalse(inv.is_expired)
def test_only_pending_invites_can_expire(self):
inv = self._invite(
status=SeaInvite.ACCEPTED,
created_at=timezone.now() - timedelta(hours=25),
)
self.assertFalse(inv.is_expired)
# ── voice_active ─────────────────────────────────────────────────────
def test_voice_active_true_when_voice_until_in_future(self):
inv = self._invite(voice_until=timezone.now() + timedelta(hours=1))
self.assertTrue(inv.voice_active)
def test_voice_active_false_when_none(self):
self.assertFalse(self._invite().voice_active)
def test_voice_active_false_when_past(self):
inv = self._invite(voice_until=timezone.now() - timedelta(minutes=1))
self.assertFalse(inv.voice_active)
# ── is_present ───────────────────────────────────────────────────────
def test_is_present_true_when_accepted_deposited_not_left(self):
inv = self._invite(
status=SeaInvite.ACCEPTED,
token_deposited_at=timezone.now(),
)
self.assertTrue(inv.is_present)
def test_is_present_false_without_deposit(self):
inv = self._invite(status=SeaInvite.ACCEPTED)
self.assertFalse(inv.is_present)
def test_is_present_false_after_left(self):
# left_at set (status still ACCEPTED here) isolates the `left_at is
# None` clause of the guard.
inv = self._invite(
status=SeaInvite.ACCEPTED,
token_deposited_at=timezone.now() - timedelta(hours=1),
left_at=timezone.now(),
)
self.assertFalse(inv.is_present)