Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: Tests

on:
push:
branches: [main]
pull_request:

jobs:
pytest:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11"]

steps:
- uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip

- name: Install dependencies
run: pip install -r requirements-dev.txt

- name: Run tests
run: pytest -v
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
config.toml
__pycache__
bot.log
.pytest_cache
120 changes: 120 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""Root conftest.py.

Every module under ``comandos/`` (and ``configuration.py`` itself) calls
``Config()`` at *import time*, and ``Config.__init__`` reads ``config.toml``
from the current working directory, exiting the process if it's missing or
incomplete. ``config.toml`` is git-ignored (it holds real bot secrets), so it
won't exist in a fresh checkout or in CI.

To make the test modules importable at all, we build a throwaway
``config.toml`` plus the ``logs/`` directory it expects, and ``chdir`` into
that sandbox *before* pytest imports any test module. This has to happen as
plain module-level code (not inside a fixture) because pytest imports
conftest.py files before it collects/imports the test modules that in turn
``import comandos.flood`` etc.
"""
import os
import shutil
import tempfile
from pathlib import Path

import pytest

_REPO_ROOT = Path(__file__).resolve().parent

_SANDBOX = Path(tempfile.mkdtemp(prefix="pyes-bot-tests-"))
(_SANDBOX / "logs").mkdir()
(_SANDBOX / "config.toml").write_text(
"""
[bot]
token = "test-token"
id = 111111111111111111
log_file = "logs/bot_log.csv"

[moderation]
log_file = "logs/mod_log.csv"
channel_id = 222222222222222222
role = "Coordinacion"
muted_role = "Muted"

[server]
guild = 333333333333333333

[channels]
[channels.eventos]
main = 444444444444444444
moderation = 555555555555555555
submission = 666666666666666666
"""
)

# A couple of commands read files via relative paths at call time (not
# import time) - e.g. ping.py's "resources/llama.gif". Make sure those are
# still reachable from the sandboxed working directory.
if (_REPO_ROOT / "resources").is_dir():
shutil.copytree(_REPO_ROOT / "resources", _SANDBOX / "resources")

os.chdir(_SANDBOX)


@pytest.fixture(scope="session")
def sandbox_dir():
"""The temporary directory tests are running from."""
return _SANDBOX


@pytest.fixture(scope="session")
def repo_root():
"""The actual repository root, for tests that need real project files."""
return _REPO_ROOT


@pytest.fixture
def config():
"""The (singleton) Config instance, backed by the sandboxed config.toml."""
from configuration import Config

return Config()


@pytest.fixture
def isolated_logs(config, tmp_path, monkeypatch):
"""Point every log file Config knows about at a fresh, empty file.

Config is a singleton shared across the whole test session, and several
of its log paths are appended to by the code under test (e.g.
``add_spam_message``). Without this, one test's writes would leak into
the next test that also touches those files.
"""
logs_dir = tmp_path / "logs"
logs_dir.mkdir()

for attr, filename in [
("log_spam_file", "spam_log.csv"),
("log_image_spam_file", "image_spam_log.csv"),
("log_mod_file", "mod_log.csv"),
("log_accepted_file", "mod_log_accepted.csv"),
("log_rejected_file", "mod_log_rejected.csv"),
("log_main_file", "main_log.csv"),
("log_file", "bot_log.csv"),
]:
path = logs_dir / filename
path.write_text("\n")
monkeypatch.setattr(config, attr, path)

return config


@pytest.fixture(autouse=True)
def patched_message_delete(monkeypatch):
"""The production code deletes messages via ``discord.Message.delete(message)``
(an unbound call on the real class) rather than ``message.delete()``, so
fakes that aren't real ``discord.Message`` instances need this patched to
avoid hitting real discord.py internals/HTTP calls.
"""
import discord
from unittest.mock import AsyncMock

mock_delete = AsyncMock()
monkeypatch.setattr(discord.Message, "delete", mock_delete)
return mock_delete
4 changes: 4 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[pytest]
asyncio_mode = auto
filterwarnings =
ignore:'asyncio.iscoroutinefunction' is deprecated:DeprecationWarning:discord.ext.commands.core
4 changes: 4 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-r requirements.txt

pytest
pytest-asyncio
Empty file added tests/__init__.py
Empty file.
59 changes: 59 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import pandas as pd
import pytest

from comandos.flood import FloodSpam
from comandos.moderacion import Moderacion
from tests.factories import make_bot, make_role, make_text_channel


@pytest.fixture
def coord_role():
return make_role(name="Coordinacion", id=1)


@pytest.fixture
def muted_role():
return make_role(name="Muted", id=2)


@pytest.fixture
def mod_channel():
return make_text_channel(id=999, name="mod-general")


@pytest.fixture
def flood_cog(isolated_logs, coord_role, muted_role, mod_channel):
"""A FloodSpam cog wired up the way on_ready() would, without needing a
real discord.Client/Guild."""
bot = make_bot(channels={mod_channel.id: mod_channel})
cog = FloodSpam(bot)
cog._coord_role = coord_role
cog._muted_role = muted_role
cog._main_mod_channel = mod_channel
return cog


@pytest.fixture
def moderacion_channels(config):
ids = config.CHANNELS["eventos"]
return {
"main": make_text_channel(id=ids["main"], name="eventos"),
"mod": make_text_channel(id=ids["moderation"], name="eventos-mod"),
"sub": make_text_channel(id=ids["submission"], name="envio-eventos"),
}


@pytest.fixture
async def moderacion_cog(isolated_logs, moderacion_channels):
"""A Moderacion cog wired up the way on_ready() would (channel mapping
populated from config.CHANNELS), with an empty ``data_mod`` table."""
from types import SimpleNamespace

channels = {c.id: c for c in moderacion_channels.values()}
bot = make_bot(channels=channels, guild=SimpleNamespace(id=333333333333333333))
bot.data_mod = pd.DataFrame(
columns=["date", "message_id", "channel", "author_id", "author", "message"]
)
cog = Moderacion(bot)
await cog.on_ready()
return cog
Loading
Loading