diff --git a/bin/register_commands.py b/bin/register_commands.py index b31e273..d9c6044 100755 --- a/bin/register_commands.py +++ b/bin/register_commands.py @@ -180,6 +180,50 @@ def register(config: Dict): "required": False, } ] + }, + { + "name": "delete-guess", + "description": "Delete a member's guess on a game.", + "type": COMMAND_OPTION_TYPE_SUB_COMMAND, + "options": [ + { + "name": "game-id", + "description": "The ID of the Eternal Guess game", + "type": COMMAND_OPTION_TYPE_STRING, + "required": True, + }, + { + "name": "member", + "description": "The member whose guess to delete", + "type": COMMAND_OPTION_TYPE_USER, + "required": True, + }, + ] + }, + { + "name": "edit-guess", + "description": "Edit a member's guess on a game.", + "type": COMMAND_OPTION_TYPE_SUB_COMMAND, + "options": [ + { + "name": "game-id", + "description": "The ID of the Eternal Guess game", + "type": COMMAND_OPTION_TYPE_STRING, + "required": True, + }, + { + "name": "member", + "description": "The member whose guess to change", + "type": COMMAND_OPTION_TYPE_USER, + "required": True, + }, + { + "name": "guess", + "description": "The new guess", + "type": COMMAND_OPTION_TYPE_STRING, + "required": True, + }, + ] } ] }, diff --git a/discord_app/eternal_guesses/api/router.py b/discord_app/eternal_guesses/api/router.py index acd5245..8867a76 100644 --- a/discord_app/eternal_guesses/api/router.py +++ b/discord_app/eternal_guesses/api/router.py @@ -28,9 +28,13 @@ def __init__(self, add_management_channel_route: Route, remove_management_channel_route: Route, add_management_role_route: Route, - remove_management_role_route: Route): + remove_management_role_route: Route, + edit_guess_route: Route, + delete_guess_route: Route): self.route_handler = route_handler self.list_games_route = list_games_route + self.edit_guess_route = edit_guess_route + self.delete_guess_route = delete_guess_route self.close_game_route = close_game_route self.post_route = post_route self.create_route = create_route @@ -54,6 +58,10 @@ def _register_routes(self): permission=PermissionSet.MANAGEMENT)) self._register(RouteDefinition(self.list_games_route, 'manage', 'list-games', permission=PermissionSet.MANAGEMENT)) + self._register(RouteDefinition(self.edit_guess_route, 'manage', 'edit-guess', + permission=PermissionSet.MANAGEMENT)) + self._register(RouteDefinition(self.delete_guess_route, 'manage', 'delete-guess', + permission=PermissionSet.MANAGEMENT)) self._register(RouteDefinition(self.create_route, 'create', permission=PermissionSet.MANAGEMENT)) self._register(RouteDefinition(self.guild_info_route, 'admin', 'info', diff --git a/discord_app/eternal_guesses/model/error/unkonwn_event_exception.py b/discord_app/eternal_guesses/model/error/unkonwn_event_exception.py index bfbdcd0..e878e80 100644 --- a/discord_app/eternal_guesses/model/error/unkonwn_event_exception.py +++ b/discord_app/eternal_guesses/model/error/unkonwn_event_exception.py @@ -3,4 +3,4 @@ class UnknownEventException(Exception): def __init__(self, event: DiscordEvent): - super().__init__(f"could not handle event (type={event.type}") + super().__init__(f"could not handle event {event}") diff --git a/discord_app/eternal_guesses/routes/delete_guess.py b/discord_app/eternal_guesses/routes/delete_guess.py new file mode 100644 index 0000000..7ef2e5f --- /dev/null +++ b/discord_app/eternal_guesses/routes/delete_guess.py @@ -0,0 +1,40 @@ +from eternal_guesses.model.discord.discord_event import DiscordEvent +from eternal_guesses.model.discord.discord_response import DiscordResponse +from eternal_guesses.repositories.games_repository import GamesRepository +from eternal_guesses.routes.route import Route +from eternal_guesses.util.game_post_manager import GamePostManager +from eternal_guesses.util.message_provider import MessageProvider + + +class DeleteGuessRoute(Route): + def __init__(self, + games_repository: GamesRepository, + message_provider: MessageProvider, + game_post_manager: GamePostManager): + self.game_post_manager = game_post_manager + self.message_provider = message_provider + self.games_repository = games_repository + + async def call(self, event: DiscordEvent) -> DiscordResponse: + guild_id = event.guild_id + game_id = event.command.options['game-id'] + member = int(event.command.options['member']) + + game = self.games_repository.get(guild_id=guild_id, game_id=game_id) + + if game is None: + error_game_not_found = self.message_provider.error_game_not_found(game_id) + return DiscordResponse.ephemeral_channel_message(error_game_not_found) + + if member not in game.guesses: + error_guess_not_found = self.message_provider.error_guess_not_found(game_id, member) + return DiscordResponse.ephemeral_channel_message(error_guess_not_found) + + del game.guesses[member] + + self.games_repository.save(game) + + await self.game_post_manager.update(game) + + guess_deleted_message = self.message_provider.guess_deleted() + return DiscordResponse.ephemeral_channel_message(guess_deleted_message) diff --git a/discord_app/eternal_guesses/routes/edit_guess.py b/discord_app/eternal_guesses/routes/edit_guess.py new file mode 100644 index 0000000..13e7835 --- /dev/null +++ b/discord_app/eternal_guesses/routes/edit_guess.py @@ -0,0 +1,40 @@ +from eternal_guesses.model.discord.discord_event import DiscordEvent +from eternal_guesses.model.discord.discord_response import DiscordResponse +from eternal_guesses.repositories.games_repository import GamesRepository +from eternal_guesses.routes.route import Route +from eternal_guesses.util.game_post_manager import GamePostManager +from eternal_guesses.util.message_provider import MessageProvider + + +class EditGuessRoute(Route): + def __init__(self, + games_repository: GamesRepository, + message_provider: MessageProvider, + game_post_manager: GamePostManager): + self.games_repository = games_repository + self.message_provider = message_provider + self.game_post_manager = game_post_manager + + async def call(self, event: DiscordEvent) -> DiscordResponse: + guild_id = event.guild_id + game_id = event.command.options['game-id'] + member = int(event.command.options['member']) + guess = event.command.options['guess'] + + game = self.games_repository.get(guild_id=guild_id, game_id=game_id) + + if game is None: + game_not_found_error = self.message_provider.error_game_not_found(game_id) + return DiscordResponse.ephemeral_channel_message(game_not_found_error) + + if member not in game.guesses: + guess_not_found_error = self.message_provider.error_guess_not_found(game_id, member) + return DiscordResponse.ephemeral_channel_message(guess_not_found_error) + + game.guesses[member].guess = guess + self.games_repository.save(game) + + await self.game_post_manager.update(game) + + guess_edited_message = self.message_provider.guess_edited() + return DiscordResponse.ephemeral_channel_message(guess_edited_message) diff --git a/discord_app/eternal_guesses/routes/guess.py b/discord_app/eternal_guesses/routes/guess.py index 5293abc..55b7292 100644 --- a/discord_app/eternal_guesses/routes/guess.py +++ b/discord_app/eternal_guesses/routes/guess.py @@ -1,26 +1,22 @@ from datetime import datetime -import discord -from loguru import logger - -from eternal_guesses.model.data.game import Game from eternal_guesses.model.data.game_guess import GameGuess from eternal_guesses.model.discord.discord_event import DiscordEvent from eternal_guesses.model.discord.discord_response import DiscordResponse from eternal_guesses.repositories.games_repository import GamesRepository from eternal_guesses.routes.route import Route -from eternal_guesses.util.discord_messaging import DiscordMessaging +from eternal_guesses.util.game_post_manager import GamePostManager from eternal_guesses.util.message_provider import MessageProvider class GuessRoute(Route): def __init__(self, games_repository: GamesRepository, - discord_messaging: DiscordMessaging, - message_provider: MessageProvider): + message_provider: MessageProvider, + game_post_manager: GamePostManager): self.games_repository = games_repository - self.discord_messaging = discord_messaging self.message_provider = message_provider + self.game_post_manager = game_post_manager async def call(self, event: DiscordEvent) -> DiscordResponse: guild_id = event.guild_id @@ -51,23 +47,7 @@ async def call(self, event: DiscordEvent) -> DiscordResponse: game.guesses[int(user_id)] = game_guess self.games_repository.save(game) - await self._update_channel_messages(game) + await self.game_post_manager.update(game) guess_added_message = self.message_provider.guess_added(game_id, guess) return DiscordResponse.ephemeral_channel_message(content=guess_added_message) - - async def _update_channel_messages(self, game: Game): - logger.info(f"updating {len(game.channel_messages)} channel messages for {game.game_id}") - if game.channel_messages is not None: - new_embed = self.message_provider.game_post_embed(game) - for channel_message in game.channel_messages: - logger.debug(f"sending update to channel message, channel_id={channel_message.channel_id}, " - f"message_id={channel_message.message_id}, message='{new_embed}'") - - try: - await self.discord_messaging.update_channel_message(channel_message.channel_id, - channel_message.message_id, - embed=new_embed) - except discord.NotFound: - game.channel_messages.remove(channel_message) - self.games_repository.save(game) diff --git a/discord_app/eternal_guesses/util/game_post_manager.py b/discord_app/eternal_guesses/util/game_post_manager.py new file mode 100644 index 0000000..20b37cb --- /dev/null +++ b/discord_app/eternal_guesses/util/game_post_manager.py @@ -0,0 +1,44 @@ +from abc import ABC + +import discord +from loguru import logger + +from eternal_guesses.model.data.game import Game +from eternal_guesses.repositories.games_repository import GamesRepository +from eternal_guesses.util.discord_messaging import DiscordMessaging +from eternal_guesses.util.message_provider import MessageProvider + + +class GamePostManager(ABC): + async def post(self, game: Game): + raise NotImplementedError() + + async def update(self, game: Game): + raise NotImplementedError() + + +class GamePostManagerImpl(GamePostManager): + def __init__(self, games_repository: GamesRepository, message_provider: MessageProvider, + discord_messaging: DiscordMessaging): + self.discord_messaging = discord_messaging + self.games_repository = games_repository + self.message_provider = message_provider + + async def post(self, game: Game): + pass + + async def update(self, game: Game): + logger.info(f"updating {len(game.channel_messages)} channel messages for {game.game_id}") + if game.channel_messages is not None: + new_embed = self.message_provider.game_post_embed(game) + for channel_message in game.channel_messages: + logger.debug(f"sending update to channel message, channel_id={channel_message.channel_id}, " + f"message_id={channel_message.message_id}, message='{new_embed}'") + + try: + await self.discord_messaging.update_channel_message(channel_message.channel_id, + channel_message.message_id, + embed=new_embed) + except discord.NotFound: + game.channel_messages.remove(channel_message) + self.games_repository.save(game) diff --git a/discord_app/eternal_guesses/util/injector.py b/discord_app/eternal_guesses/util/injector.py index e06ad73..aa0ea93 100644 --- a/discord_app/eternal_guesses/util/injector.py +++ b/discord_app/eternal_guesses/util/injector.py @@ -9,6 +9,8 @@ from eternal_guesses.routes.add_management_role import AddManagementRoleRoute from eternal_guesses.routes.close_game import CloseGameRoute from eternal_guesses.routes.create import CreateRoute +from eternal_guesses.routes.delete_guess import DeleteGuessRoute +from eternal_guesses.routes.edit_guess import EditGuessRoute from eternal_guesses.routes.guess import GuessRoute from eternal_guesses.routes.guild_info import GuildInfoRoute from eternal_guesses.routes.list_games import ListGamesRoute @@ -17,6 +19,7 @@ from eternal_guesses.routes.remove_management_channel import RemoveManagementChannelRoute from eternal_guesses.routes.remove_management_role import RemoveManagementRoleRoute from eternal_guesses.util.discord_messaging import DiscordMessaging, DiscordMessagingImpl +from eternal_guesses.util.game_post_manager import GamePostManager, GamePostManagerImpl from eternal_guesses.util.message_provider import MessageProviderImpl, MessageProvider @@ -38,6 +41,12 @@ def _router() -> Router: message_provider = _message_provider() command_authorizer = _command_authorizer(configs_repository=configs_repository) + game_post_manager = GamePostManagerImpl( + games_repository=games_repository, + message_provider=message_provider, + discord_messaging=discord_messaging, + ) + route_handler = _route_handler( command_authorizer=command_authorizer, message_provider=message_provider, @@ -51,7 +60,7 @@ def _router() -> Router: ) guess_route = _guess_route( games_repository=games_repository, - discord_messaging=discord_messaging, + game_post_manager=game_post_manager, message_provider=message_provider ) close_game_route = _close_game_route( @@ -88,6 +97,16 @@ def _router() -> Router: configs_repository=configs_repository, message_provider=message_provider, ) + edit_guess_route = _edit_guess_route( + games_repository=games_repository, + message_provider=message_provider, + game_post_manager=game_post_manager, + ) + delete_guess_route = _delete_guess_route( + games_repository=games_repository, + message_provider=message_provider, + game_post_manager=game_post_manager, + ) return RouterImpl( route_handler=route_handler, @@ -102,6 +121,8 @@ def _router() -> Router: remove_management_channel_route=remove_management_channel_route, add_management_role_route=add_management_role_route, remove_management_role_route=remove_management_role_route, + edit_guess_route=edit_guess_route, + delete_guess_route=delete_guess_route, ) @@ -139,12 +160,12 @@ def _create_route(games_repository: GamesRepository, def _guess_route(games_repository: GamesRepository, - discord_messaging: DiscordMessaging, + game_post_manager: GamePostManager, message_provider: MessageProvider): return GuessRoute( games_repository=games_repository, - discord_messaging=discord_messaging, - message_provider=message_provider + message_provider=message_provider, + game_post_manager=game_post_manager, ) @@ -212,6 +233,30 @@ def _remove_management_role_route( ) +def _edit_guess_route( + games_repository: GamesRepository, + message_provider: MessageProvider, + game_post_manager: GamePostManager, +): + return EditGuessRoute( + games_repository=games_repository, + message_provider=message_provider, + game_post_manager=game_post_manager, + ) + + +def _delete_guess_route( + games_repository: GamesRepository, + message_provider: MessageProvider, + game_post_manager: GamePostManager, +): + return DeleteGuessRoute( + games_repository=games_repository, + message_provider=message_provider, + game_post_manager=game_post_manager, + ) + + def _games_repository() -> GamesRepository: return GamesRepositoryImpl() diff --git a/discord_app/eternal_guesses/util/message_provider.py b/discord_app/eternal_guesses/util/message_provider.py index 5ac42bb..c132b2c 100644 --- a/discord_app/eternal_guesses/util/message_provider.py +++ b/discord_app/eternal_guesses/util/message_provider.py @@ -15,6 +15,9 @@ def game_post_embed(self, game: Game) -> discord.Embed: def error_game_not_found(self, game_id: str) -> str: raise NotImplementedError() + def error_guess_not_found(self, game_id: str, member_id: int) -> str: + raise NotImplementedError() + def guess_added(self, game_id: str, guess: str) -> str: raise NotImplementedError() @@ -81,6 +84,12 @@ def game_post_created_message(self) -> str: def bot_missing_access(self) -> str: raise NotImplementedError() + def guess_edited(self) -> str: + raise NotImplementedError() + + def guess_deleted(self) -> str: + raise NotImplementedError() + class MessageProviderImpl(MessageProvider): def game_post_embed(self, game: Game) -> discord.Embed: @@ -118,6 +127,9 @@ def game_post_embed(self, game: Game) -> discord.Embed: def error_game_not_found(self, game_id: str) -> str: return f"No game found with id {game_id}." + def error_guess_not_found(self, game_id: str, member_id: str) -> str: + return f"No guess found by <@{member_id}> found for game {game_id}." + def guess_added(self, game_id: str, guess: str) -> str: return f"Your guess '{guess}' for game '{game_id}' has been registered." @@ -215,3 +227,9 @@ def game_created(self, game) -> str: def bot_missing_access(self) -> str: return "The bot is missing permissions to perform this command. Please inform a server admin." + + def guess_edited(self) -> str: + return "The guess has been edited." + + def guess_deleted(self) -> str: + return "The guess has been deleted." diff --git a/discord_app/tests/integration/helpers.py b/discord_app/tests/integration/helpers.py index 567cab9..7d6829a 100644 --- a/discord_app/tests/integration/helpers.py +++ b/discord_app/tests/integration/helpers.py @@ -329,6 +329,80 @@ def make_discord_manage_list_event(guild_id: int, channel_id: int, user_id: int return _make_event(event_body) +def make_discord_change_guess_event(guild_id: int, game_id: str, new_guess: str, member: int, channel_id: int, + user_id: int = DEFAULT_USER_ID, role_id: int = DEFAULT_ROLE_ID, + member_nickname: str = DEFAULT_MEMBER_NICK, + user_name: str = DEFAULT_USER_NAME) -> Dict: + event_body = _base_event_body(guild_id=guild_id, channel_id=channel_id, user_id=user_id, + member_nickname=member_nickname, user_name=user_name, role_id=role_id, is_admin=False) + event_body['data'] = { + "id": "2001", + "name": "eternal-guess", + "options": [ + { + "name": "manage", + "options": [ + { + "name": "edit-guess", + "options": [ + { + "name": "game-id", + "value": str(game_id) + }, + { + "name": "member", + "value": str(member) + }, + { + "name": "guess", + "value": str(new_guess) + }, + ] + } + ] + } + ] + + } + + return _make_event(event_body) + + +def make_discord_delete_guess_event(guild_id: int, game_id: str, member: int, channel_id: int, + user_id: int = DEFAULT_USER_ID, role_id: int = DEFAULT_ROLE_ID, + member_nickname: str = DEFAULT_MEMBER_NICK, + user_name: str = DEFAULT_USER_NAME) -> Dict: + event_body = _base_event_body(guild_id=guild_id, channel_id=channel_id, user_id=user_id, + member_nickname=member_nickname, user_name=user_name, role_id=role_id, is_admin=False) + event_body['data'] = { + "id": "2001", + "name": "eternal-guess", + "options": [ + { + "name": "manage", + "options": [ + { + "name": "delete-guess", + "options": [ + { + "name": "game-id", + "value": str(game_id) + }, + { + "name": "member", + "value": str(member) + } + ] + } + ] + } + ] + + } + + return _make_event(event_body) + + def _create_member(user_id: int, member_nickname: str, user_name: str, role_id: int, is_admin: bool) -> Dict: permissions = 0 if is_admin: diff --git a/discord_app/tests/integration/test_integration_create_and_vote.py b/discord_app/tests/integration/test_integration_create_and_vote.py index 96c5277..9f4ab9e 100644 --- a/discord_app/tests/integration/test_integration_create_and_vote.py +++ b/discord_app/tests/integration/test_integration_create_and_vote.py @@ -3,7 +3,8 @@ from eternal_guesses.repositories.configs_repository import ConfigsRepositoryImpl from eternal_guesses.repositories.games_repository import GamesRepositoryImpl from tests.integration.helpers import create_context, make_discord_create_event, \ - make_discord_guess_event, make_discord_manage_post_event + make_discord_guess_event, make_discord_manage_post_event, make_discord_change_guess_event, \ + make_discord_delete_guess_event def test_integration_full_flow(): @@ -59,6 +60,21 @@ def test_integration_full_flow(): assert game.guesses[another_user_id].guess == guess assert len(game.guesses) == 2 + # Change a member's guess as a management user + new_guess = "5600" + change_guess(guild_id=guild_id, game_id=game_id, guessing_user_id=user_id, new_guess=new_guess, + channel_id=management_channel) + + game = games_repository.get(guild_id, game_id) + assert game.guesses[user_id].guess == new_guess + + # Delete a member's guess as a management user + delete_guess(guild_id=guild_id, game_id=game_id, guessing_user_id=user_id, channel_id=management_channel) + + game = games_repository.get(guild_id, game_id) + assert user_id not in game.guesses + assert another_user_id in game.guesses + def guess_on_game(guild_id: int, game_id: str, guess: str, user_id: int): response = handler.handle_lambda( @@ -94,3 +110,25 @@ def post_channel_message(guild_id: int, game_id: str, channel_id: int): ) assert response['statusCode'] == 200 + + +def change_guess(guild_id: int, game_id: str, new_guess: str, guessing_user_id: int, channel_id: int): + response = handler.handle_lambda( + make_discord_change_guess_event( + guild_id=guild_id, game_id=game_id, member=guessing_user_id, new_guess=new_guess, channel_id=channel_id + ), + create_context() + ) + + assert response['statusCode'] == 200 + + +def delete_guess(guild_id: int, game_id: str, guessing_user_id: int, channel_id: int): + response = handler.handle_lambda( + make_discord_delete_guess_event( + guild_id=guild_id, game_id=game_id, member=guessing_user_id, channel_id=channel_id + ), + create_context() + ) + + assert response['statusCode'] == 200 diff --git a/discord_app/tests/unit/api/test_router.py b/discord_app/tests/unit/api/test_router.py index f406fde..1d71304 100644 --- a/discord_app/tests/unit/api/test_router.py +++ b/discord_app/tests/unit/api/test_router.py @@ -63,6 +63,8 @@ def _router( remove_management_channel_route=None, add_management_role_route=None, remove_management_role_route=None, + edit_guess_route=None, + delete_guess_route=None, ): return RouterImpl( route_handler=route_handler, @@ -77,4 +79,6 @@ def _router( remove_management_channel_route=remove_management_channel_route or Route(), add_management_role_route=add_management_role_route or Route(), remove_management_role_route=remove_management_role_route or Route(), + edit_guess_route=edit_guess_route or Route(), + delete_guess_route=delete_guess_route or Route(), ) diff --git a/discord_app/tests/unit/routes/test_delete_guess.py b/discord_app/tests/unit/routes/test_delete_guess.py new file mode 100644 index 0000000..e5b05d9 --- /dev/null +++ b/discord_app/tests/unit/routes/test_delete_guess.py @@ -0,0 +1,182 @@ +import typing +from unittest.mock import MagicMock, AsyncMock + +import pytest + +from eternal_guesses.model.data.game import Game +from eternal_guesses.model.data.game_guess import GameGuess +from eternal_guesses.model.discord.discord_command import DiscordCommand +from eternal_guesses.model.discord.discord_event import DiscordEvent +from eternal_guesses.model.discord.discord_member import DiscordMember +from eternal_guesses.routes.delete_guess import DeleteGuessRoute +from eternal_guesses.util.game_post_manager import GamePostManager +from eternal_guesses.util.message_provider import MessageProvider +from tests.fakes import FakeGamesRepository + +pytestmark = pytest.mark.asyncio + + +async def test_delete_guess(): + # Given + guild_id = 10 + game_id = "game-1" + guessing_player_id = 100 + + guess_deleted_message = "Guess has been deleted." + message_provider = MagicMock(MessageProvider) + message_provider.guess_deleted.return_value = guess_deleted_message + + game = Game( + guild_id=guild_id, + game_id=game_id, + guesses={ + guessing_player_id: GameGuess( + user_id=guessing_player_id, + guess="", + ) + } + ) + games_repository = FakeGamesRepository(games=[game]) + + game_post_manager = AsyncMock(GamePostManager) + + route = _route( + games_repository=games_repository, + message_provider=message_provider, + game_post_manager=game_post_manager, + ) + + # When + event = _make_event( + guild_id=guild_id, + options={ + 'game-id': game_id, + 'member': str(guessing_player_id), + } + ) + response = await route.call(event) + + # Then + updated_game = games_repository.get(guild_id, game_id) + assert guessing_player_id not in updated_game.guesses + + game_post_manager.update.assert_called_with(game) + + assert response.is_ephemeral + assert response.content == guess_deleted_message + + +async def test_delete_guess_does_not_exist(): + # Given + guild_id = 10 + game_id = "game-1" + delete_member_id = 200 + + guess_not_found_message = "No guess found." + message_provider = MagicMock(MessageProvider) + message_provider.error_guess_not_found.return_value = guess_not_found_message + + game = Game( + guild_id=guild_id, + game_id=game_id, + guesses={ + -1: GameGuess( + user_id=-1, + guess="", + ) + } + ) + games_repository = FakeGamesRepository(games=[game]) + + game_post_manager = AsyncMock(GamePostManager) + + route = _route( + games_repository=games_repository, + message_provider=message_provider, + game_post_manager=game_post_manager, + ) + + # When + event = _make_event( + guild_id=guild_id, + options={ + 'game-id': game_id, + 'member': str(delete_member_id), + } + ) + response = await route.call(event) + + # Then + game_post_manager.update.assert_not_called() + + assert response.is_ephemeral + assert response.content == guess_not_found_message + + +async def test_delete_guess_game_does_not_exist(): + # Given + guild_id = 10 + game_id = "game-1" + guessing_player_id = 100 + + game_not_found_message = "No game found." + message_provider = MagicMock(MessageProvider) + message_provider.error_game_not_found.return_value = game_not_found_message + + games_repository = FakeGamesRepository(games=[]) + + route = _route( + games_repository=games_repository, + message_provider=message_provider, + ) + + # When + event = _make_event( + guild_id=guild_id, + options={ + 'game-id': game_id, + 'member': str(guessing_player_id), + } + ) + response = await route.call(event) + + # Then + assert response.is_ephemeral + assert response.content == game_not_found_message + + +def _make_event(guild_id: int = -1, options: typing.Dict = None, discord_member: DiscordMember = None, + channel_id: int = -1): + if options is None: + options = {} + + if discord_member is None: + discord_member = DiscordMember() + + return DiscordEvent( + guild_id=guild_id, + channel_id=channel_id, + command=DiscordCommand( + command_name="manage", + subcommand_name="delete-guess", + options=options, + ), + member=discord_member + ) + + +def _route(games_repository=None, message_provider=None, game_post_manager=None): + if games_repository is None: + games_repository = FakeGamesRepository() + + if message_provider is None: + message_provider = MagicMock(MessageProvider) + + if game_post_manager is None: + game_post_manager = AsyncMock(GamePostManager) + + return DeleteGuessRoute( + games_repository=games_repository, + message_provider=message_provider, + game_post_manager=game_post_manager, + ) diff --git a/discord_app/tests/unit/routes/test_edit_guess.py b/discord_app/tests/unit/routes/test_edit_guess.py new file mode 100644 index 0000000..79886ba --- /dev/null +++ b/discord_app/tests/unit/routes/test_edit_guess.py @@ -0,0 +1,187 @@ +import typing +from unittest.mock import MagicMock, AsyncMock + +import pytest + +from eternal_guesses.model.data.game import Game +from eternal_guesses.model.data.game_guess import GameGuess +from eternal_guesses.model.discord.discord_command import DiscordCommand +from eternal_guesses.model.discord.discord_event import DiscordEvent +from eternal_guesses.model.discord.discord_member import DiscordMember +from eternal_guesses.routes.edit_guess import EditGuessRoute +from eternal_guesses.util.game_post_manager import GamePostManager +from eternal_guesses.util.message_provider import MessageProvider +from tests.fakes import FakeGamesRepository + +pytestmark = pytest.mark.asyncio + + +async def test_edit_guess(): + # Given + guild_id = 10 + game_id = "game-1" + guessing_player_id = 100 + old_guess = "" + + guess_edited_message = "Guess has been edited." + message_provider = MagicMock(MessageProvider) + message_provider.guess_edited.return_value = guess_edited_message + + game = Game( + guild_id=guild_id, + game_id=game_id, + guesses={ + guessing_player_id: GameGuess( + user_id=guessing_player_id, + guess=old_guess, + ) + } + ) + games_repository = FakeGamesRepository(games=[game]) + + game_post_manager = AsyncMock(GamePostManager) + + route = _route( + games_repository=games_repository, + message_provider=message_provider, + game_post_manager=game_post_manager, + ) + + # When + new_guess = "500" + event = _make_event( + guild_id=guild_id, + options={ + 'game-id': game_id, + 'member': str(guessing_player_id), + 'guess': new_guess, + } + ) + response = await route.call(event) + + # Then + updated_game = games_repository.get(guild_id, game_id) + assert updated_game.guesses[guessing_player_id].guess == new_guess + + game_post_manager.update.assert_called_with(game) + + assert response.is_ephemeral + assert response.content == guess_edited_message + + +async def test_edit_guess_does_not_exist(): + # Given + guild_id = 10 + game_id = "game-1" + delete_member_id = 200 + + guess_not_found_message = "No guess found." + message_provider = MagicMock(MessageProvider) + message_provider.error_guess_not_found.return_value = guess_not_found_message + + game = Game( + guild_id=guild_id, + game_id=game_id, + guesses={ + -1: GameGuess( + user_id=-1, + guess="", + ) + } + ) + games_repository = FakeGamesRepository(games=[game]) + + game_post_manager = AsyncMock(GamePostManager) + + route = _route( + games_repository=games_repository, + message_provider=message_provider, + game_post_manager=game_post_manager, + ) + + # When + event = _make_event( + guild_id=guild_id, + options={ + 'game-id': game_id, + 'member': str(delete_member_id), + 'guess': "some new guess", + } + ) + response = await route.call(event) + + # Then + game_post_manager.update.assert_not_called() + + assert response.is_ephemeral + assert response.content == guess_not_found_message + + +async def test_edit_guess_game_does_not_exist(): + # Given + guild_id = 10 + game_id = "game-1" + guessing_player_id = 100 + + game_not_found_message = "No game found." + message_provider = MagicMock(MessageProvider) + message_provider.error_game_not_found.return_value = game_not_found_message + + games_repository = FakeGamesRepository(games=[]) + + route = _route( + games_repository=games_repository, + message_provider=message_provider, + ) + + # When + event = _make_event( + guild_id=guild_id, + options={ + 'game-id': game_id, + 'member': str(guessing_player_id), + 'guess': "some new guess", + } + ) + response = await route.call(event) + + # Then + assert response.is_ephemeral + assert response.content == game_not_found_message + + +def _make_event(guild_id: int = -1, options: typing.Dict = None, discord_member: DiscordMember = None, + channel_id: int = -1): + if options is None: + options = {} + + if discord_member is None: + discord_member = DiscordMember() + + return DiscordEvent( + guild_id=guild_id, + channel_id=channel_id, + command=DiscordCommand( + command_name="manage", + subcommand_name="edit-guess", + options=options, + ), + member=discord_member + ) + + +def _route(games_repository=None, message_provider=None, game_post_manager=None): + if games_repository is None: + games_repository = FakeGamesRepository() + + if message_provider is None: + message_provider = MagicMock(MessageProvider) + + if game_post_manager is None: + game_post_manager = AsyncMock(GamePostManager) + + return EditGuessRoute( + games_repository=games_repository, + message_provider=message_provider, + game_post_manager=game_post_manager, + ) diff --git a/discord_app/tests/unit/routes/test_guess.py b/discord_app/tests/unit/routes/test_guess.py index 07f7736..f1a2bad 100644 --- a/discord_app/tests/unit/routes/test_guess.py +++ b/discord_app/tests/unit/routes/test_guess.py @@ -1,10 +1,9 @@ from datetime import datetime -from unittest.mock import patch, MagicMock +from unittest.mock import patch, MagicMock, AsyncMock import discord import pytest -from eternal_guesses.model.data.channel_message import ChannelMessage from eternal_guesses.model.data.game import Game from eternal_guesses.model.data.game_guess import GameGuess from eternal_guesses.model.discord.discord_event import DiscordCommand, DiscordEvent @@ -12,9 +11,9 @@ from eternal_guesses.repositories.games_repository import GamesRepository from eternal_guesses.routes import guess from eternal_guesses.routes.guess import GuessRoute -from eternal_guesses.util.discord_messaging import DiscordMessaging +from eternal_guesses.util.game_post_manager import GamePostManager from eternal_guesses.util.message_provider import MessageProvider -from tests.fakes import FakeDiscordMessaging, FakeGamesRepository, FakeMessageProvider +from tests.fakes import FakeGamesRepository, FakeMessageProvider pytestmark = pytest.mark.asyncio @@ -42,11 +41,9 @@ async def test_guess_updates_game_guesses(mock_datetime): ) fake_games_repository = FakeGamesRepository([existing_game]) - fake_discord_messaging = FakeDiscordMessaging() guess_route = _route( games_repository=fake_games_repository, - discord_messaging=fake_discord_messaging, ) # When we make a guess @@ -76,8 +73,6 @@ async def test_guess_updates_channel_messages(): # Given guild_id = 1001 user_id = 12000 - game_post_1 = ChannelMessage(channel_id=1000, message_id=5000) - game_post_2 = ChannelMessage(channel_id=1005, message_id=5005) post_embed = discord.Embed() message_provider = MagicMock(MessageProvider) @@ -85,17 +80,16 @@ async def test_guess_updates_channel_messages(): game = Game( guild_id=guild_id, - game_id='game-id', - channel_messages=[game_post_1, game_post_2] + game_id="game-1", ) games_repository = FakeGamesRepository([game]) - discord_messaging = FakeDiscordMessaging() + game_post_manager = MagicMock(GamePostManager) guess_route = _route( games_repository=games_repository, - discord_messaging=discord_messaging, - message_provider=message_provider + message_provider=message_provider, + game_post_manager=game_post_manager, ) # When @@ -103,53 +97,7 @@ async def test_guess_updates_channel_messages(): await guess_route.call(event) # Then - update_channel_message_calls = discord_messaging.updated_channel_messages - assert len(update_channel_message_calls) == 2 - assert { - 'channel_id': game_post_1.channel_id, - 'message_id': game_post_1.message_id, - 'embed': post_embed, - } in update_channel_message_calls - assert { - 'channel_id': game_post_2.channel_id, - 'message_id': game_post_2.message_id, - 'embed': post_embed, - } in update_channel_message_calls - - -async def test_guess_channel_message_gone_silently_fails(): - # Given - guild_id = 1001 - user_id = 12000 - game_id = 'game-id' - deleted_channel_message = ChannelMessage(channel_id=1000, message_id=5000) - other_channel_message = ChannelMessage(channel_id=1000, message_id=5001) - - # We have a game with two channel messages - game = Game( - guild_id=guild_id, - game_id=game_id, - channel_messages=[deleted_channel_message, other_channel_message] - ) - games_repository = FakeGamesRepository([game]) - - # And we will get a 'not found' error after updating one of those messages - discord_messaging = FakeDiscordMessaging() - discord_messaging.raise_404_on_update_of_message(deleted_channel_message.message_id) - - guess_route = _route( - discord_messaging=discord_messaging, - games_repository=games_repository - ) - - # When we trigger an update - event = _create_guess_event(guild_id, game.game_id, user_id, 'nickname') - await guess_route.call(event) - - # Then that channel message is removed from the game - updated_game = games_repository.get(guild_id=guild_id, game_id=game_id) - assert len(updated_game.channel_messages) == 1 - assert updated_game.channel_messages[0].message_id == other_channel_message.message_id + game_post_manager.update.assert_called_with(game) async def test_guess_replies_with_ephemeral_message(): @@ -247,9 +195,8 @@ async def test_guess_duplicate_guess(): message_provider = MagicMock(MessageProvider) message_provider.error_duplicate_guess.return_value = duplicate_guess_message - guess_route = GuessRoute( + guess_route = _route( games_repository=games_repository, - discord_messaging=FakeDiscordMessaging(), message_provider=message_provider ) @@ -297,9 +244,8 @@ async def test_guess_closed_game(): message_provider = MagicMock(MessageProvider) message_provider.error_guess_on_closed_game.return_value = duplicate_guess_message - route = GuessRoute( + route = _route( games_repository=games_repository, - discord_messaging=FakeDiscordMessaging(), message_provider=message_provider, ) @@ -346,12 +292,9 @@ def _create_guess_event(guild_id: int, game_id: str, user_id: int = -1, user_nic return event -def _route(discord_messaging: DiscordMessaging = None, - games_repository: GamesRepository = None, - message_provider: MessageProvider = None): - - if discord_messaging is None: - discord_messaging = FakeDiscordMessaging() +def _route(games_repository: GamesRepository = None, + message_provider: MessageProvider = None, + game_post_manager: GamePostManager = None): if games_repository is None: games_repository = FakeGamesRepository([]) @@ -359,9 +302,12 @@ def _route(discord_messaging: DiscordMessaging = None, if message_provider is None: message_provider = FakeMessageProvider() + if game_post_manager is None: + game_post_manager = AsyncMock(GamePostManager, autospec=True) + guess_route = GuessRoute( games_repository=games_repository, - discord_messaging=discord_messaging, message_provider=message_provider, + game_post_manager=game_post_manager, ) return guess_route diff --git a/discord_app/tests/unit/util/test_game_post_manager.py b/discord_app/tests/unit/util/test_game_post_manager.py new file mode 100644 index 0000000..562836a --- /dev/null +++ b/discord_app/tests/unit/util/test_game_post_manager.py @@ -0,0 +1,103 @@ +from unittest.mock import MagicMock + +import discord +import pytest + +from eternal_guesses.model.data.channel_message import ChannelMessage +from eternal_guesses.model.data.game import Game +from eternal_guesses.util.game_post_manager import GamePostManagerImpl +from eternal_guesses.util.message_provider import MessageProvider +from tests.fakes import FakeGamesRepository, FakeDiscordMessaging + +pytestmark = pytest.mark.asyncio + + +async def test_update_post(): + # Given + game_post_1 = ChannelMessage(channel_id=1000, message_id=5000) + game_post_2 = ChannelMessage(channel_id=1005, message_id=5005) + + post_embed = discord.Embed() + message_provider = MagicMock(MessageProvider) + message_provider.game_post_embed.return_value = post_embed + + game = Game( + game_id='game-id', + channel_messages=[game_post_1, game_post_2] + ) + + games_repository = FakeGamesRepository([game]) + discord_messaging = FakeDiscordMessaging() + + game_post_manager = _game_post_manager( + games_repository=games_repository, + discord_messaging=discord_messaging, + message_provider=message_provider, + ) + + # When + await game_post_manager.update(game) + + # Then + update_channel_message_calls = discord_messaging.updated_channel_messages + assert len(update_channel_message_calls) == 2 + assert { + 'channel_id': game_post_1.channel_id, + 'message_id': game_post_1.message_id, + 'embed': post_embed, + } in update_channel_message_calls + assert { + 'channel_id': game_post_2.channel_id, + 'message_id': game_post_2.message_id, + 'embed': post_embed, + } in update_channel_message_calls + + +async def test_guess_channel_message_gone_silently_fails(): + # Given + guild_id = 1001 + game_id = 'game-id' + deleted_channel_message = ChannelMessage(channel_id=1000, message_id=5000) + other_channel_message = ChannelMessage(channel_id=1000, message_id=5001) + + # We have a game with two channel messages + game = Game( + guild_id=guild_id, + game_id=game_id, + channel_messages=[deleted_channel_message, other_channel_message] + ) + games_repository = FakeGamesRepository([game]) + + # And we will get a 'not found' error after updating one of those messages + discord_messaging = FakeDiscordMessaging() + discord_messaging.raise_404_on_update_of_message(deleted_channel_message.message_id) + + game_post_manager = _game_post_manager( + games_repository=games_repository, + discord_messaging=discord_messaging, + ) + + # When + await game_post_manager.update(game) + + # Then that channel message is removed from the game + updated_game = games_repository.get(guild_id=guild_id, game_id=game_id) + assert len(updated_game.channel_messages) == 1 + assert updated_game.channel_messages[0].message_id == other_channel_message.message_id + + +def _game_post_manager(games_repository=None, message_provider=None, discord_messaging=None): + if games_repository is None: + games_repository = FakeGamesRepository() + + if message_provider is None: + message_provider = MagicMock(MessageProvider) + + if discord_messaging is None: + discord_messaging = FakeDiscordMessaging() + + return GamePostManagerImpl( + games_repository=games_repository, + message_provider=message_provider, + discord_messaging=discord_messaging, + )