Skip to content

Commit 98b147f

Browse files
committed
added support for scheduling messages, including debugging async tests
1 parent a8a0e3a commit 98b147f

File tree

6 files changed

+276
-5
lines changed

6 files changed

+276
-5
lines changed

Makefile

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,14 @@ bot:
4949
# ================
5050

5151
test:
52-
$(TEST_CMD) -s -vv
52+
$(TEST_CMD) -s -v
53+
54+
test/k:
55+
$(TEST_CMD) -s -v -k $(K)
56+
57+
test/fast:
58+
# skip slow tests
59+
$(TEST_CMD) -s -v -m "not slow"
5360

5461

5562
lint:

intbot/core/bot/main.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import discord
2+
from core.models import DiscordMessage
3+
from discord.ext import commands, tasks
24
from django.conf import settings
3-
from discord.ext import commands
5+
from django.utils import timezone
46

57
intents = discord.Intents.default()
68
intents.members = True
@@ -33,6 +35,38 @@ async def version(ctx):
3335
await ctx.send(f"Version: {app_version}")
3436

3537

38+
@bot.command()
39+
async def qlen(ctx):
40+
qs = get_messages()
41+
# qs = DiscordMessage.objects.afilter(sent_at__isnull=True)
42+
cnt = await qs.acount()
43+
await ctx.send(f"In the queue there are: {cnt} messages")
44+
45+
46+
def get_messages():
47+
messages = DiscordMessage.objects.filter(sent_at__isnull=True)
48+
return messages
49+
50+
51+
@tasks.loop(seconds=60) # Seconds
52+
async def poll_database():
53+
"""Check for unsent messages and send them."""
54+
messages = get_messages()
55+
print("Polling database.... ", timezone.now())
56+
57+
async for message in messages:
58+
channel = bot.get_channel(int(message.channel_id))
59+
if channel:
60+
await channel.send(
61+
message.content,
62+
suppress_embeds=True,
63+
)
64+
message.sent_at = timezone.now()
65+
await message.asave()
66+
else:
67+
print("Channel does not exist!")
68+
69+
3670
def run_bot():
3771
bot_token = settings.DISCORD_BOT_TOKEN
3872
bot.run(bot_token)

