"""Test IdleRPG character operations. SPDX-FileCopyrightText: © 2024 Brian S. Stephan SPDX-License-Identifier: AGPL-3.0-or-later """ import datetime import re import unittest.mock as mock from django.test import TestCase from ircbot.models import 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 = 'admin!admin@the_kicker' mock_event.target = '#test' mock_event.arguments = ['bss', ''] 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') self.plugin.seen_hostmasks.add('bss!bss@test_kick_penalty') with mock.patch('idlerpg.models.Character.penalize', return_value=250) 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 == 250 assert 'bss!bss@test_kick_penalty' not in self.plugin.seen_hostmasks self.mock_bot.reply.assert_called_once_with( None, "testkickpen, the level 0 tester has been penalized 250 seconds for getting kicked from the game channel, " "and has been logged out.", explicit_target='bss', ) 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') self.plugin.seen_hostmasks.add('bss!bss@test_nick_penalty') with mock.patch('idlerpg.models.Character.penalize', return_value=30) 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, "a nick change") mock_log_out.assert_called() test_char = Character.objects.get(name='testnickpen') assert test_char.time_penalized_nick_change == 30 assert 'bss!bss@test_nick_penalty' not in self.plugin.seen_hostmasks self.mock_bot.reply.assert_called_once_with( None, "testnickpen, the level 0 tester has been penalized 30 seconds for a nick change, " "and has been logged out.", explicit_target='bss', ) 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') self.plugin.seen_hostmasks.add('bss!bss@test_part_penalty') with mock.patch('idlerpg.models.Character.penalize', return_value=200) 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 == 200 assert 'bss!bss@test_part_penalty' not in self.plugin.seen_hostmasks self.mock_bot.reply.assert_called_once_with( None, "testpartpen, the level 0 tester has been penalized 200 seconds for parting the game channel, " "and has been logged out.", explicit_target='bss', ) 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=22) 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 == 22 self.mock_bot.reply.assert_called_once_with( None, "testpubmsgpen, the level 0 tester has been penalized 22 seconds for talking in the game channel.", explicit_target='bss', ) 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. " "They have been penalized 20 seconds.") ) 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), ])