Skip to content

Commit cc86784

Browse files
authored
Merge pull request #184 from HackSoc/pymongo4
Upgrade to pymongo 4.x
2 parents 4d736c6 + 1e980e0 commit cc86784

File tree

10 files changed

+348
-53
lines changed

10 files changed

+348
-53
lines changed

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ aioresponses==0.7.3
66
pytest-cov
77
asynctest==0.13.0
88
aiofastforward==0.0.24
9+
time-machine==2.6.0
910
mongomock
1011

1112
# Requirements for documentation

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
install_requires=[
1717
'click>=6.2,<7.0',
1818
'straight.plugin==1.4.0-post-1',
19-
'pymongo>=3.6.0',
19+
'pymongo>=4.0.1',
2020
'requests>=2.9.1,<3.0.0',
2121
'lxml>=2.3.5',
2222
'aiogoogle>=0.1.13',

src/csbot/plugins/last.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ def last(self, nick, channel=None, msgtype=None):
2424
if msgtype is not None:
2525
search['type'] = msgtype
2626

27-
return self.db.find_one(search, sort=[('when', pymongo.DESCENDING)])
27+
# Additional sorting by _id to make sort order stable for messages that arrive in the same millisecond
28+
# (which sometimes happens during tests).
29+
return self.db.find_one(search, sort=[('when', pymongo.DESCENDING), ('_id', pymongo.DESCENDING)])
2830

2931
def last_message(self, nick, channel=None):
3032
"""Get the last message sent by a nick, optionally filtering
@@ -104,8 +106,7 @@ def _schedule_update(self, e, query, update):
104106

105107
@Plugin.hook('last.update')
106108
def _apply_update(self, e):
107-
self.db.remove(e['query'])
108-
self.db.insert(e['update'])
109+
self.db.replace_one(e['query'], e['update'], upsert=True)
109110

110111
@Plugin.command('seen', help=('seen nick [type]: show the last thing'
111112
' said by a nick in this channel, optionally'

src/csbot/plugins/termdates.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,16 @@ def termdates_set(self, e):
196196
# Save to the database. As we don't touch the _id attribute in this
197197
# method, this will cause `save` to override the previously-loaded
198198
# entry (if there is one).
199-
self.db_terms.save(self.terms)
200-
self.db_weeks.save(self.weeks)
199+
if '_id' in self.terms:
200+
self.db_terms.replace_one({'_id': self.terms['_id']}, self.terms, upsert=True)
201+
else:
202+
res = self.db_terms.insert_one(self.terms)
203+
self.terms['_id'] = res.inserted_id
204+
if '_id' in self.weeks:
205+
self.db_weeks.replace_one({'_id': self.weeks['_id']}, self.weeks, upsert=True)
206+
else:
207+
res = self.db_weeks.insert_one(self.weeks)
208+
self.weeks['_id'] = res.inserted_id
201209

202210
# Finally, we're initialised!
203211
self.initialised = True

tests/conftest.py

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from __future__ import annotations
12
import asyncio
23
from textwrap import dedent
34
from unittest import mock
@@ -124,16 +125,51 @@ def receive(self, lines):
124125
lines = [lines]
125126
return [self.client.line_received(line) for line in lines]
126127

127-
def assert_sent(self, lines):
128+
def assert_sent(self, matchers, *, any_order=False, reset_mock=True):
128129
"""Check that a list of (unicode) strings have been sent.
129130
130131
Resets the mock so the next call will not contain what was checked by
131132
this call.
132133
"""
133-
if isinstance(lines, str):
134-
lines = [lines]
135-
self.client.send_line.assert_has_calls([mock.call(line) for line in lines])
136-
self.client.send_line.reset_mock()
134+
sent_lines = [args[0] for name, args, kwargs in self.client.send_line.mock_calls]
135+
136+
if callable(matchers) or isinstance(matchers, str):
137+
matchers = [matchers]
138+
matchers = [LineMatcher.equals(matcher) if not callable(matcher) else matcher
139+
for matcher in matchers]
140+
141+
if not matchers:
142+
pass
143+
elif any_order:
144+
for matcher in matchers:
145+
assert any(matcher(line) for line in sent_lines), f"sent line not found: {matcher}"
146+
else:
147+
# Find the start of the matching run of sent messages
148+
start = 0
149+
while start < len(sent_lines) and not matchers[0](sent_lines[start]):
150+
start += 1
151+
for i, matcher in enumerate(matchers):
152+
assert start + i < len(sent_lines), f"no line matching {matcher} in {sent_lines}"
153+
assert matcher(sent_lines[start + i]), f"expected {sent_lines[start + i]!r} to match {matcher}"
154+
155+
if reset_mock:
156+
self.client.send_line.reset_mock()
157+
158+
159+
class LineMatcher:
160+
def __init__(self, f, description):
161+
self.f = f
162+
self.description = description
163+
164+
def __call__(self, line):
165+
return self.f(line)
166+
167+
def __repr__(self):
168+
return self.description
169+
170+
@classmethod
171+
def equals(cls, other):
172+
return cls(lambda line: line == other, f"`line == {other!r}`")
137173

138174

139175
@pytest.fixture
@@ -170,7 +206,7 @@ def bot_helper_class():
170206

171207

172208
@pytest.fixture
173-
def bot_helper(irc_client, bot_helper_class):
209+
def bot_helper(irc_client, bot_helper_class) -> BotHelper:
174210
irc_client.bot_setup()
175211
return bot_helper_class(irc_client)
176212

tests/test_irc.py

Lines changed: 7 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -193,62 +193,38 @@ async def test_client_PING(self, fast_forward, run_client):
193193
run_client.client.send_line.assert_not_called()
194194
# Advance time, test that a ping was sent
195195
await fast_forward(4)
196-
assert run_client.client.send_line.mock_calls == [
197-
mock.call('PING 1'),
198-
]
196+
run_client.assert_sent(['PING 1'], reset_mock=False)
199197
# Advance time again, test that the right number of pings was sent
200198
await fast_forward(12)
201-
assert run_client.client.send_line.mock_calls == [
202-
mock.call('PING 1'),
203-
mock.call('PING 2'),
204-
mock.call('PING 3'),
205-
mock.call('PING 4'),
206-
mock.call('PING 5'),
207-
]
199+
run_client.assert_sent(['PING 1', 'PING 2', 'PING 3', 'PING 4', 'PING 5'], reset_mock=False)
208200
# Disconnect, advance time, test that no more pings were sent
209201
run_client.client.disconnect()
210202
await run_client.client.disconnected.wait()
211203
await fast_forward(12)
212-
assert run_client.client.send_line.mock_calls == [
213-
mock.call('PING 1'),
214-
mock.call('PING 2'),
215-
mock.call('PING 3'),
216-
mock.call('PING 4'),
217-
mock.call('PING 5'),
218-
]
204+
run_client.assert_sent(['PING 1', 'PING 2', 'PING 3', 'PING 4', 'PING 5'], reset_mock=False)
219205

220206
async def test_client_PING_only_when_needed(self, fast_forward, run_client):
221207
"""Check that client PING commands are sent relative to the last received message."""
222208
run_client.reset_mock()
223209
run_client.client.send_line.assert_not_called()
224210
# Advance time to just before the second PING, check that the first PING was sent
225211
await fast_forward(5)
226-
assert run_client.client.send_line.mock_calls == [
227-
mock.call('PING 1'),
228-
]
212+
run_client.assert_sent(['PING 1'], reset_mock=False)
229213
# Receive a message, this should reset the PING timer
230214
run_client.receive(':nick!user@host PRIVMSG #channel :foo')
231215
# Advance time to just after when the second PING would happen without any messages
232216
# received, check that still only one PING was sent
233217
await fast_forward(2)
234-
assert run_client.client.send_line.mock_calls == [
235-
mock.call('PING 1'),
236-
]
218+
run_client.assert_sent(['PING 1'], reset_mock=False)
237219
# Advance time to 4 seconds after the last message was received, and check that another
238220
# PING has now been sent
239221
await fast_forward(2)
240-
assert run_client.client.send_line.mock_calls == [
241-
mock.call('PING 1'),
242-
mock.call('PING 2'),
243-
]
222+
run_client.assert_sent(['PING 1', 'PING 2'], reset_mock=False)
244223
# Disconnect, advance time, test that no more pings were sent
245224
run_client.client.disconnect()
246225
await run_client.client.disconnected.wait()
247226
await fast_forward(12)
248-
assert run_client.client.send_line.mock_calls == [
249-
mock.call('PING 1'),
250-
mock.call('PING 2'),
251-
]
227+
run_client.assert_sent(['PING 1', 'PING 2'], reset_mock=False)
252228

253229

254230
def test_PING_PONG(irc_client_helper):

tests/test_plugin_last.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import asyncio
2+
3+
import pytest
4+
5+
from csbot.plugins.last import Last
6+
7+
8+
pytestmark = [
9+
pytest.mark.bot(config="""\
10+
["@bot"]
11+
plugins = ["mongodb", "last"]
12+
13+
[mongodb]
14+
mode = "mock"
15+
"""),
16+
pytest.mark.usefixtures("run_client"),
17+
]
18+
19+
20+
def diff_dict(actual: dict, expected: dict) -> dict:
21+
"""Find items in *expected* that are different at the same keys in *actual*, returning a dict
22+
mapping the offending key to a dict with "expected" and "actual" items."""
23+
diff = dict()
24+
for k, v in expected.items():
25+
actual_value = actual.get(k)
26+
expected_value = expected.get(k)
27+
if actual_value != expected_value:
28+
diff[k] = dict(actual=actual_value, expected=expected_value)
29+
return diff
30+
31+
32+
async def test_message_types(bot_helper):
33+
plugin: Last = bot_helper["last"]
34+
35+
# Starting state: should have no "last message" for a user
36+
assert plugin.last("Nick") is None
37+
assert plugin.last_message("Nick") is None
38+
assert plugin.last_action("Nick") is None
39+
assert plugin.last_command("Nick") is None
40+
41+
# Receive a PRIVMSG from the user
42+
await bot_helper.client.line_received(":Nick!~user@hostname PRIVMSG #channel :Example message")
43+
# Check that message was recorded correctly
44+
assert diff_dict(plugin.last("Nick"), {"nick": "Nick", "message": "Example message"}) == {}
45+
# Check that message was only recorded in the correct category
46+
assert plugin.last_message("Nick") == plugin.last("Nick")
47+
assert not plugin.last_action("Nick") == plugin.last("Nick")
48+
assert not plugin.last_command("Nick") == plugin.last("Nick")
49+
50+
# Receive a CTCP ACTION from the user (inside a PRIVMSG)
51+
await bot_helper.client.line_received(":Nick!~user@hostname PRIVMSG #channel :\x01ACTION emotes\x01")
52+
# Check that message was recorded correctly
53+
assert diff_dict(plugin.last("Nick"), {"nick": "Nick", "message": "emotes"}) == {}
54+
# Check that message was only recorded in the correct category
55+
assert not plugin.last_message("Nick") == plugin.last("Nick")
56+
assert plugin.last_action("Nick") == plugin.last("Nick")
57+
assert not plugin.last_command("Nick") == plugin.last("Nick")
58+
59+
# Receive a bot command from the user (inside a PRIVMSG)
60+
await bot_helper.client.line_received(":Nick!~user@hostname PRIVMSG #channel :!help")
61+
# Check that message was recorded correctly
62+
assert diff_dict(plugin.last("Nick"), {"nick": "Nick", "message": "!help"}) == {}
63+
# Check that message was only recorded in the correct category
64+
assert not plugin.last_message("Nick") == plugin.last("Nick")
65+
assert not plugin.last_action("Nick") == plugin.last("Nick")
66+
assert plugin.last_command("Nick") == plugin.last("Nick")
67+
68+
# Final confirmation that the "message", "action" and "command" message types were all recorded separately
69+
assert diff_dict(plugin.last_message("Nick"), {"nick": "Nick", "message": "Example message"}) == {}
70+
assert diff_dict(plugin.last_action("Nick"), {"nick": "Nick", "message": "emotes"}) == {}
71+
assert diff_dict(plugin.last_command("Nick"), {"nick": "Nick", "message": "!help"}) == {}
72+
73+
# Also there shouldn't be any records for a different nick
74+
assert plugin.last("OtherNick") is None
75+
76+
77+
async def test_channel_filter(bot_helper):
78+
plugin: Last = bot_helper["last"]
79+
80+
# Starting state: should have no "last message" for a user
81+
assert plugin.last("Nick") is None
82+
assert plugin.last("Nick", channel="#a") is None
83+
assert plugin.last("Nick", channel="#b") is None
84+
85+
# Receive a PRIVMSG from the user in #a
86+
await bot_helper.client.line_received(":Nick!~user@hostname PRIVMSG #a :Message A")
87+
# Check that the message was recorded correctly
88+
assert diff_dict(plugin.last("Nick"), {"nick": "Nick", "channel": "#a", "message": "Message A"}) == {}
89+
# Check that channel filter applies correctly
90+
assert plugin.last("Nick", channel="#a") == plugin.last("Nick")
91+
assert not plugin.last("Nick", channel="#b") == plugin.last("Nick")
92+
93+
# Receive a PRIVMSG from the user in #b
94+
await bot_helper.client.line_received(":Nick!~user@hostname PRIVMSG #b :Message B")
95+
# Check that the message was recorded correctly
96+
assert diff_dict(plugin.last("Nick"), {"nick": "Nick", "channel": "#b", "message": "Message B"}) == {}
97+
# Check that channel filter applies correctly
98+
assert not plugin.last("Nick", channel="#a") == plugin.last("Nick")
99+
assert plugin.last("Nick", channel="#b") == plugin.last("Nick")
100+
101+
# Final confirmation that the latest message for each channel is stored
102+
assert diff_dict(plugin.last("Nick", channel="#a"), {"nick": "Nick", "channel": "#a", "message": "Message A"}) == {}
103+
assert diff_dict(plugin.last("Nick", channel="#b"), {"nick": "Nick", "channel": "#b", "message": "Message B"}) == {}
104+
105+
# Also there shouldn't be any records for a different channel
106+
assert plugin.last("Nick", channel="#c") is None
107+
108+
109+
async def test_seen_command(bot_helper):
110+
bot_helper.reset_mock()
111+
112+
# !seen for a nick not yet seen
113+
await asyncio.wait(bot_helper.receive(":A!~user@hostname PRIVMSG #a :!seen B"))
114+
bot_helper.assert_sent("NOTICE #a :Nothing recorded for B")
115+
116+
# !seen for a nick only seen in a different channel
117+
await asyncio.wait(bot_helper.receive(":B!~user@hostname PRIVMSG #b :First message"))
118+
await asyncio.wait(bot_helper.receive(":A!~user@hostname PRIVMSG #a :!seen B"))
119+
bot_helper.assert_sent("NOTICE #a :Nothing recorded for B")
120+
121+
# !seen for nick seen in the same channel
122+
await asyncio.wait(bot_helper.receive(":A!~user@hostname PRIVMSG #b :!seen B"))
123+
bot_helper.assert_sent(lambda line: "<B> First message" in line)
124+
125+
# Now seen in both channels, !seen should only return the message relating to the current channel
126+
await asyncio.wait(bot_helper.receive(":B!~user@hostname PRIVMSG #a :Second message"))
127+
await asyncio.wait(bot_helper.receive(":A!~user@hostname PRIVMSG #a :!seen B"))
128+
bot_helper.assert_sent(lambda line: "<B> Second message" in line)
129+
await asyncio.wait(bot_helper.receive(":A!~user@hostname PRIVMSG #b :!seen B"))
130+
bot_helper.assert_sent(lambda line: "<B> First message" in line)
131+
132+
# !seen on own nick should get the !seen command itself (because it makes more sense than "Nothing recorded")
133+
await asyncio.wait(bot_helper.receive(":B!~user@hostname PRIVMSG #a :!seen B"))
134+
bot_helper.assert_sent(lambda line: "<B> !seen B" in line)
135+
136+
# Check different formatting for actions
137+
await asyncio.wait(bot_helper.receive(":B!~user@hostname PRIVMSG #a :\x01ACTION does something\x01"))
138+
await asyncio.wait(bot_helper.receive(":A!~user@hostname PRIVMSG #a :!seen B"))
139+
bot_helper.assert_sent(lambda line: "* B does something" in line)
140+
141+
# Error when bad message type is specified
142+
await asyncio.wait(bot_helper.receive(":A!~user@hostname PRIVMSG #a :!seen B foobar"))
143+
bot_helper.assert_sent("NOTICE #a :Bad filter: foobar. Accepted are \"message\", \"command\", and \"action\".")

tests/test_plugin_linkinfo.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -249,9 +249,7 @@ async def handler(url, **kwargs):
249249
event.set()
250250
await asyncio.wait(futures, timeout=0.1)
251251
assert all(f.done() for f in futures)
252-
bot_helper.client.send_line.assert_has_calls([
253-
mock.call('NOTICE #channel :foo'),
254-
])
252+
bot_helper.assert_sent('NOTICE #channel :foo')
255253

256254
async def test_non_blocking_command(self, event_loop, bot_helper, aioresponses):
257255
bot_helper.reset_mock()
@@ -281,7 +279,5 @@ async def handler(url, **kwargs):
281279
event.set()
282280
await asyncio.wait(futures, timeout=0.1)
283281
assert all(f.done() for f in futures)
284-
bot_helper.client.send_line.assert_has_calls([
285-
mock.call('NOTICE #channel :Error: Content-Type not HTML-ish: '
286-
'application/octet-stream (http://example.com/)'),
287-
])
282+
bot_helper.assert_sent('NOTICE #channel :Error: Content-Type not HTML-ish: '
283+
'application/octet-stream (http://example.com/)')

0 commit comments

Comments
 (0)