"""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)