Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add unit tests #161

Merged
merged 26 commits into from
Nov 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
fb834f3
Move Memebot code to `memebot` package
super-cooper Jun 7, 2023
3d6e720
Update test requirements to support new tests
super-cooper Jun 7, 2023
3129334
Update docker-compose.yaml and README to reflect new `memebot` package
super-cooper Jun 7, 2023
d61e6bf
Update Dockerfile to use new `memebot` package
super-cooper Jun 7, 2023
7e0b103
Add initial test fixtures
super-cooper Jun 7, 2023
fe0de15
Add tests for `/hello` command
super-cooper Jun 7, 2023
f936cc8
Add tests for `/poll` command
super-cooper Jun 7, 2023
57d626c
Add tests for `/role` command
super-cooper Jun 7, 2023
57a89b8
Add tests for Twitter integration
super-cooper Jun 7, 2023
9e29794
Update mypy config to ignore `discord.ext.commands`
super-cooper Jun 7, 2023
29789c6
Update pytest workflow
super-cooper Jun 7, 2023
3933fdf
Update README with instructions on running tests
super-cooper Jun 7, 2023
5df38fb
Update pytest action to remove unnecessary permission
super-cooper Jun 7, 2023
1540442
Fix lint
super-cooper Jun 7, 2023
328b108
Exclude tests from mypy
super-cooper Jun 7, 2023
c399be5
Rename pytest job
super-cooper Jun 7, 2023
0bea15f
Fix broken pytest job
super-cooper Jun 7, 2023
4a7be89
Lazily instantiate Discord client
super-cooper May 6, 2024
821e07e
Run all pytests by default
super-cooper May 6, 2024
48aa925
Use constant display name in test for `hello.py`
super-cooper May 6, 2024
fbc3993
Use static answer matrix in single-answer test for `poll.py`
super-cooper May 6, 2024
19179ad
Remove redundant check for typing module
super-cooper May 6, 2024
7cd0adf
Remove stale script entry from `.dockerignore`
super-cooper May 6, 2024
4bfd634
Fix lint
super-cooper May 6, 2024
74ff612
Remove helpers from `/role` tests
super-cooper Nov 12, 2024
8470738
Fix failing `/role` tests
super-cooper Nov 12, 2024
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
1 change: 0 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,4 @@
.mypy_cache/
data/
docker/
install.bash
venv/
14 changes: 2 additions & 12 deletions .github/workflows/pytest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,9 @@ on:
pull_request:
branches: ["master"]

permissions:
contents: read

jobs:
build:

test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
- name: Set up Python 3.9
Expand All @@ -27,12 +22,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest
pip install -r requirements.txt -r tests/requirements.txt
- name: Test with pytest
env:
MEMEBOT_DISCORD_CLIENT_TOKEN: ${{ secrets.MEMEBOT_DISCORD_CLIENT_TOKEN }}
MEMEBOT_TWITTER_CONSUMER_KEY: ${{ secrets.MEMEBOT_TWITTER_CONSUMER_KEY }}
MEMEBOT_TWITTER_CONSUMER_SECRET: ${{ secrets.MEMEBOT_TWITTER_CONSUMER_SECRET }}
run: |
pytest
pytest --tb=long -v
34 changes: 28 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,28 @@ Leaving variables empty just means that default values will be used.

## Tests

### pytest

