From ccd5df7dcb6a12f10b6e3afa80d3c2bfa3261a14 Mon Sep 17 00:00:00 2001 From: lexicalunit Date: Thu, 5 Feb 2026 20:24:16 -0800 Subject: [PATCH] Send additional data to Convoke --- CHANGELOG.md | 1 + src/spellbot/integrations/convoke.py | 16 +- src/spellbot/models/__init__.py | 3 +- src/spellbot/models/user.py | 6 + src/spellbot/services/games.py | 11 +- tests/integrations/test_convoke.py | 402 +++++++++++++++++++++++++++ tests/services/test_games.py | 53 +++- 7 files changed, 477 insertions(+), 15 deletions(-) create mode 100644 tests/integrations/test_convoke.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 889e0752..e52cd06b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added - More emojis in the messages that SpellBot sends. +- Send additional data to Convoke when creating games. ## [v17.10.2](https://github.com/lexicalunit/spellbot/releases/tag/v17.10.2) - 2026-02-04 diff --git a/src/spellbot/integrations/convoke.py b/src/spellbot/integrations/convoke.py index 52110517..40b9e3e6 100644 --- a/src/spellbot/integrations/convoke.py +++ b/src/spellbot/integrations/convoke.py @@ -75,7 +75,7 @@ class ConvokeGameTypes(Enum): Other = "other" -def convoke_game_format(format: GameFormat) -> ConvokeGameTypes: # pragma: no cover +def convoke_game_format(format: GameFormat) -> ConvokeGameTypes: match format: case ( GameFormat.COMMANDER @@ -101,13 +101,13 @@ def convoke_game_format(format: GameFormat) -> ConvokeGameTypes: # pragma: no c return ConvokeGameTypes.Other -def passphrase() -> str | None: # pragma: no cover +def passphrase() -> str | None: if USE_PASSWORD: return f"{random.choice(ADJECTIVES)} {random.choice(NOUNS)}" # noqa: S311 return None -async def fetch_convoke_link( # pragma: no cover +async def fetch_convoke_link( client: httpx.AsyncClient, game: GameDict, key: str | None, @@ -120,11 +120,14 @@ async def fetch_convoke_link( # pragma: no cover "apiKey": settings.CONVOKE_API_KEY, "isPublic": False, "name": name, + "spellbotGameId": game["id"], "seatLimit": game["seats"], "format": format, "discordGuild": str(game["guild_xid"]), "discordChannel": str(game["channel_xid"]), - "discordPlayers": [{"id": str(p["xid"]), "name": p["name"]} for p in players], + "discordPlayers": [ + {"id": str(p["xid"]), "name": p["name"], "pin": p["pin"]} for p in players + ], } if game["bracket"] != GameBracket.NONE.value: payload["bracketLevel"] = f"B{game['bracket'] - 1}" @@ -137,9 +140,7 @@ async def fetch_convoke_link( # pragma: no cover return resp.json() -async def generate_link( - game: GameDict, -) -> tuple[str | None, str | None]: # pragma: no cover +async def generate_link(game: GameDict) -> tuple[str | None, str | None]: if not settings.CONVOKE_API_KEY: return None, None @@ -160,6 +161,7 @@ async def generate_link( attempt + 1, exc_info=True, ) + continue if not data: return None, None diff --git a/src/spellbot/models/__init__.py b/src/spellbot/models/__init__.py index 3bc3863a..6864c552 100644 --- a/src/spellbot/models/__init__.py +++ b/src/spellbot/models/__init__.py @@ -27,7 +27,7 @@ def import_models() -> None: # pragma: no cover from .post import Post, PostDict # noqa: E402 from .queue import Queue, QueueDict # noqa: E402 from .token import Token, TokenDict # noqa: E402 -from .user import User, UserDict # noqa: E402 +from .user import PlayerDataDict, User, UserDict # noqa: E402 from .verify import Verify, VerifyDict # noqa: E402 from .watch import Watch, WatchDict # noqa: E402 @@ -51,6 +51,7 @@ def import_models() -> None: # pragma: no cover "NotificationDict", "Play", "PlayDict", + "PlayerDataDict", "Post", "PostDict", "Queue", diff --git a/src/spellbot/models/user.py b/src/spellbot/models/user.py index 435dc4c4..5656ee3e 100644 --- a/src/spellbot/models/user.py +++ b/src/spellbot/models/user.py @@ -21,6 +21,12 @@ class UserDict(TypedDict): banned: bool +class PlayerDataDict(TypedDict): + xid: int + name: str + pin: str | None + + class User(Base): """Represents a Discord user.""" diff --git a/src/spellbot/services/games.py b/src/spellbot/services/games.py index e58bbaaf..f9cba5ed 100644 --- a/src/spellbot/services/games.py +++ b/src/spellbot/services/games.py @@ -19,12 +19,12 @@ GameDict, GameStatus, Play, + PlayerDataDict, Post, Queue, QueueDict, User, UserAward, - UserDict, Watch, ) from spellbot.settings import settings @@ -579,10 +579,11 @@ def select_last_game(self, user_xid: int, guild_xid: int) -> GameDict | None: @sync_to_async() @tracer.wrap() - def player_data(self, game_id: int) -> list[UserDict]: + def player_data(self, game_id: int) -> list[PlayerDataDict]: game = DatabaseSession.query(Game).filter(Game.id == game_id).first() if not game: return [] - player_xids = game.player_xids - players = DatabaseSession.query(User).filter(User.xid.in_(player_xids)).all() - return [p.to_dict() for p in players] + + player_pins = game.player_pins + players = DatabaseSession.query(User).filter(User.xid.in_(game.player_xids)).all() + return [PlayerDataDict(xid=p.xid, name=p.name, pin=player_pins.get(p.xid)) for p in players] diff --git a/tests/integrations/test_convoke.py b/tests/integrations/test_convoke.py new file mode 100644 index 00000000..ca4211f5 --- /dev/null +++ b/tests/integrations/test_convoke.py @@ -0,0 +1,402 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, cast +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +import spellbot.integrations.convoke as convoke_module +from spellbot.enums import GameBracket, GameFormat +from spellbot.integrations.convoke import ( + ConvokeGameTypes, + convoke_game_format, + fetch_convoke_link, + generate_link, + passphrase, +) + +if TYPE_CHECKING: + from spellbot.models import GameDict + + +class TestConvokeGameFormat: + @pytest.mark.parametrize( + ("game_format", "expected"), + [ + pytest.param(GameFormat.COMMANDER, ConvokeGameTypes.Commander, id="commander"), + pytest.param(GameFormat.EDH_MAX, ConvokeGameTypes.Commander, id="edh_max"), + pytest.param(GameFormat.EDH_HIGH, ConvokeGameTypes.Commander, id="edh_high"), + pytest.param(GameFormat.EDH_MID, ConvokeGameTypes.Commander, id="edh_mid"), + pytest.param(GameFormat.EDH_LOW, ConvokeGameTypes.Commander, id="edh_low"), + pytest.param( + GameFormat.EDH_BATTLECRUISER, + ConvokeGameTypes.Commander, + id="edh_battlecruiser", + ), + pytest.param(GameFormat.PRE_CONS, ConvokeGameTypes.Commander, id="pre_cons"), + pytest.param(GameFormat.CEDH, ConvokeGameTypes.Commander, id="cedh"), + pytest.param(GameFormat.PAUPER_EDH, ConvokeGameTypes.Commander, id="pauper_edh"), + pytest.param(GameFormat.MODERN, ConvokeGameTypes.Modern, id="modern"), + pytest.param(GameFormat.STANDARD, ConvokeGameTypes.Standard, id="standard"), + pytest.param(GameFormat.HORDE_MAGIC, ConvokeGameTypes.Horde, id="horde_magic"), + pytest.param(GameFormat.PLANECHASE, ConvokeGameTypes.Planechase, id="planechase"), + pytest.param(GameFormat.LEGACY, ConvokeGameTypes.Other, id="legacy"), + pytest.param(GameFormat.VINTAGE, ConvokeGameTypes.Other, id="vintage"), + pytest.param(GameFormat.PIONEER, ConvokeGameTypes.Other, id="pioneer"), + ], + ) + def test_game_format_mapping( + self, + game_format: GameFormat, + expected: ConvokeGameTypes, + ) -> None: + assert convoke_game_format(game_format) == expected + + +class TestPassphrase: + def test_passphrase_when_disabled(self) -> None: + with patch.object(convoke_module, "USE_PASSWORD", False): + assert passphrase() is None + + def test_passphrase_when_enabled(self) -> None: + with patch.object(convoke_module, "USE_PASSWORD", True): + result = passphrase() + assert result is not None + parts = result.split(" ") + assert len(parts) == 2 + assert parts[0] in convoke_module.ADJECTIVES + assert parts[1] in convoke_module.NOUNS + + +class TestFetchConvokeLink: + @pytest.mark.asyncio + async def test_fetch_convoke_link_success(self) -> None: + mock_client = MagicMock(spec=httpx.AsyncClient) + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = {"url": "https://convoke.gg/game/123"} + mock_client.post = AsyncMock(return_value=mock_response) + + game = cast( + "GameDict", + { + "id": 42, + "format": GameFormat.COMMANDER.value, + "seats": 4, + "guild_xid": 12345, + "channel_xid": 67890, + "bracket": GameBracket.NONE.value, + }, + ) + + players = [ + {"xid": 100, "name": "Player1", "pin": "123456"}, + {"xid": 200, "name": "Player2", "pin": None}, + ] + + with ( + patch.object( + convoke_module.services.games, + "player_data", + AsyncMock(return_value=players), + ), + patch.object(convoke_module.settings, "CONVOKE_API_KEY", "test_api_key"), + patch.object(convoke_module.settings, "CONVOKE_ROOT", "https://api.convoke.gg"), + ): + result = await fetch_convoke_link(mock_client, game, None) + + assert result == {"url": "https://convoke.gg/game/123"} + mock_client.post.assert_called_once() + call_args = mock_client.post.call_args + payload = call_args.kwargs["json"] + assert payload["spellbotGameId"] == 42 + assert payload["discordPlayers"] == [ + {"id": "100", "name": "Player1", "pin": "123456"}, + {"id": "200", "name": "Player2", "pin": None}, + ] + + @pytest.mark.asyncio + async def test_fetch_convoke_link_with_bracket(self) -> None: + mock_client = MagicMock(spec=httpx.AsyncClient) + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = {"url": "https://convoke.gg/game/456"} + mock_client.post = AsyncMock(return_value=mock_response) + + game = cast( + "GameDict", + { + "id": 99, + "format": GameFormat.COMMANDER.value, + "seats": 4, + "guild_xid": 12345, + "channel_xid": 67890, + "bracket": GameBracket.BRACKET_2.value, + }, + ) + + with ( + patch.object( + convoke_module.services.games, + "player_data", + AsyncMock(return_value=[]), + ), + patch.object(convoke_module.settings, "CONVOKE_API_KEY", "test_api_key"), + patch.object(convoke_module.settings, "CONVOKE_ROOT", "https://api.convoke.gg"), + ): + result = await fetch_convoke_link(mock_client, game, None) + + assert result == {"url": "https://convoke.gg/game/456"} + payload = mock_client.post.call_args.kwargs["json"] + assert payload["bracketLevel"] == "B2" # BRACKET_2.value (3) -> B{3-1} = B2 + + @pytest.mark.asyncio + async def test_fetch_convoke_link_with_password(self) -> None: + mock_client = MagicMock(spec=httpx.AsyncClient) + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = {"url": "https://convoke.gg/game/789"} + mock_client.post = AsyncMock(return_value=mock_response) + + game = cast( + "GameDict", + { + "id": 101, + "format": GameFormat.COMMANDER.value, + "seats": 4, + "guild_xid": 12345, + "channel_xid": 67890, + "bracket": GameBracket.NONE.value, + }, + ) + + with ( + patch.object( + convoke_module.services.games, + "player_data", + AsyncMock(return_value=[]), + ), + patch.object(convoke_module.settings, "CONVOKE_API_KEY", "test_api_key"), + patch.object(convoke_module.settings, "CONVOKE_ROOT", "https://api.convoke.gg"), + ): + result = await fetch_convoke_link(mock_client, game, "secret_pass") + + assert result == {"url": "https://convoke.gg/game/789"} + payload = mock_client.post.call_args.kwargs["json"] + assert payload["password"] == "secret_pass" + + +class TestGenerateLink: + @pytest.mark.asyncio + async def test_generate_link_no_api_key(self) -> None: + game = cast( + "GameDict", + {"id": 1, "format": GameFormat.COMMANDER.value}, + ) + + with patch.object(convoke_module.settings, "CONVOKE_API_KEY", ""): + result = await generate_link(game) + + assert result == (None, None) + + @pytest.mark.asyncio + async def test_generate_link_success(self) -> None: + game = cast( + "GameDict", + { + "id": 1, + "format": GameFormat.COMMANDER.value, + "seats": 4, + "guild_xid": 12345, + "channel_xid": 67890, + "bracket": GameBracket.NONE.value, + }, + ) + + with ( + patch.object(convoke_module.settings, "CONVOKE_API_KEY", "test_key"), + patch.object(convoke_module, "passphrase", return_value=None), + patch.object( + convoke_module, + "fetch_convoke_link", + AsyncMock(return_value={"url": "https://convoke.gg/game/123"}), + ), + ): + result = await generate_link(game) + + assert result == ("https://convoke.gg/game/123", None) + + @pytest.mark.asyncio + async def test_generate_link_success_with_password_from_response(self) -> None: + game = cast( + "GameDict", + { + "id": 1, + "format": GameFormat.COMMANDER.value, + "seats": 4, + "guild_xid": 12345, + "channel_xid": 67890, + "bracket": GameBracket.NONE.value, + }, + ) + + with ( + patch.object(convoke_module.settings, "CONVOKE_API_KEY", "test_key"), + patch.object(convoke_module, "passphrase", return_value=None), + patch.object( + convoke_module, + "fetch_convoke_link", + AsyncMock( + return_value={"url": "https://convoke.gg/game/123", "password": "resp_pass"}, + ), + ), + ): + result = await generate_link(game) + + assert result == ("https://convoke.gg/game/123", "resp_pass") + + @pytest.mark.asyncio + async def test_generate_link_success_with_passphrase(self) -> None: + game = cast( + "GameDict", + { + "id": 1, + "format": GameFormat.COMMANDER.value, + "seats": 4, + "guild_xid": 12345, + "channel_xid": 67890, + "bracket": GameBracket.NONE.value, + }, + ) + + with ( + patch.object(convoke_module.settings, "CONVOKE_API_KEY", "test_key"), + patch.object(convoke_module, "passphrase", return_value="ancient dragon"), + patch.object( + convoke_module, + "fetch_convoke_link", + AsyncMock(return_value={"url": "https://convoke.gg/game/456"}), + ), + ): + result = await generate_link(game) + + assert result == ("https://convoke.gg/game/456", "ancient dragon") + + @pytest.mark.asyncio + async def test_generate_link_retries_on_failure_then_succeeds(self) -> None: + """Test that generate_link retries after failure and succeeds on second attempt.""" + game = cast( + "GameDict", + { + "id": 1, + "format": GameFormat.COMMANDER.value, + "seats": 4, + "guild_xid": 12345, + "channel_xid": 67890, + "bracket": GameBracket.NONE.value, + }, + ) + + # Create a mock that fails then succeeds (to test the retry path) + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = {"url": "https://convoke.gg/game/789"} + + mock_client = MagicMock(spec=httpx.AsyncClient) + # First call fails, second call succeeds + mock_client.post = AsyncMock( + side_effect=[Exception("Connection error"), mock_response], + ) + + mock_player_data = AsyncMock(return_value=[]) + + with ( + patch.object(convoke_module.settings, "CONVOKE_API_KEY", "test_key"), + patch.object(convoke_module, "passphrase", return_value=None), + patch("httpx.AsyncClient") as mock_client_class, + patch.object(convoke_module, "add_span_error"), + patch.object(convoke_module.services.games, "player_data", mock_player_data), + ): + mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client) + mock_client_class.return_value.__aexit__ = AsyncMock(return_value=None) + result = await generate_link(game) + + # First attempt fails, second succeeds + assert result == ("https://convoke.gg/game/789", None) + assert mock_client.post.call_count == 2 + + @pytest.mark.asyncio + async def test_generate_link_fails_after_all_retries(self) -> None: + """Test that generate_link returns None after exhausting all retry attempts.""" + game = cast( + "GameDict", + { + "id": 1, + "format": GameFormat.COMMANDER.value, + "seats": 4, + "guild_xid": 12345, + "channel_xid": 67890, + "bracket": GameBracket.NONE.value, + }, + ) + + mock_client = MagicMock(spec=httpx.AsyncClient) + # All calls fail + mock_client.post = AsyncMock(side_effect=Exception("Connection error")) + + mock_player_data = AsyncMock(return_value=[]) + + with ( + patch.object(convoke_module.settings, "CONVOKE_API_KEY", "test_key"), + patch.object(convoke_module, "passphrase", return_value=None), + patch("httpx.AsyncClient") as mock_client_class, + patch.object(convoke_module, "add_span_error"), + patch.object(convoke_module.services.games, "player_data", mock_player_data), + ): + mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client) + mock_client_class.return_value.__aexit__ = AsyncMock(return_value=None) + result = await generate_link(game) + + # All attempts fail, returns None + assert result == (None, None) + # Verify that the post method was called RETRY_ATTEMPTS times (2) + assert mock_client.post.call_count == convoke_module.RETRY_ATTEMPTS + + @pytest.mark.asyncio + async def test_generate_link_returns_none_when_data_is_empty(self) -> None: + """Test that generate_link returns None when fetch_convoke_link returns empty dict.""" + game = cast( + "GameDict", + { + "id": 1, + "format": GameFormat.COMMANDER.value, + "seats": 4, + "guild_xid": 12345, + "channel_xid": 67890, + "bracket": GameBracket.NONE.value, + }, + ) + + # Mock the response to return an empty dict (falsy) + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = {} + + mock_client = MagicMock(spec=httpx.AsyncClient) + mock_client.post = AsyncMock(return_value=mock_response) + + mock_player_data = AsyncMock(return_value=[]) + + with ( + patch.object(convoke_module.settings, "CONVOKE_API_KEY", "test_key"), + patch.object(convoke_module, "passphrase", return_value=None), + patch("httpx.AsyncClient") as mock_client_class, + patch.object(convoke_module.services.games, "player_data", mock_player_data), + ): + mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client) + mock_client_class.return_value.__aexit__ = AsyncMock(return_value=None) + result = await generate_link(game) + + # Empty dict is falsy, so returns None + assert result == (None, None) diff --git a/tests/services/test_games.py b/tests/services/test_games.py index af166aa1..28b23dc1 100644 --- a/tests/services/test_games.py +++ b/tests/services/test_games.py @@ -9,7 +9,7 @@ from spellbot.database import DatabaseSession from spellbot.enums import GameBracket, GameFormat, GameService -from spellbot.models import Channel, Game, GameStatus, Guild, Post, Queue, User +from spellbot.models import Channel, Game, GameStatus, Guild, Play, Post, Queue, User from spellbot.services import GamesService from tests.factories import ( BlockFactory, @@ -177,7 +177,56 @@ async def test_player_data(self, game: Game) -> None: user1 = UserFactory.create(game=game) user2 = UserFactory.create(game=game) games = GamesService() - assert await games.player_data(game.id) == [user1.to_dict(), user2.to_dict()] + result = await games.player_data(game.id) + # Pending game (no plays) returns empty pins + expected = [ + {"xid": user1.xid, "name": user1.name, "pin": None}, + {"xid": user2.xid, "name": user2.name, "pin": None}, + ] + assert result == expected + + async def test_player_data_with_pins(self) -> None: + # Create a guild with mythic track enabled + guild = GuildFactory.create(enable_mythic_track=True) + channel = ChannelFactory.create(guild=guild) + + # Create a started game with plays (which have pins) + game = GameFactory.create( + guild=guild, + channel=channel, + status=GameStatus.STARTED.value, + started_at=datetime.now(UTC), + ) + user1 = UserFactory.create(game=game) + user2 = UserFactory.create(game=game) + + # UserFactory.create(game=game) automatically creates Play records for started games + # Query for the Play records that were automatically created + play1 = ( + DatabaseSession.query(Play) + .filter( + Play.user_xid == user1.xid, + Play.game_id == game.id, + ) + .first() + ) + play2 = ( + DatabaseSession.query(Play) + .filter( + Play.user_xid == user2.xid, + Play.game_id == game.id, + ) + .first() + ) + + games = GamesService() + result = await games.player_data(game.id) + # Started game with mythic track returns pins + expected = [ + {"xid": user1.xid, "name": user1.name, "pin": play1.pin}, + {"xid": user2.xid, "name": user2.name, "pin": play2.pin}, + ] + assert result == expected async def test_player_data_when_game_not_found(self) -> None: games = GamesService()