diff --git a/requirements.txt b/requirements.txt index a09a5fa..287ea32 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,7 @@ aioresponses==0.7.3 pytest-cov asynctest==0.13.0 aiofastforward==0.0.24 +time-machine==2.6.0 mongomock # Requirements for documentation diff --git a/setup.py b/setup.py index df78702..2c2826f 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ install_requires=[ 'click>=6.2,<7.0', 'straight.plugin==1.4.0-post-1', - 'pymongo>=3.6.0', + 'pymongo>=4.0.1', 'requests>=2.9.1,<3.0.0', 'lxml>=2.3.5', 'aiogoogle>=0.1.13', diff --git a/src/csbot/plugins/last.py b/src/csbot/plugins/last.py index c1fc59d..fbba774 100644 --- a/src/csbot/plugins/last.py +++ b/src/csbot/plugins/last.py @@ -24,7 +24,9 @@ def last(self, nick, channel=None, msgtype=None): if msgtype is not None: search['type'] = msgtype - return self.db.find_one(search, sort=[('when', pymongo.DESCENDING)]) + # Additional sorting by _id to make sort order stable for messages that arrive in the same millisecond + # (which sometimes happens during tests). + return self.db.find_one(search, sort=[('when', pymongo.DESCENDING), ('_id', pymongo.DESCENDING)]) def last_message(self, nick, channel=None): """Get the last message sent by a nick, optionally filtering @@ -104,8 +106,7 @@ def _schedule_update(self, e, query, update): @Plugin.hook('last.update') def _apply_update(self, e): - self.db.remove(e['query']) - self.db.insert(e['update']) + self.db.replace_one(e['query'], e['update'], upsert=True) @Plugin.command('seen', help=('seen nick [type]: show the last thing' ' said by a nick in this channel, optionally' diff --git a/src/csbot/plugins/termdates.py b/src/csbot/plugins/termdates.py index a9dd869..948adaa 100644 --- a/src/csbot/plugins/termdates.py +++ b/src/csbot/plugins/termdates.py @@ -196,8 +196,16 @@ def termdates_set(self, e): # Save to the database. As we don't touch the _id attribute in this # method, this will cause `save` to override the previously-loaded # entry (if there is one). - self.db_terms.save(self.terms) - self.db_weeks.save(self.weeks) + if '_id' in self.terms: + self.db_terms.replace_one({'_id': self.terms['_id']}, self.terms, upsert=True) + else: + res = self.db_terms.insert_one(self.terms) + self.terms['_id'] = res.inserted_id + if '_id' in self.weeks: + self.db_weeks.replace_one({'_id': self.weeks['_id']}, self.weeks, upsert=True) + else: + res = self.db_weeks.insert_one(self.weeks) + self.weeks['_id'] = res.inserted_id # Finally, we're initialised! self.initialised = True diff --git a/tests/conftest.py b/tests/conftest.py index 97907c9..f016561 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +from __future__ import annotations import asyncio from textwrap import dedent from unittest import mock @@ -124,16 +125,51 @@ def receive(self, lines): lines = [lines] return [self.client.line_received(line) for line in lines] - def assert_sent(self, lines): + def assert_sent(self, matchers, *, any_order=False, reset_mock=True): """Check that a list of (unicode) strings have been sent. Resets the mock so the next call will not contain what was checked by this call. """ - if isinstance(lines, str): - lines = [lines] - self.client.send_line.assert_has_calls([mock.call(line) for line in lines]) - self.client.send_line.reset_mock() + sent_lines = [args[0] for name, args, kwargs in self.client.send_line.mock_calls] + + if callable(matchers) or isinstance(matchers, str): + matchers = [matchers] + matchers = [LineMatcher.equals(matcher) if not callable(matcher) else matcher + for matcher in matchers] + + if not matchers: + pass + elif any_order: + for matcher in matchers: + assert any(matcher(line) for line in sent_lines), f"sent line not found: {matcher}" + else: + # Find the start of the matching run of sent messages + start = 0 + while start < len(sent_lines) and not matchers[0](sent_lines[start]): + start += 1 + for i, matcher in enumerate(matchers): + assert start + i < len(sent_lines), f"no line matching {matcher} in {sent_lines}" + assert matcher(sent_lines[start + i]), f"expected {sent_lines[start + i]!r} to match {matcher}" + + if reset_mock: + self.client.send_line.reset_mock() + + +class LineMatcher: + def __init__(self, f, description): + self.f = f + self.description = description + + def __call__(self, line): + return self.f(line) + + def __repr__(self): + return self.description + + @classmethod + def equals(cls, other): + return cls(lambda line: line == other, f"`line == {other!r}`") @pytest.fixture @@ -170,7 +206,7 @@ def bot_helper_class(): @pytest.fixture -def bot_helper(irc_client, bot_helper_class): +def bot_helper(irc_client, bot_helper_class) -> BotHelper: irc_client.bot_setup() return bot_helper_class(irc_client) diff --git a/tests/test_irc.py b/tests/test_irc.py index 950bbb0..9b88dbb 100644 --- a/tests/test_irc.py +++ b/tests/test_irc.py @@ -193,29 +193,15 @@ async def test_client_PING(self, fast_forward, run_client): run_client.client.send_line.assert_not_called() # Advance time, test that a ping was sent await fast_forward(4) - assert run_client.client.send_line.mock_calls == [ - mock.call('PING 1'), - ] + run_client.assert_sent(['PING 1'], reset_mock=False) # Advance time again, test that the right number of pings was sent await fast_forward(12) - assert run_client.client.send_line.mock_calls == [ - mock.call('PING 1'), - mock.call('PING 2'), - mock.call('PING 3'), - mock.call('PING 4'), - mock.call('PING 5'), - ] + run_client.assert_sent(['PING 1', 'PING 2', 'PING 3', 'PING 4', 'PING 5'], reset_mock=False) # Disconnect, advance time, test that no more pings were sent run_client.client.disconnect() await run_client.client.disconnected.wait() await fast_forward(12) - assert run_client.client.send_line.mock_calls == [ - mock.call('PING 1'), - mock.call('PING 2'), - mock.call('PING 3'), - mock.call('PING 4'), - mock.call('PING 5'), - ] + run_client.assert_sent(['PING 1', 'PING 2', 'PING 3', 'PING 4', 'PING 5'], reset_mock=False) async def test_client_PING_only_when_needed(self, fast_forward, run_client): """Check that client PING commands are sent relative to the last received message.""" @@ -223,32 +209,22 @@ async def test_client_PING_only_when_needed(self, fast_forward, run_client): run_client.client.send_line.assert_not_called() # Advance time to just before the second PING, check that the first PING was sent await fast_forward(5) - assert run_client.client.send_line.mock_calls == [ - mock.call('PING 1'), - ] + run_client.assert_sent(['PING 1'], reset_mock=False) # Receive a message, this should reset the PING timer run_client.receive(':nick!user@host PRIVMSG #channel :foo') # Advance time to just after when the second PING would happen without any messages # received, check that still only one PING was sent await fast_forward(2) - assert run_client.client.send_line.mock_calls == [ - mock.call('PING 1'), - ] + run_client.assert_sent(['PING 1'], reset_mock=False) # Advance time to 4 seconds after the last message was received, and check that another # PING has now been sent await fast_forward(2) - assert run_client.client.send_line.mock_calls == [ - mock.call('PING 1'), - mock.call('PING 2'), - ] + run_client.assert_sent(['PING 1', 'PING 2'], reset_mock=False) # Disconnect, advance time, test that no more pings were sent run_client.client.disconnect() await run_client.client.disconnected.wait() await fast_forward(12) - assert run_client.client.send_line.mock_calls == [ - mock.call('PING 1'), - mock.call('PING 2'), - ] + run_client.assert_sent(['PING 1', 'PING 2'], reset_mock=False) def test_PING_PONG(irc_client_helper): diff --git a/tests/test_plugin_last.py b/tests/test_plugin_last.py new file mode 100644 index 0000000..585d49b --- /dev/null +++ b/tests/test_plugin_last.py @@ -0,0 +1,143 @@ +import asyncio + +import pytest + +from csbot.plugins.last import Last + + +pytestmark = [ + pytest.mark.bot(config="""\ + ["@bot"] + plugins = ["mongodb", "last"] + + [mongodb] + mode = "mock" + """), + pytest.mark.usefixtures("run_client"), +] + + +def diff_dict(actual: dict, expected: dict) -> dict: + """Find items in *expected* that are different at the same keys in *actual*, returning a dict + mapping the offending key to a dict with "expected" and "actual" items.""" + diff = dict() + for k, v in expected.items(): + actual_value = actual.get(k) + expected_value = expected.get(k) + if actual_value != expected_value: + diff[k] = dict(actual=actual_value, expected=expected_value) + return diff + + +async def test_message_types(bot_helper): + plugin: Last = bot_helper["last"] + + # Starting state: should have no "last message" for a user + assert plugin.last("Nick") is None + assert plugin.last_message("Nick") is None + assert plugin.last_action("Nick") is None + assert plugin.last_command("Nick") is None + + # Receive a PRIVMSG from the user + await bot_helper.client.line_received(":Nick!~user@hostname PRIVMSG #channel :Example message") + # Check that message was recorded correctly + assert diff_dict(plugin.last("Nick"), {"nick": "Nick", "message": "Example message"}) == {} + # Check that message was only recorded in the correct category + assert plugin.last_message("Nick") == plugin.last("Nick") + assert not plugin.last_action("Nick") == plugin.last("Nick") + assert not plugin.last_command("Nick") == plugin.last("Nick") + + # Receive a CTCP ACTION from the user (inside a PRIVMSG) + await bot_helper.client.line_received(":Nick!~user@hostname PRIVMSG #channel :\x01ACTION emotes\x01") + # Check that message was recorded correctly + assert diff_dict(plugin.last("Nick"), {"nick": "Nick", "message": "emotes"}) == {} + # Check that message was only recorded in the correct category + assert not plugin.last_message("Nick") == plugin.last("Nick") + assert plugin.last_action("Nick") == plugin.last("Nick") + assert not plugin.last_command("Nick") == plugin.last("Nick") + + # Receive a bot command from the user (inside a PRIVMSG) + await bot_helper.client.line_received(":Nick!~user@hostname PRIVMSG #channel :!help") + # Check that message was recorded correctly + assert diff_dict(plugin.last("Nick"), {"nick": "Nick", "message": "!help"}) == {} + # Check that message was only recorded in the correct category + assert not plugin.last_message("Nick") == plugin.last("Nick") + assert not plugin.last_action("Nick") == plugin.last("Nick") + assert plugin.last_command("Nick") == plugin.last("Nick") + + # Final confirmation that the "message", "action" and "command" message types were all recorded separately + assert diff_dict(plugin.last_message("Nick"), {"nick": "Nick", "message": "Example message"}) == {} + assert diff_dict(plugin.last_action("Nick"), {"nick": "Nick", "message": "emotes"}) == {} + assert diff_dict(plugin.last_command("Nick"), {"nick": "Nick", "message": "!help"}) == {} + + # Also there shouldn't be any records for a different nick + assert plugin.last("OtherNick") is None + + +async def test_channel_filter(bot_helper): + plugin: Last = bot_helper["last"] + + # Starting state: should have no "last message" for a user + assert plugin.last("Nick") is None + assert plugin.last("Nick", channel="#a") is None + assert plugin.last("Nick", channel="#b") is None + + # Receive a PRIVMSG from the user in #a + await bot_helper.client.line_received(":Nick!~user@hostname PRIVMSG #a :Message A") + # Check that the message was recorded correctly + assert diff_dict(plugin.last("Nick"), {"nick": "Nick", "channel": "#a", "message": "Message A"}) == {} + # Check that channel filter applies correctly + assert plugin.last("Nick", channel="#a") == plugin.last("Nick") + assert not plugin.last("Nick", channel="#b") == plugin.last("Nick") + + # Receive a PRIVMSG from the user in #b + await bot_helper.client.line_received(":Nick!~user@hostname PRIVMSG #b :Message B") + # Check that the message was recorded correctly + assert diff_dict(plugin.last("Nick"), {"nick": "Nick", "channel": "#b", "message": "Message B"}) == {} + # Check that channel filter applies correctly + assert not plugin.last("Nick", channel="#a") == plugin.last("Nick") + assert plugin.last("Nick", channel="#b") == plugin.last("Nick") + + # Final confirmation that the latest message for each channel is stored + assert diff_dict(plugin.last("Nick", channel="#a"), {"nick": "Nick", "channel": "#a", "message": "Message A"}) == {} + assert diff_dict(plugin.last("Nick", channel="#b"), {"nick": "Nick", "channel": "#b", "message": "Message B"}) == {} + + # Also there shouldn't be any records for a different channel + assert plugin.last("Nick", channel="#c") is None + + +async def test_seen_command(bot_helper): + bot_helper.reset_mock() + + # !seen for a nick not yet seen + await asyncio.wait(bot_helper.receive(":A!~user@hostname PRIVMSG #a :!seen B")) + bot_helper.assert_sent("NOTICE #a :Nothing recorded for B") + + # !seen for a nick only seen in a different channel + await asyncio.wait(bot_helper.receive(":B!~user@hostname PRIVMSG #b :First message")) + await asyncio.wait(bot_helper.receive(":A!~user@hostname PRIVMSG #a :!seen B")) + bot_helper.assert_sent("NOTICE #a :Nothing recorded for B") + + # !seen for nick seen in the same channel + await asyncio.wait(bot_helper.receive(":A!~user@hostname PRIVMSG #b :!seen B")) + bot_helper.assert_sent(lambda line: " First message" in line) + + # Now seen in both channels, !seen should only return the message relating to the current channel + await asyncio.wait(bot_helper.receive(":B!~user@hostname PRIVMSG #a :Second message")) + await asyncio.wait(bot_helper.receive(":A!~user@hostname PRIVMSG #a :!seen B")) + bot_helper.assert_sent(lambda line: " Second message" in line) + await asyncio.wait(bot_helper.receive(":A!~user@hostname PRIVMSG #b :!seen B")) + bot_helper.assert_sent(lambda line: " First message" in line) + + # !seen on own nick should get the !seen command itself (because it makes more sense than "Nothing recorded") + await asyncio.wait(bot_helper.receive(":B!~user@hostname PRIVMSG #a :!seen B")) + bot_helper.assert_sent(lambda line: " !seen B" in line) + + # Check different formatting for actions + await asyncio.wait(bot_helper.receive(":B!~user@hostname PRIVMSG #a :\x01ACTION does something\x01")) + await asyncio.wait(bot_helper.receive(":A!~user@hostname PRIVMSG #a :!seen B")) + bot_helper.assert_sent(lambda line: "* B does something" in line) + + # Error when bad message type is specified + await asyncio.wait(bot_helper.receive(":A!~user@hostname PRIVMSG #a :!seen B foobar")) + bot_helper.assert_sent("NOTICE #a :Bad filter: foobar. Accepted are \"message\", \"command\", and \"action\".") diff --git a/tests/test_plugin_linkinfo.py b/tests/test_plugin_linkinfo.py index 94a256d..ecd789e 100644 --- a/tests/test_plugin_linkinfo.py +++ b/tests/test_plugin_linkinfo.py @@ -249,9 +249,7 @@ async def handler(url, **kwargs): event.set() await asyncio.wait(futures, timeout=0.1) assert all(f.done() for f in futures) - bot_helper.client.send_line.assert_has_calls([ - mock.call('NOTICE #channel :foo'), - ]) + bot_helper.assert_sent('NOTICE #channel :foo') async def test_non_blocking_command(self, event_loop, bot_helper, aioresponses): bot_helper.reset_mock() @@ -281,7 +279,5 @@ async def handler(url, **kwargs): event.set() await asyncio.wait(futures, timeout=0.1) assert all(f.done() for f in futures) - bot_helper.client.send_line.assert_has_calls([ - mock.call('NOTICE #channel :Error: Content-Type not HTML-ish: ' - 'application/octet-stream (http://example.com/)'), - ]) + bot_helper.assert_sent('NOTICE #channel :Error: Content-Type not HTML-ish: ' + 'application/octet-stream (http://example.com/)') diff --git a/tests/test_plugin_termdates.py b/tests/test_plugin_termdates.py new file mode 100644 index 0000000..bbdc077 --- /dev/null +++ b/tests/test_plugin_termdates.py @@ -0,0 +1,136 @@ +import asyncio +import datetime + +import pytest + + +pytestmark = [ + pytest.mark.bot(config="""\ + ["@bot"] + plugins = ["mongodb", "termdates"] + + [mongodb] + mode = "mock" + """), + pytest.mark.usefixtures("run_client"), +] + + +def say(msg): + return f":Nick!~user@hostname PRIVMSG #channel :{msg}" + + +async def test_term_dates(bot_helper, time_machine): + bot_helper.reset_mock() + + # Nothing configured yet, !termdates should error + await asyncio.wait(bot_helper.receive(say("!termdates"))) + bot_helper.assert_sent(lambda line: line.endswith("error: no term dates (see termdates.set)")) + + # Save dates + await asyncio.wait(bot_helper.receive([ + say("!termdates.set 2021-09-27 2022-01-10 2022-04-19"), + say("!termdates"), + ])) + bot_helper.assert_sent([ + lambda line: line.endswith("Aut 2021-09-27 -- 2021-12-03, " + "Spr 2022-01-10 -- 2022-03-18, " + "Sum 2022-04-19 -- 2022-06-24"), + ]) + + +async def test_week_command(bot_helper, time_machine): + bot_helper.reset_mock() + + # Nothing configured yet, !week should error + await asyncio.wait(bot_helper.receive(say("!week"))) + bot_helper.assert_sent(lambda line: line.endswith("error: no term dates (see termdates.set)")) + + # Configure term dates + await asyncio.wait(bot_helper.receive(say("!termdates.set 2021-09-27 2022-01-10 2022-04-19"))) + + # `!week term n` should give the correct dates for the specified week in the specified term + await asyncio.wait(bot_helper.receive(say("!week aut 3"))) + bot_helper.assert_sent(lambda line: line.endswith("Aut 3: 2021-10-11")) + await asyncio.wait(bot_helper.receive(say("!week spr 10"))) + bot_helper.assert_sent(lambda line: line.endswith("Spr 10: 2022-03-14")) + await asyncio.wait(bot_helper.receive(say("!week sum 4"))) + # TODO: should actually be bot_helper.assert_sent(lambda line: line.endswith("Sum 4: 2022-05-09")) + bot_helper.assert_sent(lambda line: line.endswith("Sum 4: 2022-05-10")) + # `!week n term` means the same as `!week term n` + await asyncio.wait(bot_helper.receive(say("!week 3 aut"))) + bot_helper.assert_sent(lambda line: line.endswith("Aut 3: 2021-10-11")) + await asyncio.wait(bot_helper.receive(say("!week 10 spr"))) + bot_helper.assert_sent(lambda line: line.endswith("Spr 10: 2022-03-14")) + await asyncio.wait(bot_helper.receive(say("!week 4 sum"))) + # TODO: should actually be bot_helper.assert_sent(lambda line: line.endswith("Sum 4: 2022-05-09")) + bot_helper.assert_sent(lambda line: line.endswith("Sum 4: 2022-05-10")) + + # Time travel to before the start of the Autumn term + time_machine.move_to(datetime.datetime(2021, 8, 1, 12, 0)) + # `!week` should give "Nth week before Aut" + await asyncio.wait(bot_helper.receive(say("!week"))) + bot_helper.assert_sent(lambda line: line.endswith("9th week before Aut (starts 2021-09-27)")) + # `!week n` should give the start of the Nth week in the Autumn term + await asyncio.wait(bot_helper.receive(say("!week 3"))) + bot_helper.assert_sent(lambda line: line.endswith("Aut 3: 2021-10-11")) + + # Time travel to during the Autumn term, week 4 + time_machine.move_to(datetime.datetime(2021, 10, 21, 12, 0)) + # `!week` should give "Aut 4: ..." + await asyncio.wait(bot_helper.receive(say("!week"))) + bot_helper.assert_sent(lambda line: line.endswith("Aut 4: 2021-10-18")) + # `!week n` should give the start of the Nth week in the Autumn term + await asyncio.wait(bot_helper.receive(say("!week 3"))) + bot_helper.assert_sent(lambda line: line.endswith("Aut 3: 2021-10-11")) + + # Time travel to after the Autumn term + time_machine.move_to(datetime.datetime(2021, 12, 15, 12, 0)) + # `!week` should give "Nth week before Spr" + await asyncio.wait(bot_helper.receive(say("!week"))) + bot_helper.assert_sent(lambda line: line.endswith("4th week before Spr (starts 2022-01-10)")) + # `!week n` should give the start of the Nth week in the Spring term + await asyncio.wait(bot_helper.receive(say("!week 3"))) + bot_helper.assert_sent(lambda line: line.endswith("Spr 3: 2022-01-24")) + + # Time travel to during the Spring term, week 10 + time_machine.move_to(datetime.datetime(2022, 3, 16, 12, 0)) + # `!week` should give "Spr 10: ..." + await asyncio.wait(bot_helper.receive(say("!week"))) + bot_helper.assert_sent(lambda line: line.endswith("Spr 10: 2022-03-14")) + # `!week n` should give the start of the Nth week in the Spring term + await asyncio.wait(bot_helper.receive(say("!week 3"))) + bot_helper.assert_sent(lambda line: line.endswith("Spr 3: 2022-01-24")) + + # Time travel to after the Spring term + time_machine.move_to(datetime.datetime(2022, 4, 4, 12, 0)) + # `!week` should give "Nth week before Sum" + await asyncio.wait(bot_helper.receive(say("!week"))) + # TODO: should actually be + # bot_helper.assert_sent(lambda line: line.endswith("2nd week before Sum (starts 2022-04-18)")) + bot_helper.assert_sent(lambda line: line.endswith("3rd week before Sum (starts 2022-04-19)")) + # `!week n` should give the start of the Nth week in the Summer term + await asyncio.wait(bot_helper.receive(say("!week 3"))) + # TODO: should actually be bot_helper.assert_sent(lambda line: line.endswith("Sum 3: 2022-05-02")) + bot_helper.assert_sent(lambda line: line.endswith("Sum 3: 2022-05-03")) + + # Time travel to during the Summer term, week 7 + time_machine.move_to(datetime.datetime(2022, 5, 31, 12, 0)) + # `!week` should give "Sum 7: ..." + await asyncio.wait(bot_helper.receive(say("!week"))) + # TODO: should actually be bot_helper.assert_sent(lambda line: line.endswith("Sum 7: 2022-05-30")) + bot_helper.assert_sent(lambda line: line.endswith("Sum 7: 2022-05-31")) + # `!week n` should give the start of the Nth week in the Summer term + await asyncio.wait(bot_helper.receive(say("!week 3"))) + # TODO: should actually be bot_helper.assert_sent(lambda line: line.endswith("Sum 3: 2022-05-02")) + bot_helper.assert_sent(lambda line: line.endswith("Sum 3: 2022-05-03")) + + # TODO: currently just throws an exception when after the end of Summer term, fix code and enable these tests + # time_machine.move_to(datetime.datetime(2022, 8, 15, 12, 0)) + # # `!week` should give "Sum 18" + # await asyncio.wait(bot_helper.receive(say("!week"))) + # bot_helper.assert_sent(lambda line: line.endswith("Sum 18: 2022-08-15")) + # # `!week n` should give the start of the Nth week in the Summer term + # await asyncio.wait(bot_helper.receive(say("!week 3"))) + # # TODO: should actually be bot_helper.assert_sent(lambda line: line.endswith("Sum 3: 2022-05-02")) + # bot_helper.assert_sent(lambda line: line.endswith("Sum 3: 2022-05-03")) diff --git a/tests/test_plugin_xkcd.py b/tests/test_plugin_xkcd.py index e335b66..ff08adb 100644 --- a/tests/test_plugin_xkcd.py +++ b/tests/test_plugin_xkcd.py @@ -173,9 +173,7 @@ async def test_command(self, bot_helper, num, url, content_type, fixture, expect _, title, alt = expected incoming = f":nick!user@host PRIVMSG #channel :!xkcd {num}" await asyncio.wait(bot_helper.receive(incoming)) - _, (outgoing,), _ = bot_helper.client.send_line.mock_calls[-1] - assert title in outgoing - assert alt in outgoing + bot_helper.assert_sent(lambda line: title in line and alt in line) @pytest.mark.usefixtures("populate_responses") async def test_integration_error(self, bot_helper):