"""Test IdleRPG character operations. SPDX-FileCopyrightText: © 2024 Brian S. Stephan SPDX-License-Identifier: AGPL-3.0-or-later """ import datetime import logging import re import unittest.mock as mock from django.test import TestCase from django.utils import timezone from ircbot.models import IrcChannel, IrcServer from idlerpg.ircplugin import IdleRPG from idlerpg.models import Character, Game class IrcPluginTest(TestCase): """Test the IRC integration.""" fixtures = ['tests/fixtures/simple_character.json'] def setUp(self): """Create common objects.""" self.mock_bot = mock.MagicMock() self.mock_connection = mock.MagicMock() self.mock_connection.get_nickname.return_value = 'test_bot' self.mock_connection.server_config = IrcServer.objects.get(pk=1) self.plugin = IdleRPG(self.mock_bot, self.mock_connection, mock.MagicMock()) self.game = Game.objects.get(pk=1) def test_start_stop(self): """Test that handlers are registered, roughly, as expected.""" self.plugin.start() self.plugin.stop() assert self.mock_connection.add_global_handler.call_count == 8 assert (self.mock_connection.add_global_handler.call_count == self.mock_connection.remove_global_handler.call_count) assert self.mock_connection.reactor.add_global_regex_handler.call_count == 5 assert (self.mock_connection.reactor.add_global_regex_handler.call_count == self.mock_connection.reactor.remove_global_regex_handler.call_count) def test_kick_penalty(self): """Test that if a character is kicked from the game channel, they get penalized.""" mock_event = mock.MagicMock() mock_event.source = 'bss!bss@test_kick_penalty' mock_event.target = '#test' mock_event.recursing = False # make a character so as to not disturb other tests test_char = Character.objects.register('testkickpen', self.game, 'test', 'bss!bss@test_kick_penalty', 'tester') with mock.patch('idlerpg.models.Character.penalize', return_value=5) as mock_penalize: with mock.patch('idlerpg.models.Character.log_out') as mock_log_out: self.plugin.handle_kick_penalty(self.mock_connection, mock_event) mock_penalize.assert_called_with(250, "getting kicked from the game channel") mock_log_out.assert_called() test_char = Character.objects.get(name='testkickpen') assert test_char.time_penalized_kicked == 5 test_char.delete() def test_generic_penalty_no_penalty_for_unknown(self): """Test that a kick from an unknown hostmask is ignored.""" mock_event = mock.MagicMock() mock_event.source = 'bss!bss@test_kick_penalty' mock_event.target = '#test' mock_event.recursing = False with mock.patch('idlerpg.models.Character.penalize', return_value=5) as mock_penalize: with mock.patch('idlerpg.models.Character.log_out') as mock_log_out: self.plugin.handle_kick_penalty(self.mock_connection, mock_event) mock_penalize.assert_not_called() mock_log_out.assert_not_called() def test_nick_penalty(self): """Test that if a user changes their nick, they get penalized.""" mock_event = mock.MagicMock() mock_event.source = 'bss!bss@test_nick_penalty' mock_event.target = '#test' mock_event.recursing = False # make a character so as to not disturb other tests test_char = Character.objects.register('testnickpen', self.game, 'test', 'bss!bss@test_nick_penalty', 'tester') with mock.patch('idlerpg.models.Character.penalize', return_value=5) as mock_penalize: with mock.patch('idlerpg.models.Character.log_out') as mock_log_out: self.plugin.handle_nick_penalty(self.mock_connection, mock_event) mock_penalize.assert_called_with(30, "changing their nick") mock_log_out.assert_called() test_char = Character.objects.get(name='testnickpen') assert test_char.time_penalized_nick_change == 5 test_char.delete() def test_part_penalty(self): """Test that if a character parts from the game channel, they get penalized.""" mock_event = mock.MagicMock() mock_event.source = 'bss!bss@test_part_penalty' mock_event.target = '#test' mock_event.recursing = False # make a character so as to not disturb other tests test_char = Character.objects.register('testpartpen', self.game, 'test', 'bss!bss@test_part_penalty', 'tester') with mock.patch('idlerpg.models.Character.penalize', return_value=5) as mock_penalize: with mock.patch('idlerpg.models.Character.log_out') as mock_log_out: self.plugin.handle_part_penalty(self.mock_connection, mock_event) mock_penalize.assert_called_with(200, "parting the game channel") mock_log_out.assert_called() test_char = Character.objects.get(name='testpartpen') assert test_char.time_penalized_part == 5 test_char.delete() def test_pubmsg_penalty(self): """Test that if a character speaks in the game channel, they get penalized.""" mock_event = mock.MagicMock() mock_event.arguments = ['this is a test message'] mock_event.source = 'bss!bss@test_pubmsg_penalty' mock_event.target = '#test' mock_event.recursing = False # make a character so as to not disturb other tests test_char = Character.objects.register('testpubmsgpen', self.game, 'test', 'bss!bss@test_pubmsg_penalty', 'tester') with mock.patch('idlerpg.models.Character.penalize', return_value=5) as mock_penalize: self.plugin.handle_message_penalty(self.mock_connection, mock_event) # 22 is the len of the message mock_penalize.assert_called_with(22, "sending a privmsg to the game channel") test_char = Character.objects.get(name='testpubmsgpen') assert test_char.time_penalized_privmsg == 5 test_char.delete() def test_pubmsg_no_penalty_for_unknown(self): """Test that a speakerwith an unknown hostmask is ignored.""" mock_event = mock.MagicMock() mock_event.arguments = ['this is a test message'] mock_event.source = 'bss!bss@some_user' mock_event.target = '#test' mock_event.recursing = False with mock.patch('idlerpg.models.Character.penalize', return_value=5) as mock_penalize: self.plugin.handle_message_penalty(self.mock_connection, mock_event) mock_penalize.assert_not_called() def test_join(self): """Test that joiners update the seen hostmasks.""" mock_event = mock.MagicMock() mock_event.arguments = ['this is a test message'] mock_event.source = 'test!test@test_join' mock_event.target = '#test' mock_event.recursing = False assert 'test!test@test_join' not in self.plugin.seen_hostmasks self.plugin.handle_join(self.mock_connection, mock_event) assert 'test!test@test_join' in self.plugin.seen_hostmasks def test_quit_penalty(self): """Test that if a character quits from the game channel, they get penalized.""" mock_event = mock.MagicMock() mock_event.source = 'bss!bss@test_quit_penalty' mock_event.target = '#test' mock_event.recursing = False # make a character so as to not disturb other tests test_char = Character.objects.register('testquitpen', self.game, 'test', 'bss!bss@test_quit_penalty', 'tester') with mock.patch('idlerpg.models.Character.penalize', return_value=5) as mock_penalize: with mock.patch('idlerpg.models.Character.log_out') as mock_log_out: self.plugin.handle_quit_penalty(self.mock_connection, mock_event) mock_penalize.assert_called_with(20, "quitting IRC") mock_log_out.assert_called() test_char = Character.objects.get(name='testquitpen') assert test_char.time_penalized_quit == 5 test_char.delete() def test_quit_penalty_even_if_logged_out(self): """Test that the penalty applies even if the character is already logged out.""" mock_event = mock.MagicMock() mock_event.source = 'bss!bss@test_quit_penalty' mock_event.target = '#test' mock_event.recursing = False # make a character so as to not disturb other tests test_char = Character.objects.register('testquitpen', self.game, 'test', 'bss!bss@test_quit_penalty', 'tester') with mock.patch('idlerpg.models.Character.penalize', return_value=5) as mock_penalize: with mock.patch('idlerpg.models.Character.log_out', side_effect=ValueError) as mock_log_out: self.plugin.handle_quit_penalty(self.mock_connection, mock_event) mock_penalize.assert_called_with(20, "quitting IRC") mock_log_out.assert_called() test_char = Character.objects.get(name='testquitpen') assert test_char.time_penalized_quit == 5 test_char.delete() def test_whoreply(self): """When joining, we do a /WHO to see who is online.""" mock_event = mock.MagicMock() mock_event.arguments = ['', 'bss', 'bss', '', 'bss2'] assert 'bss2!bss@bss' not in self.plugin.seen_hostmasks char = Character.objects.get(name='bss2', game=self.game) assert char.status == Character.CHARACTER_STATUS_OFFLINE self.plugin.handle_whoreply_response(self.mock_connection, mock_event) assert 'bss2!bss@bss' in self.plugin.seen_hostmasks char = Character.objects.get(name='bss2', game=self.game) assert char.status == Character.CHARACTER_STATUS_LOGGED_IN # clean up char.log_out() def test_login_character_not_found(self): """Test the LOGIN command sent to the bot.""" mock_event = mock.MagicMock() mock_event.source = 'bss!bss@test_login' mock_event.target = '#test' mock_event.recursing = False match = re.match(IdleRPG.LOGIN_COMMAND_PATTERN, 'LOGIN nope bss') self.plugin.handle_login(self.mock_connection, mock_event, match) self.mock_bot.reply.assert_called_once_with( mock_event, "The requested character does not exist, or has been disabled, or your password does not match." ) def test_login_bad_password(self): """Test the LOGIN command sent to the bot.""" mock_event = mock.MagicMock() mock_event.source = 'bss!bss@test_login' mock_event.target = '#test' mock_event.recursing = False match = re.match(IdleRPG.LOGIN_COMMAND_PATTERN, 'LOGIN bss nope') self.plugin.handle_login(self.mock_connection, mock_event, match) self.mock_bot.reply.assert_called_once_with( mock_event, "The requested character does not exist, or has been disabled, or your password does not match." ) def test_login_not_seen(self): """Test the LOGIN command sent to the bot.""" mock_event = mock.MagicMock() mock_event.source = 'bss!bss@test_login' mock_event.target = '#test' mock_event.recursing = False match = re.match(IdleRPG.LOGIN_COMMAND_PATTERN, 'LOGIN bss bss') self.plugin.handle_login(self.mock_connection, mock_event, match) self.mock_bot.reply.assert_called_once_with(mock_event, "Please join #test before logging in.") def test_login_not_logged_off(self): """Test the LOGIN command sent to the bot.""" mock_event = mock.MagicMock() mock_event.source = 'bss!bss@test_login' mock_event.target = '#test' mock_event.recursing = False self.plugin.seen_hostmasks.add('bss!bss@test_login') match = re.match(IdleRPG.LOGIN_COMMAND_PATTERN, 'LOGIN bss bss') self.plugin.handle_login(self.mock_connection, mock_event, match) self.mock_bot.reply.assert_called_once_with( mock_event, "Cannot log in bss, either they already are, or they are in a state that cannot be modified." ) self.plugin.seen_hostmasks.remove('bss!bss@test_login') def test_login(self): """Check that the LOGIN command actually sets the character as logged in.""" mock_event = mock.MagicMock() mock_event.source = 'bss!bss@bss_login' mock_event.recursing = False game = Game.objects.get(pk=1) test_char = Character.objects.register('test_login', game, 'test', 'bss!bss@bss_login', 'tester') test_char.log_out() test_char.next_level = datetime.datetime.fromisoformat('2024-05-17 17:00:00-00:00') test_char.last_login = test_char.next_level test_char.save() self.plugin.seen_hostmasks.add('bss!bss@bss_login') # manipulate login so it doesn't add time to next_level and so we have a consistent # value to test against below with mock.patch('django.utils.timezone.now', return_value=test_char.next_level): match = re.match(IdleRPG.LOGIN_COMMAND_PATTERN, 'LOGIN test_login test') self.plugin.handle_login(self.mock_connection, mock_event, match) # refetch refetch = Character.objects.get(name='test_login') # should have been penalized assert refetch.status == Character.CHARACTER_STATUS_LOGGED_IN # the ordering of this surprises me... keep an eye on it self.mock_bot.reply.assert_has_calls([ mock.call(None, "test_login, the level 0 tester, is now online from nickname bss. " "Next level at 2024-05-17 17:00:00 UTC.", explicit_target='#test'), mock.call(mock_event, "test_login, the level 0 tester, has been successfully logged in."), ]) def test_logout_character_not_found(self): """Test the LOGOUT command sent to the bot.""" mock_event = mock.MagicMock() mock_event.source = 'bss!bss@test_login' mock_event.recursing = False match = re.match(IdleRPG.LOGOUT_COMMAND_PATTERN, 'LOGOUT') self.plugin.handle_logout(self.mock_connection, mock_event, match) self.mock_bot.reply.assert_called_once_with(mock_event, "You do not have a character in a running game.") def test_logout_character_not_logged_in(self): """Test the LOGOUT command sent to the bot.""" mock_event = mock.MagicMock() mock_event.source = 'bss2!bss@bss' mock_event.recursing = False match = re.match(IdleRPG.LOGOUT_COMMAND_PATTERN, 'LOGOUT') self.plugin.handle_logout(self.mock_connection, mock_event, match) self.mock_bot.reply.assert_called_once_with( mock_event, "Cannot log out bss2, either they already are, or they are in a state that cannot be modified." ) def test_logout(self): """Check that the LOGOUT command actually sets the character offline.""" mock_event = mock.MagicMock() mock_event.source = 'bss!bss@bss_logout' mock_event.recursing = False game = Game.objects.get(pk=1) test_char = Character.objects.register('test_logout', game, 'test', 'bss!bss@bss_logout', 'tester') match = re.match(IdleRPG.LOGOUT_COMMAND_PATTERN, 'LOGOUT') self.plugin.handle_logout(self.mock_connection, mock_event, match) # refetch refetch = Character.objects.get(name='test_logout') # should have been penalized assert refetch.next_level > test_char.next_level assert refetch.status == Character.CHARACTER_STATUS_OFFLINE self.mock_bot.reply.assert_called_once_with( mock_event, "test_logout, the level 0 tester, has been successfully logged out." ) def test_register(self): """Test the ability to register a new character.""" mock_event = mock.MagicMock() mock_event.source = 'bss!bss@bss_register' mock_event.recursing = False self.plugin.seen_hostmasks.add('bss!bss@bss_register') now = datetime.datetime.fromisoformat('2024-05-17 17:00:00-00:00') with mock.patch('django.utils.timezone.now', return_value=now): match = re.match(IdleRPG.REGISTER_COMMAND_PATTERN, 'REGISTER reg_test test tester') self.plugin.handle_register(self.mock_connection, mock_event, match) self.mock_bot.reply.assert_has_calls([ mock.call(None, "bss's newest character, reg_test, the tester, " "has been summoned! Next level at 2024-05-18 06:15:58 UTC.", explicit_target='#test'), mock.call(mock_event, "You have registered reg_test, the level 0 tester, " "and been automatically logged in."), ]) def test_register_no_active(self): """Test the behavior of a failed registration because there's no active game.""" mock_event = mock.MagicMock() mock_event.source = 'bss!bss@bss_register' mock_event.recursing = False # manipulate the game temporarily game = Game.objects.get(active=True) game.active = False game.save() self.plugin.seen_hostmasks.add('bss!bss@bss_register') now = datetime.datetime.fromisoformat('2024-05-17 17:00:00-00:00') with mock.patch('django.utils.timezone.now', return_value=now): match = re.match(IdleRPG.REGISTER_COMMAND_PATTERN, 'REGISTER reg_test test tester') self.plugin.handle_register(self.mock_connection, mock_event, match) self.mock_bot.reply.assert_has_calls([ mock.call(mock_event, "No active game exists."), ]) # undo manipulation game.active = True game.save() def test_register_not_in_channel(self): """Test the behavior of a failed registration because the user isn't in the game channel.""" mock_event = mock.MagicMock() mock_event.source = 'bss!bss@bss_register' mock_event.recursing = False now = datetime.datetime.fromisoformat('2024-05-17 17:00:00-00:00') with mock.patch('django.utils.timezone.now', return_value=now): match = re.match(IdleRPG.REGISTER_COMMAND_PATTERN, 'REGISTER reg_test test tester') self.plugin.handle_register(self.mock_connection, mock_event, match) self.mock_bot.reply.assert_has_calls([ mock.call(mock_event, "Please join #test before registering.") ]) def test_register_cant_double_register(self): """Test the ability to register a new character fails if you try to have two.""" mock_event = mock.MagicMock() mock_event.source = 'bss!bss@bss_dupe_register' mock_event.recursing = False self.plugin.seen_hostmasks.add('bss!bss@bss_dupe_register') now = datetime.datetime.fromisoformat('2024-05-17 17:00:00-00:00') with mock.patch('django.utils.timezone.now', return_value=now): match = re.match(IdleRPG.REGISTER_COMMAND_PATTERN, 'REGISTER reg_test test tester') self.plugin.handle_register(self.mock_connection, mock_event, match) self.mock_bot.reply.assert_has_calls([ mock.call(None, "bss's newest character, reg_test, the tester, " "has been summoned! Next level at 2024-05-18 06:15:58 UTC.", explicit_target='#test'), mock.call(mock_event, "You have registered reg_test, the level 0 tester, " "and been automatically logged in."), ]) with mock.patch('django.utils.timezone.now', return_value=now): match = re.match(IdleRPG.REGISTER_COMMAND_PATTERN, 'REGISTER reg_test2 test tester') self.plugin.handle_register(self.mock_connection, mock_event, match) self.mock_bot.reply.assert_has_calls([ mock.call(mock_event, "Registration failed; either you already have a character, " "or the character name is already taken.") ]) def test_remove(self): """Test the remove command.""" mock_event = mock.MagicMock() mock_event.source = 'bss!bss@bss_remove' mock_event.recursing = False game = Game.objects.get(pk=1) test_char = Character.objects.register('test_remove', game, 'test', 'bss!bss@bss_remove', 'tester') match = re.match(IdleRPG.LOGIN_COMMAND_PATTERN, 'REMOVEME') self.plugin.handle_remove(self.mock_connection, mock_event, match) assert test_char.enabled is True refetch = Character.objects.get(name='test_remove') assert refetch.enabled is False self.mock_bot.reply.assert_has_calls([ mock.call(mock_event, "Character test_remove has been disabled."), ]) def test_remove_no_match(self): """Test the remove command doesn't do anything if the hostname isn't found.""" mock_event = mock.MagicMock() mock_event.source = 'bss2!bss@bss_remove' mock_event.recursing = False game = Game.objects.get(pk=1) test_char = Character.objects.register('test_remove', game, 'test', 'bss!bss@bss_remove', 'tester') match = re.match(IdleRPG.LOGIN_COMMAND_PATTERN, 'REMOVEME') self.plugin.handle_remove(self.mock_connection, mock_event, match) assert test_char.enabled is True refetch = Character.objects.get(name='test_remove') assert refetch.enabled is True self.mock_bot.reply.assert_has_calls([ mock.call(mock_event, "No character associated to your hostmask found (try logging in first)."), ]) def test_status(self): """Test the status command.""" mock_event = mock.MagicMock() mock_event.source = 'bss!bss@bss' mock_event.recursing = False self.plugin.seen_hostmasks.add('bss!bss@bss') match = re.match(IdleRPG.LOGIN_COMMAND_PATTERN, 'STATUS') self.plugin.handle_status(self.mock_connection, mock_event, match) self.mock_bot.reply.assert_has_calls([ mock.call(mock_event, "bss, the level 0 tester, is bss!bss@bss."), mock.call(mock_event, "bss is logged in."), mock.call(mock_event, "bss will level up on 2024-05-05 05:20:45 UTC."), ]) assert self.mock_bot.reply.call_count == 3 self.plugin.seen_hostmasks.remove('bss!bss@bss') def test_status_logged_out(self): """Test the status command.""" mock_event = mock.MagicMock() mock_event.source = 'bss2!bss@bss' mock_event.recursing = False self.plugin.seen_hostmasks.add('bss2!bss@bss') match = re.match(IdleRPG.LOGIN_COMMAND_PATTERN, 'STATUS') self.plugin.handle_status(self.mock_connection, mock_event, match) self.mock_bot.reply.assert_has_calls([ mock.call(mock_event, "bss2, the level 0 tester, is bss2!bss@bss."), mock.call(mock_event, "bss2 is offline."), ]) assert self.mock_bot.reply.call_count == 2 self.plugin.seen_hostmasks.remove('bss2!bss@bss') def test_status_no_match(self): """Test the status command.""" mock_event = mock.MagicMock() mock_event.source = 'bss3!bss@bss' mock_event.recursing = False self.plugin.seen_hostmasks.add('bss3!bss@bss') match = re.match(IdleRPG.LOGIN_COMMAND_PATTERN, 'STATUS') self.plugin.handle_status(self.mock_connection, mock_event, match) self.mock_bot.reply.assert_has_calls([ mock.call(mock_event, "No character associated to your hostmask found (try logging in first)."), ]) assert self.mock_bot.reply.call_count == 1 self.plugin.seen_hostmasks.remove('bss3!bss@bss') def test_level_up_characters(self): """Test the walking of games and characters to level them up.""" game = Game.objects.get(pk=1) char_1 = Character.objects.register('test_level_1', game, 'test', 'test_1!test@test', 'tester') char_2 = Character.objects.register('test_level_2', game, 'test', 'test_2!test@test', 'tester') char_2.log_out() level_time = datetime.datetime(year=2024, month=5, day=16, hour=00, minute=0, second=0) char_1.next_level = level_time char_2.next_level = level_time char_1.save() char_2.save() # only one should level up, since char_2 isn't logged in self.plugin._level_up_characters() char_1 = Character.objects.get(pk=char_1.pk) char_2 = Character.objects.get(pk=char_2.pk) assert char_1.level == 1 assert char_2.level == 0 assert char_1.next_level != char_2.next_level self.mock_bot.reply.assert_has_calls([ mock.call(None, "test_level_1, the tester, has attained level 1! Next level at 2024-05-16 13:15:58 UTC.", explicit_target=game.channel.name), ])