Memebot has a suite of unit tests based on [`pytest`](https://pytest.org). The test code
is located in the [tests](./tests) directory. Running the tests is straightforward:

```shell
$ python3 -m pytest [/path/to/test/package/or/module]
```

Running the above from the root of the repository with no path(s) specified will run
all the tests.

The tests can also be run from the _test_ Docker image:

```shell
$ docker run --rm -it --entrypoint python3 memebot:test -m pytest [/path/to/test/package/or/module]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't running all the tests for me:

$ docker run --rm -it --entrypoint python3 memebot:test -m pytest
================================================= test session starts ==================================================
platform linux -- Python 3.9.6, pytest-7.1.2, pluggy-1.0.0
rootdir: /opt/memebot
collected 1 item

tests/test_pytest.py .                                                                                           [100%]

================================================== 1 passed in 0.01s ===================================================

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed by copying the tests into the test image


# OR

$ docker-compose run --rm --entrypoint python3 bot -m pytest [/path/to/test/package/or/module]
```

### mypy

Memebot uses static type checking from [mypy](http://mypy-lang.org) to improve code correctness. The config
Expand All @@ -90,31 +112,31 @@ To run mypy locally, ensure it is installed to the same python environment as al
Memebot dependencies, and then run it using the proper interpreter.

```shell
$ venv/bin/mypy src
$ venv/bin/mypy memebot

# OR

$ source venv/bin/activate
$ mypy src
$ mypy memebot
```

To run mypy in Docker, ensure you are using an image built from the `test` target.

```shell
$ docker run --rm -it --entrypoint mypy memebot:test src
$ docker run --rm -it --entrypoint mypy memebot:test memebot

# OR

$ docker-compose run --rm --entrypoint mypy bot src
$ docker-compose run --rm --entrypoint mypy bot memebot
```

You can speed up subsequent runs of mypy by mounting the `.mypy-cache` directory as a volume.
This way, mypy can reuse the cache it generates inside the container on the next run.

```shell
$ docker run --rm --volume "$(pwd)/.mypy_cache:/opt/memebot/.mypy_cache" --entrypoint mypy -it memebot:test src
$ docker run --rm --volume "$(pwd)/.mypy_cache:/opt/memebot/.mypy_cache" --entrypoint mypy -it memebot:test memebot

# OR

$ docker-compose run --rm --volume "$(pwd)/.mypy_cache:/opt/memebot/.mypy_cache" --entrypoint mypy bot src
$ docker-compose run --rm --volume "$(pwd)/.mypy_cache:/opt/memebot/.mypy_cache" --entrypoint mypy bot memebot
```
2 changes: 1 addition & 1 deletion docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ services:
restart: always
volumes:
- ./data/db:/data/db
- ./src/config/mongod.yaml:/etc/mongo/mongod.yaml:ro
- ./memebot/config/mongod.yaml:/etc/mongo/mongod.yaml:ro
networks:
default:
dns:
Expand Down
5 changes: 3 additions & 2 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM python:3.9.6-slim as build

Check warning on line 1 in docker/Dockerfile

View workflow job for this annotation

GitHub Actions / build

The 'as' keyword should match the case of the 'from' keyword

FromAsCasing: 'as' and 'FROM' keywords' casing do not match More info: https://docs.docker.com/go/dockerfile/rule/from-as-casing/
WORKDIR /opt/memebot/

# Install requirements.
Expand All @@ -15,19 +15,20 @@

FROM build as build-test
COPY tests tests
RUN python3 -m pip install --no-cache-dir -r tests/requirements.txt

Check warning on line 18 in docker/Dockerfile

View workflow job for this annotation

GitHub Actions / build

The 'as' keyword should match the case of the 'from' keyword

FromAsCasing: 'as' and 'FROM' keywords' casing do not match More info: https://docs.docker.com/go/dockerfile/rule/from-as-casing/

# Prepare run image.
FROM python:3.9.6-slim as run-base
WORKDIR /opt/memebot/
ENV PATH="/opt/venv/bin:$PATH"

Check warning on line 23 in docker/Dockerfile

View workflow job for this annotation

GitHub Actions / build

The 'as' keyword should match the case of the 'from' keyword

FromAsCasing: 'as' and 'FROM' keywords' casing do not match More info: https://docs.docker.com/go/dockerfile/rule/from-as-casing/
COPY src src
ENTRYPOINT ["python3", "src/main.py"]
COPY memebot memebot
ENTRYPOINT ["python3", "memebot/main.py"]

# Run release build.
FROM run-base as release
COPY --from=build /opt/venv /opt/venv

Check warning on line 30 in docker/Dockerfile

View workflow job for this annotation

GitHub Actions / build

The 'as' keyword should match the case of the 'from' keyword

FromAsCasing: 'as' and 'FROM' keywords' casing do not match More info: https://docs.docker.com/go/dockerfile/rule/from-as-casing/
# Run test build.
FROM run-base as test
COPY tests tests
COPY --from=build-test /opt/venv /opt/venv

Check warning on line 34 in docker/Dockerfile

View workflow job for this annotation

GitHub Actions / build

The 'as' keyword should match the case of the 'from' keyword

FromAsCasing: 'as' and 'FROM' keywords' casing do not match More info: https://docs.docker.com/go/dockerfile/rule/from-as-casing/
File renamed without changes.
48 changes: 26 additions & 22 deletions src/memebot.py → memebot/client.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import functools
import logging

import discord
import discord.ext.commands

import commands
import config
import db
import log
from integrations import twitter
from lib import exception, util
from memebot import commands
from memebot import config
from memebot import db
from memebot import log
from memebot.integrations import twitter
from memebot.lib import exception, util


async def on_ready() -> None:
"""
Determines what the bot does as soon as it is logged into discord
"""
memebot = get_memebot()
if not memebot.user:
raise exception.MemebotInternalError("Memebot is not logged in to Discord")
log.info(f"Logged in as {memebot.user}")
Expand All @@ -36,7 +38,7 @@ async def on_interaction(interaction: discord.Interaction) -> None:
"""
log.interaction(
interaction,
f"{util.parse_invocation(interaction.data)} from {interaction.user}",
f"{util.parse_invocation(interaction)} from {interaction.user}",
)


Expand All @@ -48,7 +50,7 @@ async def on_command_error(
log.exception(error)
return

invocation = util.parse_invocation(interaction.data)
invocation = util.parse_invocation(interaction)

if isinstance(error, exception.MemebotInternalError):
# For intentionally thrown internal errors
Expand Down Expand Up @@ -84,20 +86,22 @@ async def on_command_error(
)


memebot = discord.ext.commands.Bot(
command_prefix="!",
intents=discord.Intents().all(),
activity=discord.Game(name="• /hello"),
)
@functools.cache
def get_memebot() -> discord.ext.commands.Bot:
new_memebot = discord.ext.commands.Bot(
command_prefix="/",
intents=discord.Intents().all(),
activity=discord.Game(name="• /hello"),
)

memebot.tree.add_command(commands.hello)
memebot.tree.add_command(commands.poll)
memebot.tree.add_command(commands.role)
new_memebot.tree.add_command(commands.hello)
new_memebot.tree.add_command(commands.poll)
new_memebot.tree.add_command(commands.role)

memebot.add_listener(on_ready)
memebot.add_listener(on_interaction)
memebot.tree.error(on_command_error)
if config.twitter_enabled:
memebot.add_listener(twitter.process_message_for_interaction, "on_message")
new_memebot.add_listener(on_ready)
new_memebot.add_listener(on_interaction)
new_memebot.tree.error(on_command_error)
if config.twitter_enabled:
new_memebot.add_listener(twitter.process_message_for_interaction, "on_message")

run = memebot.run
return new_memebot
File renamed without changes.
File renamed without changes.
5 changes: 3 additions & 2 deletions src/commands/poll.py → memebot/commands/poll.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import discord.ext.commands
import emoji

from lib import constants, exception
from memebot.lib import constants, exception


@discord.app_commands.command(
Expand Down Expand Up @@ -35,7 +35,8 @@ async def poll(
yes_no = False
if len(choices) == 1:
raise exception.MemebotUserError(
f"_Only 1 choice provided. {interaction.command.qualified_name} requires either 0 or 2+ choices!_"
f"_Only 1 choice provided. {interaction.command.qualified_name} "
f"requires either 0 or 2+ choices!_"
)
elif len(choices) == 0 or [c.lower() for c in choices] in (
["yes", "no"],
Expand Down
30 changes: 21 additions & 9 deletions src/commands/role.py → memebot/commands/role.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
from code import interact
from typing import Optional, Any, Union, Annotated
from typing import Optional, Any, Union

import discord

from lib import exception
from memebot.lib import exception


class RoleActionError(exception.MemebotUserError):
Expand Down Expand Up @@ -37,6 +36,18 @@ def __init__(self, action: str, target_name: str, *args: Any) -> None:
)


class RoleFailure(exception.MemebotInternalError):
def __init__(self, action: str, target_name: str, *args: Any) -> None:
"""
Unexpected, but handled internal failure
"""
super(RoleFailure, self).__init__(
f"Failed to {action} role `@{target_name}. "
f"It seems that Discord's API is having problems.",
*args,
)


class RoleLocationError(exception.MemebotUserError):
"""
Generic message for role commands which are executed in DMs
Expand Down Expand Up @@ -106,8 +117,8 @@ async def create(interaction: discord.Interaction, role_name: str) -> None:
)
except discord.Forbidden:
raise RolePermissionError("create", target_name)
except (discord.HTTPException, TypeError):
raise RoleActionError("create", target_name)
except discord.HTTPException:
raise RoleFailure("create", target_name)

await interaction.response.send_message(f"Created new role {new_role.mention}!")

Expand All @@ -133,7 +144,7 @@ async def delete(
except discord.Forbidden:
raise RolePermissionError("delete", target_role.name)
except discord.HTTPException:
raise RoleActionError("delete", target_role.name)
raise RoleFailure("delete", target_role.name)

await interaction.response.send_message(f"Deleted role `@{target_role.name}`")

Expand Down Expand Up @@ -161,7 +172,7 @@ async def join(
except discord.Forbidden:
raise RolePermissionError("join", target_role.name)
except discord.HTTPException:
raise RoleActionError("join", target_role.name)
raise RoleFailure("join", target_role.name)

await interaction.response.send_message(
f"{author.name} successfully joined `@{target_role.name}`"
Expand Down Expand Up @@ -192,7 +203,7 @@ async def leave(
except discord.Forbidden:
raise RolePermissionError("leave", target_role.name)
except discord.HTTPException:
raise RoleActionError("leave", target_role.name)
raise RoleFailure("leave", target_role.name)

await interaction.response.send_message(
f"{author.name} successfully left `@{target_role.name}`"
Expand All @@ -205,7 +216,8 @@ async def role_list(
target: Optional[Union[discord.Role, discord.Member]],
) -> None:
"""
List all roles managed by Memebot, or all members of a role managed by Memebot.
List all roles managed by Memebot, all managed roles of which a user is a member,
or all members of a role managed by Memebot.
"""
if not interaction.guild:
raise RoleLocationError
Expand Down
5 changes: 1 addition & 4 deletions src/config/__init__.py → memebot/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import os
import urllib.parse

from config import validators
from memebot.config import validators

# Discord API token
discord_api_token: str
Expand Down Expand Up @@ -148,6 +148,3 @@ def populate_config_from_command_line() -> None:
global database_uri
database_enabled = args.database_enabled
database_uri = args.database_uri


populate_config_from_command_line()
File renamed without changes.
1 change: 1 addition & 0 deletions src/config/validators.py → memebot/config/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Functions that will validate string input from the command line and convert them into appropriate data for use
by the config module.
"""

import collections
import logging
import logging.handlers
Expand Down
2 changes: 1 addition & 1 deletion src/db/__init__.py → memebot/db/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import config
from memebot import config
from .internals import DatabaseInternals

db_internals = DatabaseInternals()
Expand Down
2 changes: 1 addition & 1 deletion src/db/internals.py → memebot/db/internals.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import pymongo as mongo

import config
from memebot import config


class DatabaseInternals:
Expand Down
File renamed without changes.
Loading
Loading