intbot/intbot/settings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@
148148
DATABASES = {
149149
"default": {
150150
"ENGINE": "django.db.backends.postgresql",
151-
"NAME": "intbot_database_dev",
151+
"NAME": "intbot_database_test",
152152
"USER": "intbot_user",
153153
"PASSWORD": "intbot_password",
154154
"HOST": "localhost",

intbot/tests/test_bot.py

Lines changed: 169 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,72 @@
1-
from unittest.mock import AsyncMock
1+
from unittest import mock
2+
from unittest.mock import AsyncMock, patch
3+
import contextlib
4+
5+
from django.db import connections
26

37
import pytest
4-
from core.bot.main import ping, version, source
8+
from asgiref.sync import sync_to_async
9+
from core.bot.main import ping, poll_database, qlen, source, version
10+
from core.models import DiscordMessage
11+
from django.utils import timezone
12+
13+
# NOTE(artcz)
14+
# The fixture below (fix_async_db) is copied from this issue
15+
# https://github.com/pytest-dev/pytest-asyncio/issues/226
16+
# it seems to fix the issue and also speed up the test from ~6s down to 1s.
17+
# Thanks to (@gbdlin) for help with debugging.
18+
19+
@pytest.fixture(autouse=True)
20+
def fix_async_db(request):
21+
"""
22+
23+
If you don't use this fixture for async tests that use the ORM/database
24+
you won't get proper teardown of the database.
25+
This is a bug somehwere in pytest-django, pytest-asyncio or django itself.
26+
27+
Nobody knows how to solve it, or who should solve it.
28+
Workaround here: https://github.com/django/channels/issues/1091#issuecomment-701361358
29+
More info:
30+
https://github.com/pytest-dev/pytest-django/issues/580
31+
https://code.djangoproject.com/ticket/32409
32+
https://github.com/pytest-dev/pytest-asyncio/issues/226
33+
34+
35+
The actual implementation of this workaround constists on ensuring
36+
Django always returns the same database connection independently of the thread
37+
the code that requests a db connection is in.
38+
39+
We were unable to use better patching methods (the target is asgiref/local.py),
40+
so we resorted to mocking the _lock_storage context manager so that it returns a Mock.
41+
That mock contains the default connection of the main thread (instead of the connection
42+
of the running thread).
43+
44+
This only works because our tests only ever use the default connection, which is the only thing our mock returns.
45+
46+
We apologize in advance for the shitty implementation.
47+
"""
48+
if request.node.get_closest_marker("asyncio") is None or request.node.get_closest_marker("django_db") is None:
49+
# Only run for async tests that use the database
50+
yield
51+
return
52+
53+
main_thread_local = connections._connections
54+
for conn in connections.all():
55+
conn.inc_thread_sharing()
56+
57+
main_thread_default_conn = main_thread_local._storage.default
58+
main_thread_storage = main_thread_local._lock_storage
59+
60+
@contextlib.contextmanager
61+
def _lock_storage():
62+
yield mock.Mock(default=main_thread_default_conn)
63+
64+
try:
65+
with patch.object(main_thread_default_conn, "close"):
66+
object.__setattr__(main_thread_local, "_lock_storage", _lock_storage)
67+
yield
68+
finally:
69+
object.__setattr__(main_thread_local, "_lock_storage", main_thread_storage)
570

671

772
@pytest.mark.asyncio
@@ -41,3 +106,105 @@ async def test_source_command():
41106
"I'm here: https://github.com/europython/internal-bot",
42107
suppress_embeds=True,
43108
)
109+
110+
111+
@pytest.mark.asyncio
112+
@pytest.mark.django_db
113+
async def test_qlen_command_returns_zero_if_no_messages():
114+
# Mock context
115+
ctx = AsyncMock()
116+
117+
# Call the command
118+
await qlen(ctx)
119+
120+
# Assert that the command sent the expected message
121+
ctx.send.assert_called_once_with("In the queue there are: 0 messages")
122+
123+
124+
@pytest.mark.asyncio
125+
@pytest.mark.slow
126+
@pytest.mark.django_db
127+
async def test_qlen_command_returns_zero_if_all_messages_sent():
128+
# Mock context
129+
ctx = AsyncMock()
130+
await DiscordMessage.objects.acreate(sent_at=timezone.now())
131+
132+
# Call the command
133+
await qlen(ctx)
134+
135+
# Assert that the command sent the expected message
136+
ctx.send.assert_called_once_with("In the queue there are: 0 messages")
137+
138+
139+
@pytest.mark.asyncio
140+
@pytest.mark.slow
141+
@pytest.mark.django_db
142+
async def test_qlen_command_correctly_counts_unsent_messags():
143+
# Mock context
144+
ctx = AsyncMock()
145+
for _ in range(3):
146+
await DiscordMessage.objects.acreate(
147+
channel_id="1234",
148+
content="foobar",
149+
sent_at=None,
150+
)
151+
152+
# Call the command
153+
await qlen(ctx)
154+
155+
# Assert that the command sent the expected message
156+
ctx.send.assert_called_once_with("In the queue there are: 3 messages")
157+
158+
159+
@pytest.mark.asyncio
160+
@pytest.mark.slow
161+
@pytest.mark.django_db
162+
async def test_polling_messages_sends_nothing_without_messages():
163+
# NOTE: For some reason this test slows down the testsuite a bit
164+
# breakpoint()
165+
mock_channel = AsyncMock()
166+
mock_channel.send = AsyncMock()
167+
168+
with patch("core.bot.main.bot.get_channel", return_value=mock_channel):
169+
await poll_database()
170+
171+
mock_channel.send.assert_not_called()
172+
173+
174+
@pytest.mark.asyncio
175+
@pytest.mark.slow
176+
@pytest.mark.django_db
177+
async def test_polling_messages_sends_nothing_if_all_messages_are_sent():
178+
# NOTE: For some reason this test slows down the testsuite a bit
179+
mock_channel = AsyncMock()
180+
mock_channel.send = AsyncMock()
181+
await DiscordMessage.objects.acreate(sent_at=timezone.now())
182+
183+
with patch("core.bot.main.bot.get_channel", return_value=mock_channel):
184+
await poll_database()
185+
186+
mock_channel.send.assert_not_called()
187+
188+
189+
@pytest.mark.asyncio
190+
@pytest.mark.slow
191+
@pytest.mark.django_db
192+
async def test_polling_messages_sends_message_if_not_sent_and_sets_sent_at():
193+
# NOTE: For some reason this test slows down the testsuite a bit
194+
start = timezone.now()
195+
dm = await DiscordMessage.objects.acreate(
196+
channel_id="1234",
197+
content="asdf",
198+
sent_at=None,
199+
)
200+
mock_channel = AsyncMock()
201+
mock_channel.send = AsyncMock()
202+
203+
with patch("core.bot.main.bot.get_channel", return_value=mock_channel):
204+
await poll_database()
205+
206+
mock_channel.send.assert_called_once_with("asdf", suppress_embeds=True)
207+
await sync_to_async(dm.refresh_from_db)()
208+
assert dm.sent_at is not None
209+
end = timezone.now()
210+
assert start < dm.sent_at < end

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,5 @@ dependencies = [
1919
"whitenoise>=6.8.2",
2020
"gunicorn>=23.0.0",
2121
"django-stubs>=5.1.1",
22+
"pdbpp>=0.10.3",
2223
]

uv.lock

Lines changed: 62 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)