"""Channels ITs for MySeaSpectateConsumer — the async-witness WS that pushes the owner's draw to watching invitees (user-spec 2026-05-29). Same shape as the voice consumer tests: scope injected directly, in-memory channel layer, `@tag("channels")` (excluded from the default run). """ from channels.db import database_sync_to_async from channels.testing.websocket import WebsocketCommunicator from django.contrib.auth.models import AnonymousUser from django.test import TransactionTestCase, override_settings, tag from django.utils import timezone from apps.gameboard.consumers import MySeaSpectateConsumer from apps.gameboard.models import SeaInvite from apps.lyric.models import User TEST_CHANNEL_LAYERS = { "default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}, } @tag("channels") @override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS) class MySeaSpectateConsumerTest(TransactionTestCase): 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") def _comm(self, user): comm = WebsocketCommunicator( MySeaSpectateConsumer.as_asgi(), f"/ws/my-sea-spectate/{self.owner.id}/", ) comm.scope["user"] = user comm.scope["url_route"] = {"kwargs": {"owner_id": self.owner.id}} return comm def _invite(self, present): return SeaInvite.objects.create( owner=self.owner, invitee=self.bud, invitee_email=self.bud.email, status=SeaInvite.ACCEPTED, accepted_at=timezone.now(), token_deposited_at=timezone.now() if present else None, ) async def test_owner_can_watch(self): comm = self._comm(self.owner) connected, _ = await comm.connect() self.assertTrue(connected) await comm.disconnect() async def test_unauthenticated_is_rejected(self): comm = self._comm(AnonymousUser()) connected, _ = await comm.connect() self.assertFalse(connected) async def test_stranger_is_rejected(self): comm = self._comm(self.bud) # no invite connected, _ = await comm.connect() self.assertFalse(connected) async def test_accepted_but_not_present_is_rejected(self): await database_sync_to_async(self._invite)(present=False) comm = self._comm(self.bud) connected, _ = await comm.connect() self.assertFalse(connected) async def test_present_invitee_receives_the_owners_hand_and_spread(self): await database_sync_to_async(self._invite)(present=True) comm = self._comm(self.bud) connected, _ = await comm.connect() self.assertTrue(connected) from channels.layers import get_channel_layer hand = [{"position": "lay", "card_id": 1, "reversed": False, "polarity": "gravity"}] # The spread rides along so a spectator under a stale spread re-syncs its # cross layout/captions before filling the hand (asymmetry fix # 2026-05-30). await get_channel_layer().group_send( f"mysea_{self.owner.id}", {"type": "sea_draw", "hand": hand, "spread": "escape-velocity"}) msg = await comm.receive_json_from() self.assertEqual( msg, {"type": "sea_draw", "hand": hand, "spread": "escape-velocity"}) await comm.disconnect() async def test_present_invitee_receives_seat_updates(self): await database_sync_to_async(self._invite)(present=True) comm = self._comm(self.bud) connected, _ = await comm.connect() self.assertTrue(connected) from channels.layers import get_channel_layer seats = [{"n": 1, "label": "1C", "present": True, "token": "owner-x"}] await get_channel_layer().group_send( f"mysea_{self.owner.id}", {"type": "sea_seats", "seats": seats}) msg = await comm.receive_json_from() self.assertEqual(msg, {"type": "sea_seats", "seats": seats}) await comm.disconnect()