diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 814a9729..e29d7452 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -37,7 +37,6 @@ ], "settings": { "python.pythonPath": "/usr/local/bin/python", - "python.testing.pytestArgs": ["--no-cov"], "terminal.integrated.profiles.linux": { "zsh": { "path": "/usr/bin/zsh" diff --git a/.vscode/settings.json b/.vscode/settings.json index 8bcb3171..00e13291 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,6 +4,11 @@ ], "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, - "coverage-gutters.showLineCoverage": true, - "coverage-gutters.showRulerCoverage": true + "python.analysis.enablePytestSupport": true, + "[python]": { + "diffEditor.ignoreTrimWhitespace": false, + "editor.formatOnType": true, + "editor.wordBasedSuggestions": "off", + "editor.defaultFormatter": "ms-python.black-formatter" + } } diff --git a/cloudbot/bot.py b/cloudbot/bot.py index 763cc786..d1914ab5 100644 --- a/cloudbot/bot.py +++ b/cloudbot/bot.py @@ -29,25 +29,28 @@ logger = logging.getLogger("cloudbot") +class AbstractBot: + def __init__(self, *, config: Config) -> None: + self.config = config + + class BotInstanceHolder: - def __init__(self): - self._instance = None + def __init__(self) -> None: + self._instance: Optional[AbstractBot] = None - def get(self): - # type: () -> CloudBot + def get(self) -> Optional[AbstractBot]: return self._instance - def set(self, value): - # type: (CloudBot) -> None + def set(self, value: Optional[AbstractBot]) -> None: self._instance = value @property - def config(self): - # type: () -> Config - if not self.get(): + def config(self) -> Config: + instance = self.get() + if not instance: raise ValueError("No bot instance available") - return self.get().config + return instance.config # Store a global instance of the bot to allow easier access to global data @@ -88,7 +91,7 @@ def get_cmd_regex(event): return cmd_re -class CloudBot: +class CloudBot(AbstractBot): def __init__( self, *, @@ -127,7 +130,7 @@ def __init__( self.data_path.mkdir(parents=True) # set up config - self.config = Config(self) + super().__init__(config=Config(self)) logger.debug("Config system initialised.") self.executor = ThreadPoolExecutor( diff --git a/plugins/librefm.py b/plugins/librefm.py index edbfed81..7c573913 100644 --- a/plugins/librefm.py +++ b/plugins/librefm.py @@ -84,7 +84,7 @@ def librefm(text, nick, db, event): if ( "track" not in response["recenttracks"] - or response["recenttracks"]["track"] + or not response["recenttracks"]["track"] ): return f'No recent tracks for user "{user}" found.' @@ -243,7 +243,7 @@ def toptrack(text, nick): return "Error: {}.".format(data["message"]) out = f"{username}'s favorite songs: " - for r in range(5): + for r in range(min(5, len(data["toptracks"]["track"]))): track_name = data["toptracks"]["track"][r]["name"] artist_name = data["toptracks"]["track"][r]["artist"]["name"] play_count = data["toptracks"]["track"][r]["playcount"] diff --git a/plugins/quote.py b/plugins/quote.py index c239609b..32c4f58f 100644 --- a/plugins/quote.py +++ b/plugins/quote.py @@ -9,6 +9,7 @@ String, Table, func, + inspect, not_, select, ) @@ -44,8 +45,9 @@ def migrate_table(db, logger): Column("deleted", String(5), default=0), PrimaryKeyConstraint("chan", "nick", "time"), ) + inspector = inspect(db.bind) - if not old_table.exists(): + if not inspector.has_table(old_table.name): database.metadata.remove(old_table) return @@ -106,19 +108,6 @@ def add_quote(db, chan, target, sender, message): return "Quote added." -def del_quote(db, nick, msg): - """Deletes a quote from a nick""" - query = ( - qtable.update() - .where(qtable.c.chan == 1) - .where(qtable.c.nick == nick.lower()) - .where(qtable.c.msg == msg) - .values(deleted=True) - ) - db.execute(query) - db.commit() - - def get_quote_num(num, count, name): """Returns the quote number to fetch from the DB""" if num: # Make sure num is a number if it isn't false diff --git a/tests/conftest.py b/tests/conftest.py index bcd5685e..203b83ef 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -59,13 +59,14 @@ def mock_db(tmp_path): @pytest.fixture() -def mock_bot_factory(event_loop, tmp_path): +def mock_bot_factory(event_loop, tmp_path, unset_bot): instances: List[MockBot] = [] def _factory(*args, **kwargs): kwargs.setdefault("loop", event_loop) kwargs.setdefault("base_dir", tmp_path) _bot = MockBot(*args, **kwargs) + bot.set(_bot) instances.append(_bot) return _bot diff --git a/tests/core_tests/test_bot.py b/tests/core_tests/test_bot.py index 7bb70080..af3d9554 100644 --- a/tests/core_tests/test_bot.py +++ b/tests/core_tests/test_bot.py @@ -4,6 +4,7 @@ import pytest from sqlalchemy import Column, String, Table +import cloudbot.bot from cloudbot import hook from cloudbot.bot import CloudBot, clean_name, get_cmd_regex from cloudbot.event import Event, EventType @@ -15,6 +16,12 @@ from tests.util.mock_db import MockDB +def test_no_instance_config(unset_bot): + cloudbot.bot.bot.set(None) + with pytest.raises(ValueError): + _ = cloudbot.bot.bot.config + + @pytest.mark.asyncio() async def test_migrate_db( mock_db, mock_bot_factory, event_loop, mock_requests, tmp_path diff --git a/tests/plugin_tests/librefm_test.py b/tests/plugin_tests/librefm_test.py new file mode 100644 index 00000000..2b383598 --- /dev/null +++ b/tests/plugin_tests/librefm_test.py @@ -0,0 +1,345 @@ +from unittest.mock import MagicMock + +from responses import RequestsMock +from responses.matchers import query_param_matcher + +from cloudbot.event import CommandEvent +from plugins import librefm +from tests.util import wrap_hook_response +from tests.util.mock_db import MockDB + + +def test_get_account(mock_db, mock_requests): + librefm.table.create(mock_db.engine) + mock_db.add_row(librefm.table, nick="foo", acc="bar") + librefm.load_cache(mock_db.session()) + + assert librefm.get_account("foo") == "bar" + assert librefm.get_account("FOO") == "bar" + assert librefm.get_account("foo1") is None + assert librefm.get_account("foo1") is None + + +def test_getartisttags(mock_requests): + url = "https://libre.fm/2.0/" + mock_requests.add( + "GET", + url, + json={ + "toptags": {}, + }, + match=[ + query_param_matcher( + { + "format": "json", + "artist": "foobar", + "method": "artist.getTopTags", + } + ) + ], + ) + res = librefm.getartisttags("foobar") + assert res == "no tags" + + +class TestGetArtistTags: + url = "https://libre.fm/2.0/" + + def get_params(self): + return { + "format": "json", + "artist": "foobar", + "method": "artist.getTopTags", + } + + def get_tags(self): + return librefm.getartisttags("foobar") + + def test_missing_tags(self, mock_requests): + mock_requests.add( + "GET", + self.url, + json={ + "toptags": {}, + }, + match=[query_param_matcher(self.get_params())], + ) + res = self.get_tags() + assert res == "no tags" + + def test_no_tags(self, mock_requests): + mock_requests.add( + "GET", + self.url, + json={ + "toptags": {"tags": []}, + }, + match=[query_param_matcher(self.get_params())], + ) + res = self.get_tags() + assert res == "no tags" + + def test_non_existent_artist(self, mock_requests): + mock_requests.add( + "GET", + self.url, + json={"error": 6, "message": "Missing artist."}, + match=[query_param_matcher(self.get_params())], + ) + res = self.get_tags() + assert res == "no tags" + + def test_tags(self, mock_requests): + mock_requests.add( + "GET", + self.url, + json={ + "toptags": { + "tag": [ + {"name": name} + for name in [ + "foobar", + "tag2", + "seen live", + "tag4", + "tag5", + "tag6", + "tag7", + ] + ] + }, + }, + match=[query_param_matcher(self.get_params())], + ) + res = self.get_tags() + assert res == "foobar, tag2, seen live, tag4" + + +class TestTopArtists: + def test_topweek_self(self, mock_requests, mock_db): + librefm.table.create(mock_db.engine) + mock_db.add_row(librefm.table, nick="foo", acc="bar") + librefm.load_cache(mock_db.session()) + + mock_requests.add( + "GET", + "https://libre.fm/2.0/", + match=[ + query_param_matcher( + { + "format": "json", + "user": "bar", + "limit": "10", + "period": "7day", + "method": "user.gettopartists", + } + ) + ], + json={ + "topartists": { + "artist": [ + {"name": "foo", "playcount": 5}, + {"name": "bar", "playcount": 2}, + ] + } + }, + ) + + out = librefm.topweek("", "foo") + + assert out == "bar's favorite artists: foo [5] bar [2] " + + +class TestTopTrack: + def test_toptrack_self(self, mock_requests, mock_db): + librefm.table.create(mock_db.engine) + mock_db.add_row(librefm.table, nick="foo", acc="bar") + librefm.load_cache(mock_db.session()) + + mock_requests.add( + "GET", + "https://libre.fm/2.0/", + match=[ + query_param_matcher( + { + "format": "json", + "user": "bar", + "limit": "5", + "method": "user.gettoptracks", + } + ) + ], + json={ + "toptracks": { + "track": [ + { + "name": "some song", + "artist": {"name": "some artist"}, + "playcount": 10, + } + ] + } + }, + ) + + out = librefm.toptrack("", "foo") + + expected = "bar's favorite songs: some song by some artist listened to 10 times. " + + assert out == expected + + +def test_save_account( + mock_db: MockDB, + mock_bot, + mock_requests: RequestsMock, + mock_bot_factory, + freeze_time, +): + librefm.table.create(mock_db.engine) + librefm.load_cache(mock_db.session()) + hook = MagicMock() + event = CommandEvent( + bot=mock_bot, + hook=hook, + text="myaccount", + triggered_command="np", + cmd_prefix=".", + nick="foo", + conn=MagicMock(), + ) + + event.db = mock_db.session() + + track_name = "some track" + artist_name = "bar" + mock_requests.add( + "GET", + "https://libre.fm/2.0/", + match=[ + query_param_matcher( + { + "format": "json", + "user": "myaccount", + "limit": "1", + "method": "user.getrecenttracks", + } + ) + ], + json={ + "recenttracks": { + "track": { + "name": track_name, + "album": {"#text": "foo"}, + "artist": {"#text": artist_name}, + "date": {"uts": 156432453}, + "url": "https://example.com", + } + } + }, + ) + + mock_requests.add( + "GET", + "https://libre.fm/2.0/", + json={"toptags": {"tag": [{"name": "thing"}]}}, + match=[ + query_param_matcher( + { + "format": "json", + "artist": artist_name, + "method": "artist.getTopTags", + } + ) + ], + ) + + results = wrap_hook_response(librefm.librefm, event) + assert results == [ + ( + "return", + 'myaccount last listened to "some track" by \x02bar\x0f from the album \x02foo\x0f https://example.com (thing) (44 years and 8 months ago)', + ), + ] + assert mock_db.get_data(librefm.table) == [ + ("foo", "myaccount"), + ] + + +def test_update_account( + mock_db: MockDB, + mock_bot, + mock_requests: RequestsMock, + mock_bot_factory, + freeze_time, +): + librefm.table.create(mock_db.engine) + mock_db.add_row(librefm.table, nick="foo", acc="oldaccount") + librefm.load_cache(mock_db.session()) + hook = MagicMock() + event = CommandEvent( + bot=mock_bot, + hook=hook, + text="myaccount", + triggered_command="np", + cmd_prefix=".", + nick="foo", + conn=MagicMock(), + ) + + event.db = mock_db.session() + + track_name = "some track" + artist_name = "bar" + mock_requests.add( + "GET", + "https://libre.fm/2.0/", + match=[ + query_param_matcher( + { + "format": "json", + "user": "myaccount", + "limit": "1", + "method": "user.getrecenttracks", + } + ) + ], + json={ + "recenttracks": { + "track": { + "name": track_name, + "album": {"#text": "foo"}, + "artist": {"#text": artist_name}, + "date": {"uts": 156432453}, + "url": "https://example.com", + } + } + }, + ) + + mock_requests.add( + "GET", + "https://libre.fm/2.0/", + json={"toptags": {"tag": [{"name": "thing"}]}}, + match=[ + query_param_matcher( + { + "format": "json", + "artist": artist_name, + "method": "artist.getTopTags", + } + ) + ], + ) + + results = wrap_hook_response(librefm.librefm, event) + assert results == [ + ( + "return", + 'myaccount last listened to "some track" by \x02bar\x0f from the album \x02foo\x0f https://example.com (thing) (44 years and 8 months ago)', + ), + ] + + assert mock_db.get_data(librefm.table) == [ + ("foo", "myaccount"), + ] diff --git a/tests/plugin_tests/profile_test.py b/tests/plugin_tests/profile_test.py new file mode 100644 index 00000000..98ad3813 --- /dev/null +++ b/tests/plugin_tests/profile_test.py @@ -0,0 +1,111 @@ +from unittest.mock import MagicMock + +from cloudbot.event import CommandEvent +from plugins import profile +from tests.util import wrap_hook_response + + +def test_profile_add(mock_db, mock_bot): + profile.table.create(mock_db.engine) + profile.load_cache(mock_db.session()) + conn = MagicMock() + event = CommandEvent( + bot=mock_bot, + conn=conn, + hook=MagicMock(), + nick="nick", + channel="#chan", + text="foo bar", + triggered_command="profileadd", + cmd_prefix=".", + ) + event.db = mock_db.session() + res = wrap_hook_response(profile.profileadd, event) + assert res == [ + ("return", "Created new profile category foo"), + ] + assert mock_db.get_data(profile.table) == [("#chan", "nick", "foo", "bar")] + assert conn.mock_calls == [] + + +def test_profile_update(mock_db, mock_bot): + profile.table.create(mock_db.engine) + mock_db.add_row( + profile.table, chan="#chan", nick="nick", category="foo", text="text" + ) + profile.load_cache(mock_db.session()) + conn = MagicMock() + event = CommandEvent( + bot=mock_bot, + conn=conn, + hook=MagicMock(), + nick="nick", + channel="#chan", + text="foo bar", + triggered_command="profileadd", + cmd_prefix=".", + ) + + event.db = mock_db.session() + res = wrap_hook_response(profile.profileadd, event) + assert res == [ + ("return", "Updated profile category foo"), + ] + assert mock_db.get_data(profile.table) == [("#chan", "nick", "foo", "bar")] + assert conn.mock_calls == [] + + +def test_profile_category_delete(mock_db, mock_bot): + profile.table.create(mock_db.engine) + mock_db.add_row( + profile.table, chan="#chan", nick="nick", category="foo", text="text" + ) + profile.load_cache(mock_db.session()) + conn = MagicMock() + event = CommandEvent( + bot=mock_bot, + conn=conn, + hook=MagicMock(), + nick="nick", + channel="#chan", + text="foo", + triggered_command="profiledel", + cmd_prefix=".", + ) + + event.db = mock_db.session() + res = wrap_hook_response(profile.profiledel, event) + assert res == [("return", "Deleted profile category foo")] + assert mock_db.get_data(profile.table) == [] + assert conn.mock_calls == [] + + +def test_profile_clear(mock_db, mock_bot): + profile.table.create(mock_db.engine) + mock_db.add_row( + profile.table, chan="#chan", nick="nick", category="foo", text="text" + ) + mock_db.add_row( + profile.table, chan="#chan", nick="nick", category="bar", text="thing" + ) + profile.load_cache(mock_db.session()) + profile.confirm_keys["#chan"]["nick"] = "foo" + conn = MagicMock() + event = CommandEvent( + bot=mock_bot, + conn=conn, + hook=MagicMock(), + nick="nick", + channel="#chan", + text="foo", + triggered_command="profileclear", + cmd_prefix=".", + ) + + event.db = mock_db.session() + res = wrap_hook_response(profile.profileclear, event) + assert res == [ + ("return", "Profile data cleared for nick."), + ] + assert mock_db.get_data(profile.table) == [] + assert conn.mock_calls == [] diff --git a/tests/plugin_tests/quote_test.py b/tests/plugin_tests/quote_test.py index d7744cfc..2e567bd3 100644 --- a/tests/plugin_tests/quote_test.py +++ b/tests/plugin_tests/quote_test.py @@ -1,9 +1,85 @@ import time from unittest.mock import MagicMock, call +from sqlalchemy import ( + REAL, + Column, + PrimaryKeyConstraint, + String, + Table, + inspect, +) + +from cloudbot.util import database from plugins import quote +def test_migrate(mock_db, freeze_time): + db = mock_db.session() + # quote.qtable.create(mock_db.engine) + old_table = Table( + "quote", + database.metadata, + Column("chan", String(25)), + Column("nick", String(25)), + Column("add_nick", String(25)), + Column("msg", String(500)), + Column("time", REAL), + Column("deleted", String(5), default=0), + PrimaryKeyConstraint("chan", "nick", "time"), + ) + + inspector = inspect(mock_db.engine) + old_table.create(mock_db.engine) + mock_db.load_data( + old_table, + [ + { + "chan": "#chan", + "nick": "nick", + "add_nick": "other", + "msg": "foobar", + "time": 12345, + }, + ], + ) + database.metadata.remove(old_table) + logger = MagicMock() + quote.migrate_table(db, logger) + assert not inspector.has_table(old_table.name) + assert mock_db.get_data(quote.qtable) == [ + ("#chan", "nick", "other", "foobar", 12345.0, False), + ] + assert logger.mock_calls == [ + call.info("Migrating quotes table"), + call.info("Migrated all quotes"), + ] + + +def test_migrate_no_old_table(mock_db, freeze_time): + db = mock_db.session() + # quote.qtable.create(mock_db.engine) + old_table = Table( + "quote", + database.metadata, + Column("chan", String(25)), + Column("nick", String(25)), + Column("add_nick", String(25)), + Column("msg", String(500)), + Column("time", REAL), + Column("deleted", String(5), default=0), + PrimaryKeyConstraint("chan", "nick", "time"), + ) + + inspector = inspect(mock_db.engine) + database.metadata.remove(old_table) + logger = MagicMock() + quote.migrate_table(db, logger) + assert not inspector.has_table(old_table.name) + assert not inspector.has_table(quote.qtable.name) + assert logger.mock_calls == [] + + def test_add_quote(mock_db, freeze_time): db = mock_db.session() quote.qtable.create(bind=mock_db.engine) @@ -11,12 +87,41 @@ def test_add_quote(mock_db, freeze_time): target = "bar" sender = "baz" msg = "Some test quote" - quote.add_quote(db, chan, target, sender, msg) + assert quote.add_quote(db, chan, target, sender, msg) == "Quote added." assert mock_db.get_data(quote.qtable) == [ (chan, target, sender, msg, time.time(), False) ] +def test_add_quote_existing(mock_db, freeze_time): + db = mock_db.session() + quote.qtable.create(bind=mock_db.engine) + + chan = "#foo" + target = "bar" + sender = "baz" + msg = "Some test quote" + mock_db.load_data( + quote.qtable, + [ + { + "chan": chan, + "nick": target, + "add_nick": sender, + "msg": msg, + "time": 0, + }, + ], + ) + assert ( + quote.add_quote(db, chan, target, sender, msg) + == "Message already stored, doing nothing." + ) + assert mock_db.get_data(quote.qtable) == [ + ("#foo", "bar", "baz", "Some test quote", 0.0, False), + ] + + def test_quote_cmd_add(mock_db, freeze_time): db = mock_db.session() quote.qtable.create(bind=mock_db.engine) diff --git a/tests/plugin_tests/test_duckhunt.py b/tests/plugin_tests/test_duckhunt.py index b9830ce3..8ca10d39 100644 --- a/tests/plugin_tests/test_duckhunt.py +++ b/tests/plugin_tests/test_duckhunt.py @@ -1,3 +1,4 @@ +import datetime from unittest.mock import MagicMock, call, patch import pytest @@ -24,6 +25,25 @@ def test_top_list(prefix, items, result, mock_db): assert duckhunt.top_list(prefix, items.items()) == result +def test_update_score(mock_db): + duckhunt.table.create(mock_db.engine) + mock_db.add_row( + duckhunt.table, + network="net", + chan="#chan", + name="nick", + shot=1, + befriend=3, + ) + session = mock_db.session() + + conn = MockConn(name="net") + chan = "#chan" + res = duckhunt.update_score("nick", chan, session, conn, shoot=1) + assert res == {"shoot": 2, "friend": 3} + assert mock_db.get_data(duckhunt.table) == [("net", "nick", 2, 3, "#chan")] + + def test_display_scores(mock_db): duckhunt.table.create(mock_db.engine) @@ -310,3 +330,260 @@ def test_duck_stats_no_data(mock_db): == "It looks like there has been no duck activity on this channel or network." ) assert event.mock_calls == [] + + +class TestOptOut: + def test_opt_out(self, mock_db): + duckhunt.optout.create(mock_db.engine) + mock_db.add_row(duckhunt.optout, network="net", chan="#chan") + duckhunt.load_optout(mock_db.session()) + assert duckhunt.is_opt_out("net", "#chan") + assert not duckhunt.is_opt_out("net2", "#chan") + + def test_set_opt_out_on(self, mock_db): + duckhunt.table.create(mock_db.engine) + duckhunt.optout.create(mock_db.engine) + duckhunt.status_table.create(mock_db.engine) + + duckhunt.load_optout(mock_db.session()) + duckhunt.load_status(mock_db.session()) + + conn = MockConn(name="net") + res = duckhunt.hunt_opt_out( + "add #chan", "#chan", mock_db.session(), conn + ) + assert res == "The duckhunt has been successfully disabled in #chan." + assert mock_db.get_data(duckhunt.optout) == [("net", "#chan")] + + def test_set_opt_out_off(self, mock_db): + duckhunt.table.create(mock_db.engine) + duckhunt.optout.create(mock_db.engine) + duckhunt.status_table.create(mock_db.engine) + mock_db.add_row(duckhunt.optout, chan="#chan", network="net") + + duckhunt.load_optout(mock_db.session()) + duckhunt.load_status(mock_db.session()) + + conn = MockConn(name="net") + assert duckhunt.is_opt_out("net", "#chan") + res = duckhunt.hunt_opt_out( + "remove #chan", "#chan", mock_db.session(), conn + ) + assert res is None + assert mock_db.get_data(duckhunt.optout) == [] + + +class TestStatus: + def test_load(self, mock_db): + duckhunt.game_status.clear() + duckhunt.status_table.create(mock_db.engine) + mock_db.add_row( + duckhunt.status_table, + network="net", + chan="#chan", + active=True, + duck_kick=True, + ) + duckhunt.load_status(mock_db.session()) + state = duckhunt.get_state_table("net", "#chan") + assert state.game_on + assert state.no_duck_kick + + def test_save_and_load(self, mock_db): + duckhunt.game_status.clear() + duckhunt.status_table.create(mock_db.engine) + duckhunt.load_status(mock_db.session()) + conn = MockConn(name="net") + duckhunt.set_game_state(mock_db.session(), conn, "#foo", active=True) + state = duckhunt.get_state_table("net", "#foo") + assert state.game_on + assert not state.no_duck_kick + + def test_save_all(self, mock_db): + duckhunt.game_status.clear() + duckhunt.status_table.create(mock_db.engine) + duckhunt.load_status(mock_db.session()) + duckhunt.game_status["net"]["#chan"] = state = duckhunt.ChannelState() + state.next_duck_time = 7 + state.no_duck_kick = True + duckhunt.save_status(mock_db.session(), _sleep=False) + assert mock_db.get_data(duckhunt.status_table) == [ + ("net", "#chan", False, True), + ] + + def test_save_on_exit(self, mock_db): + duckhunt.game_status.clear() + duckhunt.status_table.create(mock_db.engine) + duckhunt.load_status(mock_db.session()) + duckhunt.game_status["net"]["#chan"] = state = duckhunt.ChannelState() + state.next_duck_time = 7 + state.no_duck_kick = True + duckhunt.save_on_exit(mock_db.session()) + assert mock_db.get_data(duckhunt.status_table) == [ + ("net", "#chan", False, True), + ] + + +class TestStartHunt: + def test_start_hunt(self, mock_db): + duckhunt.optout.create(mock_db.engine) + duckhunt.load_optout(mock_db.session()) + duckhunt.game_status.clear() + duckhunt.status_table.create(mock_db.engine) + duckhunt.load_status(mock_db.session()) + conn = MockConn(name="net") + message = MagicMock() + res = duckhunt.start_hunt(mock_db.session(), "#chan", message, conn) + assert mock_db.get_data(duckhunt.status_table) == [ + ("net", "#chan", True, False) + ] + assert message.mock_calls == [ + call( + "Ducks have been spotted nearby. See how many you can shoot or save. use .bang to shoot or .befriend to save them. NOTE: Ducks now appear as a function of time and channel activity.", + "#chan", + ) + ] + assert res is None + + +class TestStopHunt: + def test_stop_hunt(self, mock_db): + duckhunt.optout.create(mock_db.engine) + duckhunt.load_optout(mock_db.session()) + duckhunt.game_status.clear() + duckhunt.status_table.create(mock_db.engine) + mock_db.add_row( + duckhunt.status_table, + network="net", + chan="#chan", + active=True, + duck_kick=False, + ) + duckhunt.load_status(mock_db.session()) + conn = MockConn(name="net") + res = duckhunt.stop_hunt(mock_db.session(), "#chan", conn) + assert mock_db.get_data(duckhunt.status_table) == [ + ("net", "#chan", False, False) + ] + assert res == "the game has been stopped." + + +class TestDuckKick: + def test_enable_duck_kick(self, mock_db): + duckhunt.optout.create(mock_db.engine) + duckhunt.load_optout(mock_db.session()) + duckhunt.game_status.clear() + duckhunt.status_table.create(mock_db.engine) + mock_db.add_row( + duckhunt.status_table, + network="net", + chan="#chan", + active=True, + duck_kick=False, + ) + duckhunt.load_status(mock_db.session()) + conn = MockConn(name="net") + notice_doc = MagicMock() + res = duckhunt.no_duck_kick( + mock_db.session(), "enable", "#chan", conn, notice_doc + ) + assert mock_db.get_data(duckhunt.status_table) == [ + ("net", "#chan", True, True) + ] + assert ( + res + == "users will now be kicked for shooting or befriending non-existent ducks. The bot needs to have appropriate flags to be able to kick users for this to work." + ) + assert notice_doc.mock_calls == [] + + def test_disable_duck_kick(self, mock_db): + duckhunt.optout.create(mock_db.engine) + duckhunt.load_optout(mock_db.session()) + duckhunt.game_status.clear() + duckhunt.status_table.create(mock_db.engine) + mock_db.add_row( + duckhunt.status_table, + network="net", + chan="#chan", + active=True, + duck_kick=True, + ) + duckhunt.load_status(mock_db.session()) + conn = MockConn(name="net") + notice_doc = MagicMock() + res = duckhunt.no_duck_kick( + mock_db.session(), "disable", "#chan", conn, notice_doc + ) + assert mock_db.get_data(duckhunt.status_table) == [ + ("net", "#chan", True, False) + ] + assert res == "kicking for non-existent ducks has been disabled." + assert notice_doc.mock_calls == [] + + +class TestAttack: + def test_shoot(self, mock_db, freeze_time): + duckhunt.table.create(mock_db.engine) + duckhunt.optout.create(mock_db.engine) + duckhunt.status_table.create(mock_db.engine) + + mock_db.add_row( + duckhunt.status_table, network="net", chan="#chan", active=True + ) + + duckhunt.load_optout(mock_db.session()) + duckhunt.load_status(mock_db.session()) + + state = duckhunt.get_state_table("net", "#chan") + state.duck_status = 1 + state.duck_time = datetime.datetime.now().timestamp() - 3600.0 + + conn = MockConn(name="net") + event = MagicMock() + with patch.object(duckhunt, "hit_or_miss") as p: + p.return_value = 0 + res = duckhunt.bang("nick", "#chan", mock_db.session(), conn, event) + + assert res is None + assert event.mock_calls == [ + call.message( + "nick you shot a duck in 3600.000 seconds! You have killed 1 duck in #chan." + ) + ] + assert mock_db.get_data(duckhunt.table) == [ + ("net", "nick", 1, 0, "#chan") + ] + + def test_befriend(self, mock_db, freeze_time): + duckhunt.table.create(mock_db.engine) + duckhunt.optout.create(mock_db.engine) + duckhunt.status_table.create(mock_db.engine) + + mock_db.add_row( + duckhunt.status_table, network="net", chan="#chan", active=True + ) + + duckhunt.load_optout(mock_db.session()) + duckhunt.load_status(mock_db.session()) + + state = duckhunt.get_state_table("net", "#chan") + state.duck_status = 1 + state.duck_time = datetime.datetime.now().timestamp() - 3600.0 + + conn = MockConn(name="net") + event = MagicMock() + with patch.object(duckhunt, "hit_or_miss") as p: + p.return_value = 1 + res = duckhunt.befriend( + "nick", "#chan", mock_db.session(), conn, event + ) + + assert res is None + assert event.mock_calls == [ + call.message( + "nick you befriended a duck in 3600.000 seconds! You have made friends with 1 duck in #chan." + ) + ] + assert mock_db.get_data(duckhunt.table) == [ + ("net", "nick", 0, 1, "#chan") + ] diff --git a/tests/plugin_tests/test_factoids.py b/tests/plugin_tests/test_factoids.py index f1e7e61c..418358bb 100644 --- a/tests/plugin_tests/test_factoids.py +++ b/tests/plugin_tests/test_factoids.py @@ -2,7 +2,88 @@ import string from unittest.mock import MagicMock, call +from cloudbot.event import CommandEvent from plugins import factoids +from tests.util import wrap_hook_response_async + + +async def test_add_fact(mock_db, mock_bot): + factoids.table.create(mock_db.engine) + factoids.load_cache(mock_db.session()) + hook = MagicMock() + conn = MagicMock() + conn.configure_mock(name="net", config={}) + event = CommandEvent( + bot=mock_bot, + conn=conn, + hook=hook, + channel="#chan", + nick="nick", + text="foo bar baz", + triggered_command="remember", + cmd_prefix=".", + ) + + event.db = mock_db.session() + res = await wrap_hook_response_async(factoids.remember, event) + assert res == [ + ( + "notice", + ( + "nick", + "Remembering \x02bar baz\x02 for \x02foo\x02. Type ?foo to see it.", + ), + ) + ] + assert hook.mock_calls == [] + assert conn.mock_calls == [] + + assert mock_db.get_data(factoids.table) == [ + ("foo", "bar baz", "nick", "#chan") + ] + + +async def test_update_fact(mock_db, mock_bot): + factoids.table.create(mock_db.engine) + mock_db.load_data( + factoids.table, + [ + {"word": "foo", "data": "data", "nick": "nick", "chan": "#chan"}, + ], + ) + factoids.load_cache(mock_db.session()) + hook = MagicMock() + conn = MagicMock() + conn.configure_mock(name="net", config={}) + event = CommandEvent( + bot=mock_bot, + conn=conn, + hook=hook, + channel="#chan", + nick="nick", + text="foo bar baz", + triggered_command="remember", + cmd_prefix=".", + ) + + event.db = mock_db.session() + res = await wrap_hook_response_async(factoids.remember, event) + assert res == [ + ( + "notice", + ( + "nick", + "Remembering \x02bar baz\x02 for \x02foo\x02. Type ?foo to see it.", + ), + ), + ("notice", ("nick", "Previous data was \x02data\x02")), + ] + assert hook.mock_calls == [] + assert conn.mock_calls == [] + + assert mock_db.get_data(factoids.table) == [ + ("foo", "bar baz", "nick", "#chan") + ] def test_forget(mock_db, patch_paste): @@ -123,22 +204,21 @@ def test_clear_facts(mock_db): def test_list_facts(mock_db): factoids.table.create(mock_db.engine) - factoids.load_cache(mock_db.session()) event = MagicMock() names = [ "".join(c) for c in itertools.product(string.ascii_lowercase, repeat=2) ] - for name in names: - factoids.add_factoid( - mock_db.session(), - name.lower(), - "#chan", - name, - "nick", - ) + mock_db.load_data( + factoids.table, + [ + {"word": name, "data": name, "nick": "nick", "chan": "#chan"} + for name in names + ], + ) + factoids.load_cache(mock_db.session()) factoids.listfactoids(event.notice, "#chan") assert event.mock_calls == [ diff --git a/tests/plugin_tests/test_herald.py b/tests/plugin_tests/test_herald.py index b17e2cb6..ffbd1f30 100644 --- a/tests/plugin_tests/test_herald.py +++ b/tests/plugin_tests/test_herald.py @@ -1,11 +1,12 @@ from datetime import timedelta -from unittest.mock import MagicMock +from unittest.mock import MagicMock, call import pytest from cloudbot.event import Event from plugins import herald from tests.util import wrap_hook_response +from tests.util.mock_db import MockDB @pytest.fixture() @@ -109,3 +110,71 @@ def check(ev): # User spam time expired assert check(event) == [("message", ("#foo", "\u200b Some herald"))] + + +def test_add_herald(mock_db: MockDB, clear_cache): + herald.table.create(mock_db.engine) + herald.load_cache(mock_db.session()) + reply = MagicMock() + res = herald.herald("foobar baz", "nick", "#chan", mock_db.session(), reply) + assert mock_db.get_data(herald.table) == [ + ("nick", "#chan", "foobar baz"), + ] + + assert res is None + assert reply.mock_calls == [call("greeting successfully added")] + + +def test_update_herald(mock_db: MockDB, clear_cache): + herald.table.create(mock_db.engine) + mock_db.add_row( + herald.table, + name="nick", + chan="#chan", + quote="foo baz", + ) + herald.load_cache(mock_db.session()) + reply = MagicMock() + res = herald.herald("foobar baz", "nick", "#chan", mock_db.session(), reply) + assert mock_db.get_data(herald.table) == [ + ("nick", "#chan", "foobar baz"), + ] + + assert res is None + assert reply.mock_calls == [call("greeting successfully added")] + + +def test_delete_herald(mock_db: MockDB, clear_cache): + herald.table.create(mock_db.engine) + mock_db.add_row( + herald.table, + name="nick", + chan="#chan", + quote="foo baz", + ) + herald.load_cache(mock_db.session()) + reply = MagicMock() + res = herald.herald("delete", "nick", "#chan", mock_db.session(), reply) + assert mock_db.get_data(herald.table) == [] + + assert res is None + assert reply.mock_calls == [ + call("greeting 'foo baz' for nick has been removed") + ] + + +def test_op_delete_herald(mock_db, clear_cache): + herald.table.create(mock_db.engine) + mock_db.add_row( + herald.table, + name="nick", + chan="#chan", + quote="foo baz", + ) + herald.load_cache(mock_db.session()) + reply = MagicMock() + res = herald.deleteherald("nick", "#chan", mock_db.session(), reply) + assert mock_db.get_data(herald.table) == [] + + assert res is None + assert reply.mock_calls == [call("greeting for nick has been removed")] diff --git a/tests/plugin_tests/test_lastfm.py b/tests/plugin_tests/test_lastfm.py index ca3f9fa8..9b88d06d 100644 --- a/tests/plugin_tests/test_lastfm.py +++ b/tests/plugin_tests/test_lastfm.py @@ -1,4 +1,5 @@ from json import JSONDecodeError +from unittest.mock import MagicMock import pytest import requests @@ -7,7 +8,10 @@ from responses.matchers import query_param_matcher from cloudbot.bot import bot +from cloudbot.event import CommandEvent from plugins import lastfm +from tests.util import wrap_hook_response +from tests.util.mock_db import MockDB def test_get_account(mock_db, mock_requests): @@ -309,3 +313,200 @@ def test_toptrack_self(self, mock_api_keys, mock_requests, mock_db): expected = "b\u200bar's favorite songs: some song by some artist listened to 10 times. " assert out == expected + + +def test_save_account( + mock_db: MockDB, mock_requests: RequestsMock, mock_bot_factory, freeze_time +): + lastfm.table.create(mock_db.engine) + lastfm.load_cache(mock_db.session()) + mock_bot = mock_bot_factory(config={"api_keys": {"lastfm": "APIKEY"}}) + hook = MagicMock() + event = CommandEvent( + bot=mock_bot, + hook=hook, + text="myaccount", + triggered_command="np", + cmd_prefix=".", + nick="foo", + conn=MagicMock(), + ) + + event.db = mock_db.session() + + track_name = "some track" + artist_name = "bar" + mock_requests.add( + "GET", + "http://ws.audioscrobbler.com/2.0/", + match=[ + query_param_matcher( + { + "format": "json", + "user": "myaccount", + "limit": "1", + "method": "user.getrecenttracks", + "api_key": "APIKEY", + } + ) + ], + json={ + "recenttracks": { + "track": [ + { + "name": track_name, + "album": {"#text": "foo"}, + "artist": {"#text": artist_name}, + "date": {"uts": 156432453}, + "url": "https://example.com", + } + ] + } + }, + ) + + mock_requests.add( + "GET", + "http://ws.audioscrobbler.com/2.0/", + json={"toptags": {"tag": [{"name": "thing"}]}}, + match=[ + query_param_matcher( + { + "format": "json", + "artist": artist_name, + "autocorrect": "1", + "track": track_name, + "method": "track.getTopTags", + "api_key": "APIKEY", + } + ) + ], + ) + + mock_requests.add( + "GET", + "http://ws.audioscrobbler.com/2.0/", + json={"track": {"userplaycount": 3}}, + match=[ + query_param_matcher( + { + "format": "json", + "artist": artist_name, + "track": track_name, + "method": "track.getInfo", + "api_key": "APIKEY", + "username": "myaccount", + } + ) + ], + ) + + results = wrap_hook_response(lastfm.lastfm, event) + assert mock_db.get_data(lastfm.table) == [ + ("foo", "myaccount"), + ] + assert results == [ + ( + "return", + 'm\u200byaccount last listened to "some track" by \x02bar\x0f from the album \x02foo\x0f [playcount: 3] https://example.com (thing) (44 years and 8 months ago)', + ), + ] + + +def test_update_account( + mock_db: MockDB, mock_requests: RequestsMock, mock_bot_factory, freeze_time +): + lastfm.table.create(mock_db.engine) + mock_db.add_row(lastfm.table, nick="foo", acc="oldaccount") + lastfm.load_cache(mock_db.session()) + mock_bot = mock_bot_factory(config={"api_keys": {"lastfm": "APIKEY"}}) + hook = MagicMock() + event = CommandEvent( + bot=mock_bot, + hook=hook, + text="myaccount", + triggered_command="np", + cmd_prefix=".", + nick="foo", + conn=MagicMock(), + ) + + event.db = mock_db.session() + + track_name = "some track" + artist_name = "bar" + mock_requests.add( + "GET", + "http://ws.audioscrobbler.com/2.0/", + match=[ + query_param_matcher( + { + "format": "json", + "user": "myaccount", + "limit": "1", + "method": "user.getrecenttracks", + "api_key": "APIKEY", + } + ) + ], + json={ + "recenttracks": { + "track": [ + { + "name": track_name, + "album": {"#text": "foo"}, + "artist": {"#text": artist_name}, + "date": {"uts": 156432453}, + "url": "https://example.com", + } + ] + } + }, + ) + + mock_requests.add( + "GET", + "http://ws.audioscrobbler.com/2.0/", + json={"toptags": {"tag": [{"name": "thing"}]}}, + match=[ + query_param_matcher( + { + "format": "json", + "artist": artist_name, + "autocorrect": "1", + "track": track_name, + "method": "track.getTopTags", + "api_key": "APIKEY", + } + ) + ], + ) + + mock_requests.add( + "GET", + "http://ws.audioscrobbler.com/2.0/", + json={"track": {"userplaycount": 3}}, + match=[ + query_param_matcher( + { + "format": "json", + "artist": artist_name, + "track": track_name, + "method": "track.getInfo", + "api_key": "APIKEY", + "username": "myaccount", + } + ) + ], + ) + + results = wrap_hook_response(lastfm.lastfm, event) + assert mock_db.get_data(lastfm.table) == [ + ("foo", "myaccount"), + ] + assert results == [ + ( + "return", + 'm\u200byaccount last listened to "some track" by \x02bar\x0f from the album \x02foo\x0f [playcount: 3] https://example.com (thing) (44 years and 8 months ago)', + ), + ] diff --git a/tests/plugin_tests/test_optout.py b/tests/plugin_tests/test_optout.py index 051a2b09..ec920766 100644 --- a/tests/plugin_tests/test_optout.py +++ b/tests/plugin_tests/test_optout.py @@ -1,8 +1,10 @@ -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, call, patch import pytest +from cloudbot.event import CommandEvent from plugins.core import optout +from tests.util import wrap_hook_response_async from tests.util.mock_db import MockDB @@ -364,9 +366,41 @@ def test_format(): class TestSetOptOut: + async def test_cmd(self, mock_db: MockDB, mock_bot) -> None: + with mock_db.session() as session: + optout.optout_table.create(mock_db.engine) + optout.load_cache(session) + conn = MagicMock() + conn.configure_mock(name="net") + event = CommandEvent( + nick="nick", + channel="#chan", + conn=conn, + bot=mock_bot, + hook=MagicMock(), + text="foo.*", + triggered_command="optout", + cmd_prefix=".", + ) + event.db = session + has_perm = MagicMock() + has_perm.return_value = True + with patch.object(event, "has_permission", has_perm): + res = await wrap_hook_response_async(optout.optout, event) + + assert res == [ + ("return", "Disabled hooks matching foo.* in #chan.") + ] + assert conn.mock_calls == [] + assert has_perm.mock_calls == [call("op", notice=True)] + assert mock_db.get_data(optout.optout_table) == [ + ("net", "#chan", "foo.*", False) + ] + def test_add(self, mock_db: MockDB): with mock_db.session() as session: optout.optout_table.create(mock_db.engine) + optout.load_cache(session) optout.set_optout(session, "net", "#chan", "my.hook", True) assert mock_db.get_data(optout.optout_table) == [ @@ -388,6 +422,8 @@ def test_update(self, mock_db: MockDB): ], ) + optout.load_cache(session) + assert mock_db.get_data(optout.optout_table) == [ ("net", "#chan", "my.hook", False) ] @@ -400,6 +436,68 @@ def test_update(self, mock_db: MockDB): class TestDelOptOut: + async def test_del_cmd(self, mock_db: MockDB, mock_bot) -> None: + with mock_db.session() as session: + optout.optout_table.create(mock_db.engine) + mock_db.load_data( + optout.optout_table, + [ + { + "network": "net", + "chan": "#chan", + "hook": "foo.*", + "allow": False, + }, + { + "network": "net", + "chan": "#chan", + "hook": "foo1.*", + "allow": False, + }, + { + "network": "net1", + "chan": "#chan", + "hook": "foo.*", + "allow": False, + }, + { + "network": "net", + "chan": "#chan1", + "hook": "foo.*", + "allow": False, + }, + ], + ) + optout.load_cache(session) + conn = MagicMock() + conn.configure_mock(name="net") + event = CommandEvent( + nick="nick", + channel="#chan", + conn=conn, + bot=mock_bot, + hook=MagicMock(), + text="foo.*", + triggered_command="deloptout", + cmd_prefix=".", + ) + event.db = session + has_perm = MagicMock() + has_perm.return_value = True + with patch.object(event, "has_permission", has_perm): + res = await wrap_hook_response_async(optout.deloptout, event) + + assert res == [ + ("return", "Deleted optout 'foo.*' in channel '#chan'.") + ] + assert conn.mock_calls == [] + assert has_perm.mock_calls == [call("op", notice=True)] + assert mock_db.get_data(optout.optout_table) == [ + ("net", "#chan", "foo1.*", False), + ("net1", "#chan", "foo.*", False), + ("net", "#chan1", "foo.*", False), + ] + def test_del_no_match(self, mock_db: MockDB): with mock_db.session() as session: optout.optout_table.create(mock_db.engine) @@ -432,6 +530,65 @@ def test_del(self, mock_db: MockDB): class TestClearOptOut: + async def test_clear_cmd(self, mock_db: MockDB, mock_bot) -> None: + with mock_db.session() as session: + optout.optout_table.create(mock_db.engine) + mock_db.load_data( + optout.optout_table, + [ + { + "network": "net", + "chan": "#chan", + "hook": "foo.*", + "allow": False, + }, + { + "network": "net", + "chan": "#chan", + "hook": "foo1.*", + "allow": False, + }, + { + "network": "net1", + "chan": "#chan", + "hook": "foo.*", + "allow": False, + }, + { + "network": "net", + "chan": "#chan1", + "hook": "foo.*", + "allow": False, + }, + ], + ) + optout.load_cache(session) + conn = MagicMock() + conn.configure_mock(name="net") + event = CommandEvent( + nick="nick", + channel="#chan", + conn=conn, + bot=mock_bot, + hook=MagicMock(), + text="", + triggered_command="clearoptout", + cmd_prefix=".", + ) + event.db = session + has_perm = MagicMock() + has_perm.return_value = True + with patch.object(event, "has_permission", has_perm): + res = await wrap_hook_response_async(optout.clear, event) + + assert res == [("return", "Cleared 2 opt outs from the list.")] + assert conn.mock_calls == [] + assert has_perm.mock_calls == [call("snoonetstaff", notice=True)] + assert mock_db.get_data(optout.optout_table) == [ + ("net1", "#chan", "foo.*", False), + ("net", "#chan1", "foo.*", False), + ] + def test_clear_chan(self, mock_db: MockDB): with mock_db.session() as session: optout.optout_table.create(mock_db.engine) diff --git a/tests/plugin_tests/test_remind.py b/tests/plugin_tests/test_remind.py index afb619de..d479260f 100644 --- a/tests/plugin_tests/test_remind.py +++ b/tests/plugin_tests/test_remind.py @@ -61,7 +61,6 @@ async def test_invalid_reminder_time(mock_db, freeze_time, setup_db): assert mock_db.get_data(remind.table) == [] -@pytest.mark.asyncio() async def test_invalid_reminder_overtime(mock_db, freeze_time, setup_db): await remind.load_cache(async_call, mock_db.session()) mock_conn = MagicMock() @@ -84,7 +83,6 @@ async def test_invalid_reminder_overtime(mock_db, freeze_time, setup_db): assert mock_db.get_data(remind.table) == [] -@pytest.mark.asyncio() async def test_add_reminder(mock_db, freeze_time, setup_db): await remind.load_cache(async_call, mock_db.session()) mock_conn = MagicMock() diff --git a/tests/util/__init__.py b/tests/util/__init__.py index 4a7ed4a0..cdb11b95 100644 --- a/tests/util/__init__.py +++ b/tests/util/__init__.py @@ -1,4 +1,5 @@ -from collections.abc import Mapping +import inspect +from collections.abc import Awaitable, Mapping from pathlib import Path from unittest.mock import patch @@ -78,6 +79,45 @@ def action(*args, **kwargs): # pragma: no cover return results +async def wrap_hook_response_async(func, event, results=None): + """ + Wrap the response from a hook, allowing easy assertion against calls to + event.notice(), event.reply(), etc instead of just returning a string + """ + if results is None: + results = [] + + async def async_call(func, *args, **kwargs): + return func(*args, **kwargs) + + def add_result(name, value, data=None): + results.append(HookResult(name, value, data)) + + def notice(*args, **kwargs): # pragma: no cover + add_result("notice", args, kwargs) + + def message(*args, **kwargs): # pragma: no cover + add_result("message", args, kwargs) + + def action(*args, **kwargs): # pragma: no cover + add_result("action", args, kwargs) + + patch_notice = patch.object(event.conn, "notice", notice) + patch_message = patch.object(event.conn, "message", message) + patch_action = patch.object(event.conn, "action", action) + patch_async_call = patch.object(event, "async_call", async_call) + + with patch_action, patch_message, patch_notice, patch_async_call: + res = call_with_args(func, event) + if inspect.isawaitable(res): + res = await res + + if res is not None: + add_result("return", res) + + return results + + def get_data_path() -> Path: return Path(__file__).parent.parent / "data" diff --git a/tests/util/mock_bot.py b/tests/util/mock_bot.py index 0ea6d6b8..f12a68fc 100644 --- a/tests/util/mock_bot.py +++ b/tests/util/mock_bot.py @@ -3,7 +3,7 @@ from watchdog.observers import Observer -from cloudbot.bot import CloudBot +from cloudbot.bot import AbstractBot, CloudBot from cloudbot.client import Client from cloudbot.plugin import PluginManager from cloudbot.util.async_util import create_future @@ -11,7 +11,7 @@ from tests.util.mock_db import MockDB -class MockBot: +class MockBot(AbstractBot): def __init__( self, *, @@ -39,7 +39,7 @@ def __init__( self.running = True self.logger = logging.getLogger("cloudbot") - self.config = MockConfig(self) + super().__init__(config=MockConfig(self)) if config is not None: self.config.update(config)